Skip to content

Standalone snapshot persistence#875

Open
MhaWay wants to merge 3 commits intorwmt:devfrom
MhaWay:standalone-snapshot-persistence
Open

Standalone snapshot persistence#875
MhaWay wants to merge 3 commits intorwmt:devfrom
MhaWay:standalone-snapshot-persistence

Conversation

@MhaWay
Copy link
Copy Markdown

@MhaWay MhaWay commented Apr 11, 2026

This PR adds the standalone snapshot persistence layer on top of the previous standalone save-trigger foundation.

What this branch does:

  • adds standalone world and map snapshot upload packets
  • persists standalone join points and snapshot state on the server
  • reloads standalone snapshot state after restart
  • wires autosave/save flows to upload fresh standalone snapshots

Notes:

  • this is the second PR in the standalone server series
  • it is expected to be reviewed after Split standalone save trigger foundation #874
  • because it targets upstream/dev directly, the diff is cumulative until the earlier PR is merged

Validation:

  • dotnet build Source/Multiplayer.sln -c Release
  • focused tests passed: StandalonePersistenceTest and PacketTest

Copilot AI review requested due to automatic review settings April 11, 2026 15:50
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a durable “Saved/” persistence layer and standalone snapshot upload flow so a standalone server can persist join points/snapshots and restore state after restart.

Changes:

  • Introduces StandalonePersistence to manage on-disk Saved/ state (seed/load/write/cleanup).
  • Adds standalone snapshot upload packets + server/client wiring (autosave/save/world-travel triggers).
  • Extends protocol handshake to tell clients they’re connected to a standalone server.

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
Source/Tests/StandalonePersistenceTest.cs Adds coverage for join-point persistence + tick fallback behavior.
Source/Tests/PacketTest.cs Updates packet roundtrip list for new ServerProtocolOkPacket shape.
Source/Tests/packet-serializations/ServerProtocolOkPacket.verified.txt Updates verified bytes to include the new boolean field.
Source/Server/Server.cs Wires standalone persistence into server startup and load/seed logic.
Source/Common/WorldData.cs Adds standalone snapshot state + acceptance/persistence hooks; join point tick handling for standalone.
Source/Common/StandalonePersistence.cs New persistence implementation for Saved/ directory management.
Source/Common/PlayerManager.cs Adjusts faction assignment for standalone “primary” player joining.
Source/Common/Networking/State/ServerPlayingState.cs Accepts standalone snapshot uploads; adjusts autosave handling and world upload policy for standalone.
Source/Common/Networking/State/ServerJoiningState.cs Standalone join-point request behavior; sends standalone flag in protocol OK.
Source/Common/Networking/Packets.cs Adds packet IDs for standalone snapshot uploads.
Source/Common/Networking/Packet/StandaloneSnapshotPackets.cs New packet definitions for world/map snapshot upload.
Source/Common/Networking/Packet/ProtocolPacket.cs Extends ServerProtocolOkPacket with standalone flag.
Source/Common/Networking/Packet/AutosavingPacket.cs New typed ClientAutosavingPacket with reason enum.
Source/Common/MultiplayerServer.cs Adds StandalonePersistence field to server.
Source/Common/JoinPointRequestReason.cs New enum for autosave/join-point trigger reasons.
Source/Client/Windows/SaveGameWindow.cs Sends autosave/join-point request on manual save; special-cases standalone.
Source/Client/Session/MultiplayerSession.cs Tracks whether connection is to a standalone server.
Source/Client/Session/Autosaving.cs Sends typed autosaving packet; uploads standalone snapshots after autosave; makes save return bool.
Source/Client/Saving/SaveLoad.cs Implements snapshot compression + SHA256 hashing + fragmented upload for standalone.
Source/Client/Patches/VTRSyncPatch.cs Triggers join point request on world travel when standalone-connected.
Source/Client/Networking/State/ClientJoiningState.cs Reads standalone flag from ServerProtocolOkPacket.
Source/Client/ConstantTicker.cs Adds synthetic autosave timer for standalone connections.
Source/Client/AsyncTime/AsyncWorldTimeComp.cs Uploads standalone snapshots when a join point snapshot is created/sent.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +241 to +251
/// Atomic write: write to .tmp, then rename over the target.
/// </summary>
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);
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.

AtomicWrite isn’t actually atomic: deleting the target and then moving the .tmp introduces a window where a crash (or IO error) can leave the file missing/corrupted. On .NET Framework 4.8 you can use File.Replace(tmpPath, targetPath, backupPath: null) when the target exists (and File.Move when it doesn’t) to get an atomic swap without a delete gap.

Suggested change
/// Atomic write: write to .tmp, then rename over the target.
/// </summary>
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);
/// Atomic write: write to .tmp, then atomically replace the target.
/// </summary>
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.
// Use File.Replace for an atomic swap when the destination already exists.
if (File.Exists(targetPath))
File.Replace(tmpPath, targetPath, null);
else
File.Move(tmpPath, targetPath);

Copilot uses AI. Check for mistakes.
Comment on lines +124 to +127
// Persist to disk
Server.persistence?.WriteWorldSnapshot(worldSnapshot, sessionSnapshot, tick);

return true;
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.

TryAcceptStandaloneWorldSnapshot writes to disk without exception handling. If the filesystem is read-only/full (or transient IO errors occur), this will throw out of the packet handler and can destabilize the server connection loop. Consider wrapping the persistence write in try/catch (similar to EndJoinPointCreation) and returning false/logging when persistence fails.

Copilot uses AI. Check for mistakes.
Comment on lines +152 to +155
// Persist to disk
Server.persistence?.WriteMapSnapshot(mapId, mapSnapshot);

return true;
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.

TryAcceptStandaloneMapSnapshot writes to disk without exception handling. An IO exception here would bubble up from the packet handler; it’s safer to catch/log and reject (or accept but mark dirty) rather than risking server instability on disk failures.

Copilot uses AI. Check for mistakes.
Comment on lines +103 to +108
public bool TryAcceptStandaloneWorldSnapshot(ServerPlayer player, int tick, int leaseVersion, byte[] worldSnapshot,
byte[] sessionSnapshot, byte[] expectedHash)
{
if (tick < standaloneWorldSnapshot.tick)
return false;

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.

Snapshot acceptance ignores leaseVersion: a snapshot with the same tick as the current one will be accepted and overwrite state regardless of whether it’s stale or from a different producer. Since leaseVersion is already part of the packet/state, consider requiring tick to strictly increase, or (tick == currentTick) only accepting if leaseVersion is greater than the stored leaseVersion (and/or validating the current lease owner).

Copilot uses AI. Check for mistakes.
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.
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.
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.
Comment on lines +171 to 187
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;
}
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.

StandaloneWorldSnapshotState/StandaloneMapSnapshotState declare non-nullable reference fields (producerUsername, sha256Hash) but they’ll be null in the default struct value (e.g., the 'new()' initializers above). With enable this produces warnings and can become a runtime NRE if these fields are read before a snapshot is accepted. Consider making these fields nullable or initializing them to "" / Array.Empty() via a constructor/defaults.

Copilot uses AI. Check for mistakes.
@notfood notfood added enhancement New feature or request. waiting on merge Needs merging. 1.6 Fixes or bugs relating to 1.6 (Not Odyssey). standalone server Fix or bugs relating to the standalone server. labels Apr 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

1.6 Fixes or bugs relating to 1.6 (Not Odyssey). enhancement New feature or request. standalone server Fix or bugs relating to the standalone server. waiting on merge Needs merging.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants