-
-
Notifications
You must be signed in to change notification settings - Fork 140
Standalone save & persistence improvements #874
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
67f32f9
0390728
e15d083
6981562
506617e
bb1c510
a3ff2ca
77c52d6
df7f99c
ae87eda
53e86cf
f790fe8
cc3bd9d
a459786
10d468e
433ad1b
8255785
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -81,6 +81,7 @@ ipch/ | |
| *.opensdf | ||
| *.sdf | ||
| *.cachefile | ||
| *.lscache | ||
| *.VC.db | ||
| *.VC.VC.opendb | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -47,6 +47,40 @@ private static void TickNonSimulation() | |
|
|
||
| private static void TickAutosave() | ||
| { | ||
| // When connected to a remote standalone server, the client drives | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The code in this method is effiectively duplicated with the only change being where the settings are taken from -
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree with the cleanup direction here. The reason I did not fold it into this PR is that the two branches are still reading from two different sources of truth today. When connected to a remote standalone server, the client drives autosave from the values received through the connection protocol and stored in That seems reasonable, but it also broadens this PR from standalone persistence/upload into a more general autosave-source refactor. I preferred to keep this change focused and treat that unification as a small follow-up cleanup instead. |
||
| // the autosave timer using the interval received at connection time | ||
| // (from the server's TOML settings via ServerProtocolOkPacket). | ||
| if (Multiplayer.session?.ConnectedToStandaloneServer == true) | ||
| { | ||
| var session = Multiplayer.session; | ||
| if (session.autosaveInterval <= 0) | ||
| return; | ||
|
|
||
| if (session.autosaveUnit == AutosaveUnit.Minutes) | ||
| { | ||
| session.autosaveCounter++; | ||
|
|
||
| if (session.autosaveCounter > session.autosaveInterval * TicksPerMinute) | ||
| { | ||
| session.autosaveCounter = 0; | ||
| Autosaving.DoAutosave(); | ||
| } | ||
| } | ||
| else if (session.autosaveUnit == AutosaveUnit.Days) | ||
| { | ||
| var anyMapCounterUp = | ||
| Multiplayer.game.mapComps | ||
| .Any(m => m.autosaveCounter > session.autosaveInterval * TicksPerIngameDay); | ||
|
|
||
| if (anyMapCounterUp) | ||
| { | ||
| Multiplayer.game.mapComps.Do(m => m.autosaveCounter = 0); | ||
| Autosaving.DoAutosave(); | ||
| } | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| if (Multiplayer.LocalServer is not { } server) return; | ||
|
|
||
| if (server.settings.autosaveUnit == AutosaveUnit.Minutes) | ||
|
|
||
| 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; | ||||||||||||||||||||||
|
|
@@ -240,6 +242,60 @@ 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, | ||||||||||||||||||||||
| 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>(); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
Comment on lines
+284
to
+288
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
nit: symmetry with SendStandaloneMapSnapshots
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed on the underlying point: this hashing logic is effectively duplicated with the server-side validation path in I did not take the suggestion literally because the best fix here is not really to reshuffle this one call site, but to extract a single helper for the standalone world/session hash computation and use it from both So I agree with the cleanup direction, I just think it belongs as a small shared-helper refactor rather than as an inline rewrite of only this method. |
||||||||||||||||||||||
| var packet = new ClientStandaloneWorldSnapshotPacket | ||||||||||||||||||||||
| { | ||||||||||||||||||||||
| tick = tick, | ||||||||||||||||||||||
| worldData = worldCompressed, | ||||||||||||||||||||||
| sessionData = sessionCompressed, | ||||||||||||||||||||||
| sha256Hash = hash, | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| OnMainThread.Enqueue(() => Multiplayer.Client?.SendFragmented(packet.Serialize())); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| } | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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,19 @@ public static void DoAutosave() | |||||
| { | ||||||
| LongEventHandler.QueueLongEvent(() => | ||||||
| { | ||||||
| SaveGameToFile_Overwrite(GetNextAutosaveFileName(), false); | ||||||
| Multiplayer.Client.Send(Packets.Client_Autosaving); | ||||||
| var snapshot = SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveGameData(), false); | ||||||
|
|
||||||
| if (!SaveGameToFile_Overwrite(GetNextAutosaveFileName(), snapshot)) | ||||||
| return; | ||||||
|
|
||||||
| Multiplayer.Client.Send(new ClientAutosavingPacket(JoinPointRequestReason.Save)); | ||||||
|
|
||||||
| // When connected to a standalone server, also upload fresh snapshots | ||||||
| if (Multiplayer.session?.ConnectedToStandaloneServer == true) | ||||||
| { | ||||||
| SaveLoad.SendStandaloneMapSnapshots(snapshot); | ||||||
| SaveLoad.SendStandaloneWorldSnapshot(snapshot); | ||||||
| } | ||||||
| }, "MpSaving", false, null); | ||||||
| } | ||||||
|
|
||||||
|
|
@@ -33,30 +45,39 @@ private static string GetNextAutosaveFileName() | |||||
| .First(); | ||||||
| } | ||||||
|
|
||||||
| public static void SaveGameToFile_Overwrite(string fileNameNoExtension, bool currentReplay) | ||||||
| public static bool SaveGameToFile_Overwrite(string fileNameNoExtension, bool currentReplay) | ||||||
| => SaveGameToFile_Overwrite(fileNameNoExtension, | ||||||
| currentReplay ? Multiplayer.session.dataSnapshot : null); | ||||||
|
|
||||||
| public static bool SaveGameToFile_Overwrite(string fileNameNoExtension, GameDataSnapshot snapshot) | ||||||
| { | ||||||
| Log.Message($"Multiplayer: saving to file {fileNameNoExtension}"); | ||||||
|
|
||||||
| try | ||||||
| { | ||||||
| var tmp = new FileInfo(Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.tmp.zip")); | ||||||
| Replay.ForSaving(tmp).WriteData( | ||||||
| currentReplay ? | ||||||
| Multiplayer.session.dataSnapshot : | ||||||
| SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveGameData(), false) | ||||||
| var tmpPath = Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.tmp.zip"); | ||||||
| if (File.Exists(tmpPath)) | ||||||
| File.Delete(tmpPath); | ||||||
|
|
||||||
| Replay.ForSaving(new FileInfo(tmpPath)).WriteData( | ||||||
| snapshot ?? 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.Replace(tmpPath, dstPath, destinationBackupFileName: null); | ||||||
| else | ||||||
| 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; | ||||||
|
||||||
| return false; | |
| throw; |
| 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; | ||||||||
|
|
||||||||
|
|
@@ -199,7 +201,13 @@ private void Accept(bool currentReplay) | |||||||
| { | ||||||||
| if (curText.Length != 0) | ||||||||
| { | ||||||||
| LongEventHandler.QueueLongEvent(() => Autosaving.SaveGameToFile_Overwrite(curText, currentReplay), "MpSaving", false, null); | ||||||||
| LongEventHandler.QueueLongEvent(() => | ||||||||
| { | ||||||||
| if (!Autosaving.SaveGameToFile_Overwrite(curText, currentReplay)) | ||||||||
| return; | ||||||||
|
|
||||||||
| Multiplayer.Client.Send(new ClientAutosavingPacket(JoinPointRequestReason.Save)); | ||||||||
|
||||||||
| Multiplayer.Client.Send(new ClientAutosavingPacket(JoinPointRequestReason.Save)); | |
| if (!currentReplay && Multiplayer.Client != null) | |
| Multiplayer.Client.Send(new ClientAutosavingPacket(JoinPointRequestReason.Save)); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -53,7 +53,7 @@ public ChatCmdJoinPoint() | |
|
|
||
| public override void Handle(IChatSource source, string[] args) | ||
| { | ||
| if (!Server.worldData.TryStartJoinPointCreation(true)) | ||
| if (!Server.worldData.TryStartJoinPointCreation(true, sourcePlayer: source as ServerPlayer)) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Commands can not only be run by the players, but also by the server (by writing the command name in the console). Doesn't this change break the command when running from the server?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Uh i didnt know that, ill test it
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch. In standalone this was indeed wrong for the server-console path. The intent of passing I fixed that locally in |
||
| source.SendMsg("Join point creation already in progress."); | ||
| } | ||
| } | ||
|
|
||
| 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, | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of having every player send the game data back to the server, could you have the server send info which player is expected to send the game data back? WorldData.cs
Server.commands.Send(CommandType.CreateJoinPoint, ScheduledCommand.NoFaction, ScheduledCommand.Global, Array.Empty<byte>());- the Array.Empty could be changed to include playerId or perhaps a value oftruecould be sent to the desired player andfalseto the rest?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do you think about this approach: when a player wants to save, they request a joinpoint from the server, and the server asks a specific player to create it? I'm still leaning towards the idea that the player who triggered the save should be the one doing it for the maps they have loaded — so the server would still delegate it back to the requester. But if there are two players, maybe this server-mediated step would better coordinate who should do it?
Honestly, I'm not sure it adds much in practice — it feels like an extra round-trip for the same result. But I'd like your thoughts on it.
The philosophy I'm following is "each player uploads their own maps." The goal is to avoid a bottleneck where, say, 10 players need to save sequentially through a single designated uploader. On a standalone server there's no natural "authoritative" client like the host in a hosted game, so having each player responsible for their own maps seemed like the most resilient approach.
Currently, the
IssuedBySelfgate (which was missing from PR #876+ due to a rebase oversight — now restored) ensures that when aCreateJoinPointcommand is broadcast, only the client who originally requested the save actually executesCreateJoinPointAndSendIfHost. The other clients receive the command but skip execution. So theArray.Empty<byte>()payload doesn't need to carry a playerId — the client-side gate already handles it by checkingcurrentExecutingCmdIssuedBySelf.That said, if you'd prefer making this explicit via the command payload (e.g. embedding the requester's playerId so the server decides who saves), I'm open to it. It would make the intent clearer on the wire, even if the behavior is the same.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm fine with that, and that's not my issue with this change. The issue is that right now everyone saves every map and everyone sends the world data back to the server.
That probably is a better approach. Not sure how much of a bottleneck it actually would be. Also, right now there isn't such a functionality to have the player only save their own map. If it exists in another PR of yours, please include that info in this PR's description so I'm aware of the overarching goal and have more context to review on
I only reviewed this PR and didn't look at the others. If that PR fixes my concern, please move the relevant part to this PR. Also, IssuedBySelf will only work if you send the CreateJoinPoint command with a player provided. Right now, StartJoinPointCreation never sends an associated player
As long as the IssuedBySelf code you are referencing keeps working in non-standalone mode, I'm fine with just doing that. It's probably even better than my suggestion, so I think I'd even prefer your approach
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As soon as i get home ill send you more context, i was working on it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This PR does not implement map streaming itself, but it keeps the standalone join-point flow compatible with the upcoming streaming architecture. In hosted MP, join-point creation is host/arbiter-owned; in standalone, snapshot creation/upload must already happen client-side, and future streaming work extends that further by allowing the upload work to be split across assigned clients. Some of the standalone branching here is meant to preserve that separation cleanly without changing hosted behavior.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also i used ur idea about make the server handle who is the one who save for the streaming map one