diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs
index 7db59bb68..746ee4b19 100644
--- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs
+++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs
@@ -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);
}
@@ -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)
diff --git a/Source/Client/ConstantTicker.cs b/Source/Client/ConstantTicker.cs
index aa3bbb499..521478bd6 100644
--- a/Source/Client/ConstantTicker.cs
+++ b/Source/Client/ConstantTicker.cs
@@ -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)
diff --git a/Source/Client/MultiplayerGame.cs b/Source/Client/MultiplayerGame.cs
index 36598d785..d3651bbf4 100644
--- a/Source/Client/MultiplayerGame.cs
+++ b/Source/Client/MultiplayerGame.cs
@@ -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);
diff --git a/Source/Client/Networking/State/ClientBaseState.cs b/Source/Client/Networking/State/ClientBaseState.cs
index 0eb6bc1ba..fea761311 100644
--- a/Source/Client/Networking/State/ClientBaseState.cs
+++ b/Source/Client/Networking/State/ClientBaseState.cs
@@ -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;
diff --git a/Source/Client/Networking/State/ClientJoiningState.cs b/Source/Client/Networking/State/ClientJoiningState.cs
index fbeb4ff8d..ef1653634 100644
--- a/Source/Client/Networking/State/ClientJoiningState.cs
+++ b/Source/Client/Networking/State/ClientJoiningState.cs
@@ -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
diff --git a/Source/Client/Patches/GravshipTravelSessionPatches.cs b/Source/Client/Patches/GravshipTravelSessionPatches.cs
index 4939f821c..f1ecbe54b 100644
--- a/Source/Client/Patches/GravshipTravelSessionPatches.cs
+++ b/Source/Client/Patches/GravshipTravelSessionPatches.cs
@@ -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;
}
diff --git a/Source/Client/Patches/TickPatch.cs b/Source/Client/Patches/TickPatch.cs
index 9c41c43d7..1616c85d1 100644
--- a/Source/Client/Patches/TickPatch.cs
+++ b/Source/Client/Patches/TickPatch.cs
@@ -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);
diff --git a/Source/Client/Patches/VTRSyncPatch.cs b/Source/Client/Patches/VTRSyncPatch.cs
index 10ab17105..4e2a9c42c 100644
--- a/Source/Client/Patches/VTRSyncPatch.cs
+++ b/Source/Client/Patches/VTRSyncPatch.cs
@@ -2,6 +2,7 @@
using HarmonyLib;
using Multiplayer.Client.Util;
using Multiplayer.Common;
+using Multiplayer.Common.Networking.Packet;
using RimWorld.Planet;
using Verse;
@@ -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)
diff --git a/Source/Client/Saving/SaveLoad.cs b/Source/Client/Saving/SaveLoad.cs
index 37f5ab661..7d5ec2fe2 100644
--- a/Source/Client/Saving/SaveLoad.cs
+++ b/Source/Client/Saving/SaveLoad.cs
@@ -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;
@@ -240,6 +242,62 @@ void Send()
else
Send();
}
+
+ ///
+ /// Send per-map standalone snapshots to the server for all maps in the given snapshot.
+ /// Called after autosave when connected to a standalone server.
+ ///
+ 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()));
+ }
+ }
+
+ ///
+ /// Send the world + session standalone snapshot to the server.
+ /// Called after autosave when connected to a standalone server.
+ ///
+ 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();
+
+ var packet = new ClientStandaloneWorldSnapshotPacket
+ {
+ tick = tick,
+ leaseVersion = 0,
+ worldData = worldCompressed,
+ sessionData = sessionCompressed,
+ sha256Hash = hash,
+ };
+
+ OnMainThread.Enqueue(() => Multiplayer.Client?.SendFragmented(packet.Serialize()));
+ }
}
}
diff --git a/Source/Client/Session/Autosaving.cs b/Source/Client/Session/Autosaving.cs
index 35f8dc22e..dae2810de 100644
--- a/Source/Client/Session/Autosaving.cs
+++ b/Source/Client/Session/Autosaving.cs
@@ -2,6 +2,7 @@
using System.IO;
using System.Linq;
using Multiplayer.Common;
+using Multiplayer.Common.Networking.Packet;
using RimWorld;
using UnityEngine;
using Verse;
@@ -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);
}
@@ -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;
}
}
}
diff --git a/Source/Client/Session/MultiplayerSession.cs b/Source/Client/Session/MultiplayerSession.cs
index ff1be9d43..29b8f4890 100644
--- a/Source/Client/Session/MultiplayerSession.cs
+++ b/Source/Client/Session/MultiplayerSession.cs
@@ -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);
@@ -64,6 +68,8 @@ public void ApplyBootstrapState(ServerBootstrapPacket packet) =>
public void Stop()
{
+ isStandaloneServer = false;
+
if (client != null)
{
client.Close(MpDisconnectReason.Internal);
diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.SettingsUi.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.SettingsUi.cs
index 3fb11ac95..9a45d1b30 100644
--- a/Source/Client/Windows/BootstrapConfiguratorWindow.SettingsUi.cs
+++ b/Source/Client/Windows/BootstrapConfiguratorWindow.SettingsUi.cs
@@ -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;
@@ -143,6 +145,7 @@ private void StartUploadSettingsToml()
{
try
{
+ settings.EnforceStandaloneRequirements(isStandaloneServer: true);
connection.Send(new ClientBootstrapSettingsPacket(settings));
OnMainThread.Enqueue(() =>
diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs
index 822d2c1bd..c73e15d07 100644
--- a/Source/Client/Windows/BootstrapConfiguratorWindow.cs
+++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs
@@ -72,6 +72,7 @@ public BootstrapConfiguratorWindow(ConnectionBase connection)
settings.directAddress = $"0.0.0.0:{MultiplayerServer.DefaultPort}";
settings.steam = false;
settings.arbiter = false;
+ settings.EnforceStandaloneRequirements(isStandaloneServer: true);
settingsUiBuffers.MaxPlayersBuffer = settings.maxPlayers.ToString();
settingsUiBuffers.AutosaveBuffer = settings.autosaveInterval.ToString();
diff --git a/Source/Client/Windows/SaveGameWindow.cs b/Source/Client/Windows/SaveGameWindow.cs
index 4e6973804..80ef5ae09 100644
--- a/Source/Client/Windows/SaveGameWindow.cs
+++ b/Source/Client/Windows/SaveGameWindow.cs
@@ -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;
@@ -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();
}
}
diff --git a/Source/Common/JoinPointRequestReason.cs b/Source/Common/JoinPointRequestReason.cs
new file mode 100644
index 000000000..4c3359896
--- /dev/null
+++ b/Source/Common/JoinPointRequestReason.cs
@@ -0,0 +1,8 @@
+namespace Multiplayer.Common;
+
+public enum JoinPointRequestReason : byte
+{
+ Unknown = 0,
+ Save = 1,
+ WorldTravel = 2,
+}
\ No newline at end of file
diff --git a/Source/Common/MultiplayerServer.cs b/Source/Common/MultiplayerServer.cs
index 88ddbc00b..27dc3553e 100644
--- a/Source/Common/MultiplayerServer.cs
+++ b/Source/Common/MultiplayerServer.cs
@@ -74,6 +74,7 @@ static MultiplayerServer()
public int NetTimer { get; private set; }
public bool IsStandaloneServer { get; set; }
+ public StandalonePersistence? persistence;
public MultiplayerServer(ServerSettings settings)
{
diff --git a/Source/Common/Networking/Packet/AutosavingPacket.cs b/Source/Common/Networking/Packet/AutosavingPacket.cs
new file mode 100644
index 000000000..1b0b55b1a
--- /dev/null
+++ b/Source/Common/Networking/Packet/AutosavingPacket.cs
@@ -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)
+ {
+ buf.BindEnum(ref reason);
+ }
+}
\ No newline at end of file
diff --git a/Source/Common/Networking/Packet/ProtocolPacket.cs b/Source/Common/Networking/Packet/ProtocolPacket.cs
index 98b70963e..6b031968a 100644
--- a/Source/Common/Networking/Packet/ProtocolPacket.cs
+++ b/Source/Common/Networking/Packet/ProtocolPacket.cs
@@ -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);
+ buf.Bind(ref autosaveInterval);
+ buf.BindEnum(ref autosaveUnit);
}
}
diff --git a/Source/Common/Networking/Packet/StandaloneSnapshotPackets.cs b/Source/Common/Networking/Packet/StandaloneSnapshotPackets.cs
new file mode 100644
index 000000000..57144715b
--- /dev/null
+++ b/Source/Common/Networking/Packet/StandaloneSnapshotPackets.cs
@@ -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);
+ }
+}
\ No newline at end of file
diff --git a/Source/Common/Networking/Packets.cs b/Source/Common/Networking/Packets.cs
index 5463ef136..2a97af75a 100644
--- a/Source/Common/Networking/Packets.cs
+++ b/Source/Common/Networking/Packets.cs
@@ -34,6 +34,8 @@ public enum Packets : byte
Client_RequestRejoin,
Client_SetFaction,
Client_FrameTime,
+ Client_StandaloneWorldSnapshotUpload,
+ Client_StandaloneMapSnapshotUpload,
// Joining
Server_ProtocolOk,
diff --git a/Source/Common/Networking/State/ServerJoiningState.cs b/Source/Common/Networking/State/ServerJoiningState.cs
index 34d9d8d89..ea280b292 100644
--- a/Source/Common/Networking/State/ServerJoiningState.cs
+++ b/Source/Common/Networking/State/ServerJoiningState.cs
@@ -1,5 +1,6 @@
using System;
using System.IO;
+using System.Linq;
using System.Threading.Tasks;
using Multiplayer.Common.Networking.Packet;
@@ -28,7 +29,10 @@ protected override async Task RunState()
if (Server.settings.pauseOnJoin)
Server.commands.PauseAll();
- if (Server.settings.autoJoinPoint.HasFlag(AutoJoinPointFlags.Join))
+ // On standalone, only request a fresh join point when another player is already active.
+ // For the normal first join, serve the persisted state immediately instead of blocking on WaitJoinPoint.
+ if ((Server.IsStandaloneServer && Server.PlayingPlayers.Any()) ||
+ (!Server.IsStandaloneServer && Server.settings.autoJoinPoint.HasFlag(AutoJoinPointFlags.Join)))
Server.worldData.TryStartJoinPointCreation();
Server.playerManager.OnJoin(Player);
@@ -45,7 +49,11 @@ private void HandleProtocol(ClientProtocolPacket packet)
Player.Disconnect(MpDisconnectReason.Protocol, ByteWriter.GetBytes(MpVersion.Version, MpVersion.Protocol));
else
{
- Player.SendPacket(new ServerProtocolOkPacket(Server.settings.hasPassword));
+ Player.SendPacket(new ServerProtocolOkPacket(Server.settings.hasPassword, Server.IsStandaloneServer)
+ {
+ autosaveInterval = Server.settings.autosaveInterval,
+ autosaveUnit = Server.settings.autosaveUnit
+ });
if (Server.BootstrapMode)
{
diff --git a/Source/Common/Networking/State/ServerPlayingState.cs b/Source/Common/Networking/State/ServerPlayingState.cs
index c6d22e298..82ff7369f 100644
--- a/Source/Common/Networking/State/ServerPlayingState.cs
+++ b/Source/Common/Networking/State/ServerPlayingState.cs
@@ -83,10 +83,11 @@ public void HandleChat(ClientChatPacket packet)
[PacketHandler(Packets.Client_WorldDataUpload, allowFragmented: true)]
public void HandleWorldDataUpload(ByteReader data)
{
- if (Server.ArbiterPlaying ? !Player.IsArbiter : !Player.IsHost) // policy
+ // On standalone, accept from any playing client; otherwise only host/arbiter
+ if (!Server.IsStandaloneServer && (Server.ArbiterPlaying ? !Player.IsArbiter : !Player.IsHost))
return;
- ServerLog.Log($"Got world upload {data.Left}");
+ ServerLog.Detail($"Got world upload {data.Left}");
Server.worldData.mapData = new Dictionary();
@@ -104,6 +105,54 @@ public void HandleWorldDataUpload(ByteReader data)
Server.worldData.EndJoinPointCreation();
}
+ [TypedPacketHandler]
+ public void HandleStandaloneWorldSnapshot(ClientStandaloneWorldSnapshotPacket packet)
+ {
+ if (!Server.IsStandaloneServer)
+ return;
+
+ if (!Player.IsPlaying)
+ return;
+
+ var accepted = Server.worldData.TryAcceptStandaloneWorldSnapshot(Player, packet.tick, packet.leaseVersion,
+ packet.worldData, packet.sessionData, packet.sha256Hash);
+
+ if (accepted)
+ {
+ ServerLog.Detail(
+ $"Accepted standalone world snapshot tick={packet.tick} lease={packet.leaseVersion} from {Player.Username}");
+ }
+ else
+ {
+ ServerLog.Detail(
+ $"Rejected standalone world snapshot tick={packet.tick} lease={packet.leaseVersion} from {Player.Username}");
+ }
+ }
+
+ [TypedPacketHandler]
+ public void HandleStandaloneMapSnapshot(ClientStandaloneMapSnapshotPacket packet)
+ {
+ if (!Server.IsStandaloneServer)
+ return;
+
+ if (!Player.IsPlaying)
+ return;
+
+ var accepted = Server.worldData.TryAcceptStandaloneMapSnapshot(Player, packet.mapId, packet.tick,
+ packet.leaseVersion, packet.mapData, packet.sha256Hash);
+
+ if (accepted)
+ {
+ ServerLog.Detail(
+ $"Accepted standalone map snapshot map={packet.mapId} tick={packet.tick} lease={packet.leaseVersion} from {Player.Username}");
+ }
+ else
+ {
+ ServerLog.Detail(
+ $"Rejected standalone map snapshot map={packet.mapId} tick={packet.tick} lease={packet.leaseVersion} from {Player.Username}");
+ }
+ }
+
[TypedPacketHandler]
public void HandleCursor(ClientCursorPacket clientPacket)
{
@@ -162,12 +211,18 @@ public void HandleFreeze(ClientFreezePacket packet)
Player.unfrozenAt = Server.NetTimer;
}
- [PacketHandler(Packets.Client_Autosaving)]
- public void HandleAutosaving(ByteReader data)
+ [TypedPacketHandler]
+ public void HandleAutosaving(ClientAutosavingPacket packet)
{
- // Host policy
- if (Player.IsHost && Server.settings.autoJoinPoint.HasFlag(AutoJoinPointFlags.Autosave))
- Server.worldData.TryStartJoinPointCreation();
+ var forceJoinPoint = packet.reason == JoinPointRequestReason.Save;
+
+ ServerLog.Detail($"Received Client_Autosaving from {Player.Username}, standalone={Server.IsStandaloneServer}, isHost={Player.IsHost}, reason={packet.reason}, force={forceJoinPoint}");
+
+ // On standalone, any playing client can trigger a join point (always, regardless of settings)
+ // On hosted, only the host can trigger and only if the Autosave flag is set
+ if (Server.IsStandaloneServer ||
+ (Player.IsHost && Server.settings.autoJoinPoint.HasFlag(AutoJoinPointFlags.Autosave)))
+ Server.worldData.TryStartJoinPointCreation(forceJoinPoint);
}
[TypedPacketHandler]
diff --git a/Source/Common/PlayerManager.cs b/Source/Common/PlayerManager.cs
index cc3da9284..a56eb2d8c 100644
--- a/Source/Common/PlayerManager.cs
+++ b/Source/Common/PlayerManager.cs
@@ -136,7 +136,10 @@ public void OnDesync(ServerPlayer player, int tick, int diffAt)
public void OnJoin(ServerPlayer player)
{
player.hasJoined = true;
- player.FactionId = player.id == 0 || !server.settings.multifaction ?
+ var standalonePrimaryPlayer = server.IsStandaloneServer &&
+ !server.JoinedPlayers.Any(p => p != player && !p.IsArbiter);
+
+ player.FactionId = standalonePrimaryPlayer || player.id == 0 || !server.settings.multifaction ?
server.worldData.hostFactionId :
server.worldData.spectatorFactionId;
diff --git a/Source/Common/ServerSettings.cs b/Source/Common/ServerSettings.cs
index e651e09cf..140807ddb 100644
--- a/Source/Common/ServerSettings.cs
+++ b/Source/Common/ServerSettings.cs
@@ -31,6 +31,12 @@ public class ServerSettings
public bool pauseOnDesync = true;
public TimeControl timeControl;
+ public void EnforceStandaloneRequirements(bool isStandaloneServer)
+ {
+ if (isStandaloneServer && multifaction)
+ asyncTime = true;
+ }
+
public string? TryParseEndpoints(out IPEndPoint[] endpoints)
{
var split = directAddress.Split(MultiplayerServer.EndpointSeparator);
diff --git a/Source/Common/StandalonePersistence.cs b/Source/Common/StandalonePersistence.cs
new file mode 100644
index 000000000..20a7e7184
--- /dev/null
+++ b/Source/Common/StandalonePersistence.cs
@@ -0,0 +1,320 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+
+namespace Multiplayer.Common;
+
+///
+/// Manages the Saved/ directory for standalone server durable persistence.
+/// Uses atomic temp-file + rename pattern for crash safety.
+///
+public class StandalonePersistence
+{
+ public string SavedDir { get; }
+
+ private string MapsDir => Path.Combine(SavedDir, "maps");
+ private string WorldPath => Path.Combine(SavedDir, "world.dat");
+ private string WorldCmdsPath => Path.Combine(SavedDir, "world_cmds.dat");
+ private string SessionPath => Path.Combine(SavedDir, "session.dat");
+ private string InfoPath => Path.Combine(SavedDir, "info.xml");
+ private string StatePath => Path.Combine(SavedDir, "state.bin");
+
+ public StandalonePersistence(string baseDir)
+ {
+ SavedDir = Path.Combine(baseDir, "Saved");
+ }
+
+ public void EnsureDirectories()
+ {
+ Directory.CreateDirectory(SavedDir);
+ Directory.CreateDirectory(MapsDir);
+ }
+
+ public bool HasValidState()
+ {
+ return File.Exists(WorldPath);
+ }
+
+ ///
+ /// Seed the Saved/ directory from a save.zip (replay format).
+ ///
+ public void SeedFromSaveZip(string zipPath)
+ {
+ EnsureDirectories();
+
+ using var zip = ZipFile.OpenRead(zipPath);
+
+ // World save
+ var worldEntry = zip.GetEntry("world/000_save");
+ if (worldEntry != null)
+ {
+ var worldBytes = ReadEntry(worldEntry);
+ AtomicWrite(WorldPath, Compress(worldBytes));
+ }
+
+ // World commands
+ var worldCmdsEntry = zip.GetEntry("world/000_cmds");
+ if (worldCmdsEntry != null)
+ AtomicWrite(WorldCmdsPath, ReadEntry(worldCmdsEntry));
+
+ // Map saves and commands
+ foreach (var entry in zip.Entries)
+ {
+ if (!entry.FullName.StartsWith("maps/")) continue;
+
+ var parts = entry.FullName.Replace("maps/", "").Split('_');
+ if (parts.Length < 3) continue;
+
+ int mapId = int.Parse(parts[1]);
+
+ if (entry.FullName.EndsWith("_save"))
+ AtomicWrite(Path.Combine(MapsDir, $"{mapId}.dat"), Compress(ReadEntry(entry)));
+ else if (entry.FullName.EndsWith("_cmds"))
+ AtomicWrite(Path.Combine(MapsDir, $"{mapId}_cmds.dat"), ReadEntry(entry));
+ }
+
+ // Info/metadata
+ var infoEntry = zip.GetEntry("info");
+ if (infoEntry != null)
+ {
+ var infoBytes = ReadEntry(infoEntry);
+ AtomicWrite(InfoPath, infoBytes);
+
+ try
+ {
+ WritePersistedTick(GetLatestTick(ReplayInfo.Read(infoBytes)));
+ }
+ catch (Exception e)
+ {
+ ServerLog.Error($"Failed to seed persisted tick from info.xml: {e.Message}");
+ }
+ }
+
+ // Session data (empty for replay format, but create the file)
+ AtomicWrite(SessionPath, Array.Empty());
+
+ ServerLog.Log($"Seeded Saved/ directory from {zipPath}");
+ }
+
+ ///
+ /// Load persisted state into WorldData and return ReplayInfo if available.
+ ///
+ public ReplayInfo? LoadInto(MultiplayerServer server)
+ {
+ if (!File.Exists(WorldPath))
+ return null;
+
+ server.worldData.savedGame = File.ReadAllBytes(WorldPath);
+
+ server.worldData.sessionData = File.Exists(SessionPath) ? File.ReadAllBytes(SessionPath) : Array.Empty();
+
+ // Load maps
+ if (Directory.Exists(MapsDir))
+ {
+ foreach (var mapFile in Directory.GetFiles(MapsDir, "*.dat"))
+ {
+ var fileName = Path.GetFileNameWithoutExtension(mapFile);
+ if (fileName.EndsWith("_cmds")) continue;
+
+ if (int.TryParse(fileName, out int mapId))
+ {
+ server.worldData.mapData[mapId] = File.ReadAllBytes(mapFile);
+
+ var cmdsPath = Path.Combine(MapsDir, $"{mapId}_cmds.dat");
+ if (File.Exists(cmdsPath))
+ {
+ server.worldData.mapCmds[mapId] = ScheduledCommand.DeserializeCmds(File.ReadAllBytes(cmdsPath))
+ .Select(ScheduledCommand.Serialize).ToList();
+ }
+ else
+ {
+ server.worldData.mapCmds[mapId] = new List();
+ }
+ }
+ }
+ }
+
+ // Load world commands
+ if (File.Exists(WorldCmdsPath))
+ {
+ server.worldData.mapCmds[-1] = ScheduledCommand.DeserializeCmds(File.ReadAllBytes(WorldCmdsPath))
+ .Select(ScheduledCommand.Serialize).ToList();
+ }
+ else
+ {
+ server.worldData.mapCmds[-1] = new List();
+ }
+
+ // Load replay info for metadata
+ ReplayInfo? info = null;
+ if (File.Exists(InfoPath))
+ {
+ try { info = ReplayInfo.Read(File.ReadAllBytes(InfoPath)); }
+ catch (Exception e) { ServerLog.Error($"Failed to read info.xml: {e.Message}"); }
+ }
+
+ var loadedTick = ReadPersistedTick() ?? GetLatestTick(info);
+ server.gameTimer = loadedTick;
+ server.startingTimer = loadedTick;
+ server.worldData.standaloneWorldSnapshot.tick = loadedTick;
+
+ foreach (var mapId in server.worldData.mapData.Keys)
+ {
+ server.worldData.standaloneMapSnapshots[mapId] = new StandaloneMapSnapshotState
+ {
+ tick = loadedTick,
+ };
+ }
+
+ ServerLog.Log($"Loaded state from Saved/ directory ({server.worldData.mapData.Count} maps) at tick {loadedTick}");
+ return info;
+ }
+
+ public void WriteJoinPoint(WorldData worldData, int tick)
+ {
+ EnsureDirectories();
+
+ if (worldData.savedGame != null)
+ AtomicWrite(WorldPath, worldData.savedGame);
+
+ AtomicWrite(SessionPath, worldData.sessionData ?? Array.Empty());
+ AtomicWrite(WorldCmdsPath, SerializeStoredCmds(worldData.mapCmds.GetValueOrDefault(ScheduledCommand.Global) ?? []));
+
+ var currentMapIds = worldData.mapData.Keys.ToHashSet();
+ DeleteStaleMapFiles(currentMapIds);
+
+ foreach (var (mapId, mapData) in worldData.mapData)
+ {
+ AtomicWrite(Path.Combine(MapsDir, $"{mapId}.dat"), mapData);
+ AtomicWrite(
+ Path.Combine(MapsDir, $"{mapId}_cmds.dat"),
+ SerializeStoredCmds(worldData.mapCmds.GetValueOrDefault(mapId) ?? []));
+ }
+
+ WritePersistedTick(tick);
+ }
+
+ ///
+ /// Write an accepted map snapshot to disk atomically.
+ ///
+ public void WriteMapSnapshot(int mapId, byte[] compressedMapData)
+ {
+ EnsureDirectories();
+ AtomicWrite(Path.Combine(MapsDir, $"{mapId}.dat"), compressedMapData);
+ }
+
+ ///
+ /// Write an accepted world snapshot to disk atomically.
+ ///
+ public void WriteWorldSnapshot(byte[] compressedWorldData, byte[] sessionData, int tick)
+ {
+ EnsureDirectories();
+ AtomicWrite(WorldPath, compressedWorldData);
+ AtomicWrite(SessionPath, sessionData);
+ WritePersistedTick(tick);
+ }
+
+ ///
+ /// Cleanup any leftover .tmp files from interrupted writes.
+ ///
+ public void CleanupTempFiles()
+ {
+ if (!Directory.Exists(SavedDir)) return;
+
+ foreach (var tmp in Directory.GetFiles(SavedDir, "*.tmp", SearchOption.AllDirectories))
+ {
+ try
+ {
+ File.Delete(tmp);
+ ServerLog.Detail($"Cleaned up leftover temp file: {tmp}");
+ }
+ catch (Exception e)
+ {
+ ServerLog.Error($"Failed to clean temp file {tmp}: {e.Message}");
+ }
+ }
+ }
+
+ ///
+ /// Atomic write: write to .tmp, then rename over the target.
+ ///
+ private static void AtomicWrite(string targetPath, byte[] data)
+ {
+ var tmpPath = targetPath + ".tmp";
+ File.WriteAllBytes(tmpPath, data);
+
+ // File.Move with overwrite is .NET 5+; Common targets .NET Framework 4.8
+ if (File.Exists(targetPath))
+ File.Delete(targetPath);
+ File.Move(tmpPath, targetPath);
+ }
+
+ private static byte[] ReadEntry(ZipArchiveEntry entry)
+ {
+ using var stream = entry.Open();
+ using var ms = new MemoryStream();
+ stream.CopyTo(ms);
+ return ms.ToArray();
+ }
+
+ private static byte[] Compress(byte[] input)
+ {
+ using var result = new MemoryStream();
+ using (var gz = new GZipStream(result, CompressionMode.Compress))
+ {
+ gz.Write(input, 0, input.Length);
+ gz.Flush();
+ }
+ return result.ToArray();
+ }
+
+ private void DeleteStaleMapFiles(HashSet currentMapIds)
+ {
+ if (!Directory.Exists(MapsDir))
+ return;
+
+ foreach (var path in Directory.GetFiles(MapsDir, "*.dat"))
+ {
+ var fileName = Path.GetFileNameWithoutExtension(path);
+ var mapIdText = fileName.EndsWith("_cmds") ? fileName[..^5] : fileName;
+ if (int.TryParse(mapIdText, out var mapId) && !currentMapIds.Contains(mapId))
+ File.Delete(path);
+ }
+ }
+
+ private void WritePersistedTick(int tick)
+ {
+ AtomicWrite(StatePath, BitConverter.GetBytes(tick));
+ }
+
+ private int? ReadPersistedTick()
+ {
+ if (!File.Exists(StatePath))
+ return null;
+
+ var data = File.ReadAllBytes(StatePath);
+ return data.Length >= sizeof(int) ? BitConverter.ToInt32(data, 0) : null;
+ }
+
+ private static int GetLatestTick(ReplayInfo? info)
+ {
+ if (info?.sections.Count > 0)
+ {
+ var section = info.sections.Last();
+ return Math.Max(section.start, section.end);
+ }
+
+ return 0;
+ }
+
+ private static byte[] SerializeStoredCmds(List cmds)
+ {
+ var writer = new ByteWriter();
+ writer.WriteInt32(cmds.Count);
+ foreach (var cmd in cmds)
+ writer.WritePrefixedBytes(cmd);
+ return writer.ToArray();
+ }
+}
diff --git a/Source/Common/Version.cs b/Source/Common/Version.cs
index 7ad3bc816..222d54fad 100644
--- a/Source/Common/Version.cs
+++ b/Source/Common/Version.cs
@@ -6,7 +6,7 @@ namespace Multiplayer.Common
public static class MpVersion
{
public const string SimpleVersion = "0.11.4";
- public const int Protocol = 53;
+ public const int Protocol = 54;
public static readonly string? GitHash = Assembly.GetExecutingAssembly()
.GetCustomAttributes()
diff --git a/Source/Common/WorldData.cs b/Source/Common/WorldData.cs
index a76e18dfb..09c2a436b 100644
--- a/Source/Common/WorldData.cs
+++ b/Source/Common/WorldData.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Security.Cryptography;
using System.Threading.Tasks;
namespace Multiplayer.Common;
@@ -14,10 +15,13 @@ public class WorldData
public Dictionary> mapCmds = new(); // Map id to serialized cmds list
public Dictionary>? tmpMapCmds;
- public int lastJoinPointAtWorkTicks = -1;
+ public int lastJoinPointAtTick = -1;
public List syncInfos = new();
+ public StandaloneWorldSnapshotState standaloneWorldSnapshot = new();
+ public Dictionary standaloneMapSnapshots = new();
+
private TaskCompletionSource? dataSource;
public bool CreatingJoinPoint => tmpMapCmds != null;
@@ -29,14 +33,25 @@ public WorldData(MultiplayerServer server)
Server = server;
}
+ private int CurrentJoinPointTick => Server.IsStandaloneServer ? Server.gameTimer : Server.workTicks;
+
public bool TryStartJoinPointCreation(bool force = false)
{
- if (!force && Server.workTicks - lastJoinPointAtWorkTicks < 30)
+ int currentTick = CurrentJoinPointTick;
+
+ if (!force && lastJoinPointAtTick >= 0 && currentTick - lastJoinPointAtTick < 30)
+ {
+ ServerLog.Detail($"Join point skipped: cooldown active at tick={currentTick}, last={lastJoinPointAtTick}, standalone={Server.IsStandaloneServer}");
return false;
+ }
if (CreatingJoinPoint)
+ {
+ ServerLog.Detail("Join point skipped: already creating one");
return false;
+ }
+ ServerLog.Detail($"Join point started at tick={currentTick}, force={force}, standalone={Server.IsStandaloneServer}");
Server.SendChat("Creating a join point...");
Server.commands.Send(CommandType.CreateJoinPoint, ScheduledCommand.NoFaction, ScheduledCommand.Global, Array.Empty());
@@ -48,9 +63,24 @@ public bool TryStartJoinPointCreation(bool force = false)
public void EndJoinPointCreation()
{
+ int currentTick = CurrentJoinPointTick;
+ ServerLog.Detail($"Join point completed at tick={currentTick}, standalone={Server.IsStandaloneServer}");
mapCmds = tmpMapCmds!;
tmpMapCmds = null;
- lastJoinPointAtWorkTicks = Server.workTicks;
+ lastJoinPointAtTick = currentTick;
+
+ if (Server.IsStandaloneServer && Server.persistence != null)
+ {
+ try
+ {
+ Server.persistence.WriteJoinPoint(this, currentTick);
+ }
+ catch (Exception e)
+ {
+ ServerLog.Error($"Failed to persist standalone join point at tick={currentTick}: {e}");
+ }
+ }
+
dataSource!.SetResult(this);
dataSource = null;
}
@@ -69,4 +99,89 @@ public Task WaitJoinPoint()
{
return dataSource?.Task ?? Task.FromResult(this);
}
+
+ public bool TryAcceptStandaloneWorldSnapshot(ServerPlayer player, int tick, int leaseVersion, byte[] worldSnapshot,
+ byte[] sessionSnapshot, byte[] expectedHash)
+ {
+ if (tick < standaloneWorldSnapshot.tick)
+ return false;
+
+ var actualHash = ComputeHash(worldSnapshot, sessionSnapshot);
+ if (expectedHash.Length > 0 && !actualHash.AsSpan().SequenceEqual(expectedHash))
+ return false;
+
+ savedGame = worldSnapshot;
+ sessionData = sessionSnapshot;
+ standaloneWorldSnapshot = new StandaloneWorldSnapshotState
+ {
+ tick = tick,
+ leaseVersion = leaseVersion,
+ producerPlayerId = player.id,
+ producerUsername = player.Username,
+ sha256Hash = actualHash
+ };
+
+ // Persist to disk
+ Server.persistence?.WriteWorldSnapshot(worldSnapshot, sessionSnapshot, tick);
+
+ return true;
+ }
+
+ public bool TryAcceptStandaloneMapSnapshot(ServerPlayer player, int mapId, int tick, int leaseVersion,
+ byte[] mapSnapshot, byte[] expectedHash)
+ {
+ if (mapId < 0)
+ return false;
+
+ var snapshotState = standaloneMapSnapshots.GetOrAddNew(mapId);
+ if (tick < snapshotState.tick)
+ return false;
+
+ var actualHash = ComputeHash(mapSnapshot);
+ if (expectedHash.Length > 0 && !actualHash.AsSpan().SequenceEqual(expectedHash))
+ return false;
+
+ mapData[mapId] = mapSnapshot;
+ snapshotState.tick = tick;
+ snapshotState.leaseVersion = leaseVersion;
+ snapshotState.producerPlayerId = player.id;
+ snapshotState.producerUsername = player.Username;
+ snapshotState.sha256Hash = actualHash;
+ standaloneMapSnapshots[mapId] = snapshotState;
+
+ // Persist to disk
+ Server.persistence?.WriteMapSnapshot(mapId, mapSnapshot);
+
+ return true;
+ }
+
+ private static byte[] ComputeHash(params byte[][] payloads)
+ {
+ using var hasher = SHA256.Create();
+ foreach (var payload in payloads)
+ {
+ hasher.TransformBlock(payload, 0, payload.Length, null, 0);
+ }
+
+ hasher.TransformFinalBlock(Array.Empty(), 0, 0);
+ return hasher.Hash ?? Array.Empty();
+ }
+}
+
+public struct StandaloneWorldSnapshotState
+{
+ public int tick;
+ public int leaseVersion;
+ public int producerPlayerId;
+ public string producerUsername;
+ public byte[] sha256Hash;
+}
+
+public struct StandaloneMapSnapshotState
+{
+ public int tick;
+ public int leaseVersion;
+ public int producerPlayerId;
+ public string producerUsername;
+ public byte[] sha256Hash;
}
diff --git a/Source/Server/Server.cs b/Source/Server/Server.cs
index 00a79f4e3..bf828e836 100644
--- a/Source/Server/Server.cs
+++ b/Source/Server/Server.cs
@@ -1,10 +1,8 @@
-using System.IO.Compression;
-using System.Net;
+using System.Net;
using Multiplayer.Common;
using Multiplayer.Common.Util;
using Server;
-ServerLog.detailEnabled = true;
Directory.SetCurrentDirectory(AppContext.BaseDirectory);
const string settingsFile = "settings.toml";
@@ -24,6 +22,8 @@
else
ServerLog.Log($"Bootstrap mode: '{settingsFile}' not found. Waiting for a client to upload it.");
+settings.EnforceStandaloneRequirements(isStandaloneServer: true);
+
if (settings.steam) ServerLog.Error("Steam is not supported in standalone server.");
if (settings.arbiter) ServerLog.Error("Arbiter is not supported in standalone server.");
@@ -33,16 +33,49 @@
IsStandaloneServer = true,
};
+var persistence = new StandalonePersistence(AppContext.BaseDirectory);
+server.persistence = persistence;
+
+// Cleanup leftover temp files from any previous interrupted writes
+persistence.CleanupTempFiles();
+
var consoleSource = new ConsoleSource();
-if (!bootstrap && File.Exists(saveFile))
+if (!bootstrap && persistence.HasValidState())
{
- LoadSave(server, saveFile);
+ // Prefer loading from the Saved/ directory (structured persistence)
+ var info = persistence.LoadInto(server);
+ if (info != null)
+ {
+ server.settings.gameName = info.name;
+ server.worldData.hostFactionId = info.playerFaction;
+ var spectatorFaction = info.spectatorFaction;
+ if (server.settings.multifaction && spectatorFaction == 0)
+ ServerLog.Error("Multifaction is enabled but the save doesn't contain spectator faction id.");
+ server.worldData.spectatorFactionId = spectatorFaction;
+ }
+ ServerLog.Log("Loaded state from Saved/ directory.");
+}
+else if (!bootstrap && File.Exists(saveFile))
+{
+ // Seed the Saved/ directory from save.zip, then load from it
+ ServerLog.Log($"Seeding Saved/ directory from {saveFile}...");
+ persistence.SeedFromSaveZip(saveFile);
+ var info = persistence.LoadInto(server);
+ if (info != null)
+ {
+ server.settings.gameName = info.name;
+ server.worldData.hostFactionId = info.playerFaction;
+ var spectatorFaction = info.spectatorFaction;
+ if (server.settings.multifaction && spectatorFaction == 0)
+ ServerLog.Error("Multifaction is enabled but the save doesn't contain spectator faction id.");
+ server.worldData.spectatorFactionId = spectatorFaction;
+ }
}
else
{
bootstrap = true;
- ServerLog.Log($"Bootstrap mode: '{saveFile}' not found. Server will start without a loaded save.");
+ ServerLog.Log($"Bootstrap mode: neither Saved/ directory nor '{saveFile}' found.");
ServerLog.Log("Waiting for a client to upload world data.");
}
@@ -99,69 +132,6 @@
break;
}
-static void LoadSave(MultiplayerServer server, string path)
-{
- using var zip = ZipFile.OpenRead(path);
-
- var replayInfo = ReplayInfo.Read(zip.GetBytes("info"));
- ServerLog.Detail($"Loading {path} saved in RW {replayInfo.rwVersion} with {replayInfo.modNames.Count} mods");
-
- server.settings.gameName = replayInfo.name;
- server.worldData.hostFactionId = replayInfo.playerFaction;
- var spectatorFaction = replayInfo.spectatorFaction;
- if (server.settings.multifaction && spectatorFaction == 0)
- ServerLog.Error("Multifaction is enabled but the save doesn't contain spectator faction id.");
- server.worldData.spectatorFactionId = spectatorFaction;
-
- //This parses multiple saves as long as they are named correctly
- server.gameTimer = replayInfo.sections[0].start;
- server.startingTimer = replayInfo.sections[0].start;
-
-
- server.worldData.savedGame = Compress(zip.GetBytes("world/000_save"));
-
- // Parse cmds entry for each map
- foreach (var entry in zip.GetEntries("maps/*_cmds"))
- {
- var parts = entry.FullName.Split('_');
-
- if (parts.Length == 3)
- {
- int mapNumber = int.Parse(parts[1]);
- server.worldData.mapCmds[mapNumber] = ScheduledCommand.DeserializeCmds(zip.GetBytes(entry.FullName)).Select(ScheduledCommand.Serialize).ToList();
- }
- }
-
- // Parse save entry for each map
- foreach (var entry in zip.GetEntries("maps/*_save"))
- {
- var parts = entry.FullName.Split('_');
-
- if (parts.Length == 3)
- {
- int mapNumber = int.Parse(parts[1]);
- server.worldData.mapData[mapNumber] = Compress(zip.GetBytes(entry.FullName));
- }
- }
-
-
- server.worldData.mapCmds[-1] = ScheduledCommand.DeserializeCmds(zip.GetBytes("world/000_cmds")).Select(ScheduledCommand.Serialize).ToList();
- server.worldData.sessionData = Array.Empty();
-}
-
-static byte[] Compress(byte[] input)
-{
- using var result = new MemoryStream();
-
- using (var compressionStream = new GZipStream(result, CompressionMode.Compress))
- {
- compressionStream.Write(input, 0, input.Length);
- compressionStream.Flush();
-
- }
- return result.ToArray();
-}
-
class ConsoleSource : IChatSource
{
public void SendMsg(string msg)
diff --git a/Source/Tests/PacketTest.cs b/Source/Tests/PacketTest.cs
index b1bd0e726..9ad9dd66e 100644
--- a/Source/Tests/PacketTest.cs
+++ b/Source/Tests/PacketTest.cs
@@ -170,8 +170,8 @@ private static IEnumerable RoundtripPackets()
yield return new ClientProtocolPacket(50);
- yield return new ServerProtocolOkPacket(true);
- yield return new ServerProtocolOkPacket(false);
+ yield return new ServerProtocolOkPacket(true, true) { autosaveInterval = 5f, autosaveUnit = AutosaveUnit.Minutes };
+ yield return new ServerProtocolOkPacket(false, false);
yield return new ClientUsernamePacket("username");
yield return new ClientUsernamePacket("username", "password");
diff --git a/Source/Tests/StandalonePersistenceTest.cs b/Source/Tests/StandalonePersistenceTest.cs
new file mode 100644
index 000000000..dac778496
--- /dev/null
+++ b/Source/Tests/StandalonePersistenceTest.cs
@@ -0,0 +1,102 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Multiplayer.Common;
+
+namespace Tests;
+
+[TestFixture]
+public class StandalonePersistenceTest
+{
+ private string tempDir = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ tempDir = Path.Combine(Path.GetTempPath(), $"mp-standalone-persistence-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(tempDir);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(tempDir))
+ Directory.Delete(tempDir, true);
+ }
+
+ [Test]
+ public void WriteJoinPoint_PersistsCommandsAndTickForReload()
+ {
+ var server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings())
+ {
+ IsStandaloneServer = true,
+ persistence = new StandalonePersistence(tempDir),
+ };
+
+ server.worldData.savedGame = [1, 2, 3];
+ server.worldData.sessionData = [4, 5, 6];
+ server.worldData.mapData[7] = [7, 8, 9];
+
+ var worldCmd = ScheduledCommand.Serialize(new ScheduledCommand(CommandType.Sync, 1234, 1, ScheduledCommand.Global, 5, [10]));
+ var mapCmd = ScheduledCommand.Serialize(new ScheduledCommand(CommandType.Designator, 1234, 1, 7, 5, [11]));
+ server.worldData.mapCmds[ScheduledCommand.Global] = [worldCmd];
+ server.worldData.mapCmds[7] = [mapCmd];
+
+ server.persistence.WriteJoinPoint(server.worldData, 1234);
+
+ var reloadedServer = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings())
+ {
+ IsStandaloneServer = true,
+ persistence = new StandalonePersistence(tempDir),
+ };
+
+ var info = reloadedServer.persistence.LoadInto(reloadedServer);
+
+ Assert.That(info, Is.Null);
+ Assert.That(reloadedServer.gameTimer, Is.EqualTo(1234));
+ Assert.That(reloadedServer.startingTimer, Is.EqualTo(1234));
+ Assert.That(reloadedServer.worldData.savedGame, Is.EqualTo(new byte[] { 1, 2, 3 }));
+ Assert.That(reloadedServer.worldData.sessionData, Is.EqualTo(new byte[] { 4, 5, 6 }));
+ Assert.That(reloadedServer.worldData.mapData[7], Is.EqualTo(new byte[] { 7, 8, 9 }));
+ Assert.That(reloadedServer.worldData.mapCmds[ScheduledCommand.Global], Has.Count.EqualTo(1));
+ Assert.That(reloadedServer.worldData.mapCmds[7], Has.Count.EqualTo(1));
+
+ var reloadedWorldCmd = ScheduledCommand.Deserialize(new ByteReader(reloadedServer.worldData.mapCmds[ScheduledCommand.Global][0]));
+ var reloadedMapCmd = ScheduledCommand.Deserialize(new ByteReader(reloadedServer.worldData.mapCmds[7][0]));
+
+ Assert.That(reloadedWorldCmd.ticks, Is.EqualTo(1234));
+ Assert.That(reloadedMapCmd.ticks, Is.EqualTo(1234));
+ Assert.That(reloadedServer.worldData.standaloneWorldSnapshot.tick, Is.EqualTo(1234));
+ Assert.That(reloadedServer.worldData.standaloneMapSnapshots[7].tick, Is.EqualTo(1234));
+ }
+
+ [Test]
+ public void LoadInto_FallsBackToLatestReplaySectionTick()
+ {
+ var persistence = new StandalonePersistence(tempDir);
+ persistence.EnsureDirectories();
+
+ File.WriteAllBytes(Path.Combine(tempDir, "Saved", "world.dat"), [1]);
+ File.WriteAllBytes(Path.Combine(tempDir, "Saved", "session.dat"), []);
+ File.WriteAllBytes(Path.Combine(tempDir, "Saved", "world_cmds.dat"), ScheduledCommand.SerializeCmds(new List()));
+ File.WriteAllBytes(Path.Combine(tempDir, "Saved", "info.xml"), ReplayInfo.Write(new ReplayInfo
+ {
+ sections = new List
+ {
+ new(100, 100),
+ new(999, 999),
+ }
+ }));
+
+ var server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings())
+ {
+ IsStandaloneServer = true,
+ persistence = persistence,
+ };
+
+ persistence.LoadInto(server);
+
+ Assert.That(server.gameTimer, Is.EqualTo(999));
+ Assert.That(server.startingTimer, Is.EqualTo(999));
+ }
+}
\ No newline at end of file
diff --git a/Source/Tests/packet-serializations/ServerProtocolOkPacket.verified.txt b/Source/Tests/packet-serializations/ServerProtocolOkPacket.verified.txt
index 4f32769c1..db302e132 100644
--- a/Source/Tests/packet-serializations/ServerProtocolOkPacket.verified.txt
+++ b/Source/Tests/packet-serializations/ServerProtocolOkPacket.verified.txt
@@ -1,2 +1,2 @@
-01
-00
+01-01-00-00-A0-40-01-00-00-00
+00-00-00-00-00-00-00-00-00-00