Skip to content

Standalone async reconnect fixes#876

Open
MhaWay wants to merge 3 commits intorwmt:devfrom
MhaWay:standalone-async-reconnect-fixes
Open

Standalone async reconnect fixes#876
MhaWay wants to merge 3 commits intorwmt:devfrom
MhaWay:standalone-async-reconnect-fixes

Conversation

@MhaWay
Copy link
Copy Markdown

@MhaWay MhaWay commented Apr 11, 2026

This PR adds the standalone async-time enforcement and reconnect/control fixes on top of the previous standalone persistence work.

What this branch does:

  • enforces standalone server requirements in the bootstrap/server flow
  • aligns standalone async-time and multifaction control behavior
  • includes reconnect and control-flow fixes needed by the standalone server path

Notes:

Validation:

  • dotnet build Source/Multiplayer.sln -c Release
  • full test run currently has a known failure in ServerTest: "No handler for packet Server_KeepAlive in state ClientJoining"

Copilot AI review requested due to automatic review settings April 11, 2026 15:51
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

This PR extends the standalone server work by enforcing standalone-specific settings, adding durable “Saved/” persistence + snapshot upload packets, and adjusting join-point/reconnect/control flows to behave consistently in standalone async-time scenarios.

Changes:

  • Add StandalonePersistence and wire it into the standalone server bootstrap/load path (including seeding from save.zip).
  • Add standalone world/map snapshot upload packets and server-side acceptance/persistence.
  • Adjust join-point creation triggers/cooldowns, autosave/save signaling, and several client control/reconnect behaviors for standalone.

Reviewed changes

Copilot reviewed 29 out of 29 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
Source/Common/StandalonePersistence.cs New Saved/ persistence layer + seeding from save.zip + tick state tracking
Source/Server/Server.cs Standalone bootstrap now prefers Saved/ persistence and cleans temp files
Source/Common/WorldData.cs Join-point cooldown logic update + standalone snapshot acceptance + hashing
Source/Common/Networking/Packets.cs Adds new packet IDs for standalone snapshot uploads
Source/Common/Networking/Packet/StandaloneSnapshotPackets.cs New fragmented client snapshot upload packet definitions
Source/Common/Networking/Packet/ProtocolPacket.cs Adds isStandaloneServer flag to ServerProtocolOkPacket
Source/Common/Networking/Packet/AutosavingPacket.cs Introduces typed autosave packet with reason enum
Source/Common/Networking/State/ServerPlayingState.cs Standalone upload/snapshot handlers + autosave handler changes
Source/Common/Networking/State/ServerJoiningState.cs Standalone join-point request logic + protocol-ok payload update
Source/Common/PlayerManager.cs Standalone “primary player” faction assignment logic
Source/Common/ServerSettings.cs Adds EnforceStandaloneRequirements (forces async-time for standalone multifaction)
Source/Client/Session/Autosaving.cs Autosave now sends typed packet; standalone snapshot uploads after autosave; save method returns bool
Source/Client/Saving/SaveLoad.cs Implements snapshot creation + upload for standalone server
Source/Client/Windows/SaveGameWindow.cs Manual save path now signals autosave packet; special-case standalone
Source/Client/Networking/State/ClientJoiningState.cs Stores isStandaloneServer from protocol-ok
Source/Client/Session/MultiplayerSession.cs Tracks standalone connection state
Source/Client/ConstantTicker.cs Adds synthetic autosave timer for standalone connections
Source/Client/AsyncTime/AsyncWorldTimeComp.cs Standalone snapshot uploads after join-point creation
Source/Client/Patches/TickPatch.cs Allows executing “stale” commands (<= timer) post reconnect/latency
Source/Client/Patches/VTRSyncPatch.cs Standalone: trigger join point on world travel
Source/Client/Patches/GravshipTravelSessionPatches.cs Changes player control gating behavior during gravship flow
Source/Tests/StandalonePersistenceTest.cs New tests for join-point persistence + tick fallback
Source/Tests/PacketTest.cs Updates protocol-ok packet roundtrip construction
Source/Tests/packet-serializations/ServerProtocolOkPacket.verified.txt Updates verified serialization for protocol-ok packet
Comments suppressed due to low confidence (1)

Source/Common/Networking/Packets.cs:42

  • Adding new members in the middle of the Packets enum changes the numeric packet IDs for all subsequent entries (e.g., Server_ProtocolOk and everything after it). Since IDs are encoded in 6 bits (see ConnectionBase masking with 0x3F / Max=63), this is a wire-level breaking change and should be handled by either appending new packet IDs at the end (before Count) or assigning explicit values, and bumping MpVersion.Protocol accordingly so old clients/servers can’t connect with mismatched IDs.
    Client_Autosaving,
    Client_RequestRejoin,
    Client_SetFaction,
    Client_FrameTime,
    Client_StandaloneWorldSnapshotUpload,
    Client_StandaloneMapSnapshotUpload,

    // Joining
    Server_ProtocolOk,
    Server_InitDataRequest,

💡 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 deletes the target file before moving the .tmp into place, which is not crash-safe: if the process crashes between delete and move you lose the persisted file entirely. This contradicts the class comment about crash safety; consider using a replace pattern that keeps the old file until the new one is successfully in place (e.g., move current to backup, move tmp to target, then delete backup; or File.Replace where supported).

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 replace the target without deleting it first.
/// </summary>
private static void AtomicWrite(string targetPath, byte[] data)
{
var tmpPath = targetPath + ".tmp";
var backupPath = targetPath + ".bak";
File.WriteAllBytes(tmpPath, data);
try
{
if (File.Exists(targetPath))
{
File.Replace(tmpPath, targetPath, backupPath);
if (File.Exists(backupPath))
File.Delete(backupPath);
}
else
{
File.Move(tmpPath, targetPath);
}
}
catch
{
if (File.Exists(tmpPath))
File.Delete(tmpPath);
throw;
}

Copilot uses AI. Check for mistakes.
Comment on lines +67 to +74
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"))
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.

SeedFromSaveZip uses int.Parse(parts[1]) on every entry under maps/. If the zip contains an unexpected maps/* name (or a directory entry), this will throw and abort seeding. Use int.TryParse (and ideally validate the expected prefix/layout) so seeding is robust against malformed or user-provided zip contents.

Suggested change
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"))
var mapEntryName = entry.FullName.Substring("maps/".Length);
if (string.IsNullOrEmpty(mapEntryName) || mapEntryName.Contains("/")) continue;
bool isSave = mapEntryName.EndsWith("_save");
bool isCmds = mapEntryName.EndsWith("_cmds");
if (!isSave && !isCmds) continue;
var parts = mapEntryName.Split('_');
if (parts.Length < 3) continue;
if (!int.TryParse(parts[1], out int mapId)) continue;
if (isSave)
AtomicWrite(Path.Combine(MapsDir, $"{mapId}.dat"), Compress(ReadEntry(entry)));
else if (isCmds)

Copilot uses AI. Check for mistakes.
Comment on lines +64 to 68
if (dst.Exists)
dst.Delete();

tmp.MoveTo(dst.FullName);

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.

This save write path deletes the destination .zip and then renames the temp file into place. If the process crashes after deleting the destination but before MoveTo completes, the previous save is lost. Prefer a replace/backup pattern that preserves the old save until the new one is fully written and renamed successfully.

Suggested change
if (dst.Exists)
dst.Delete();
tmp.MoveTo(dst.FullName);
var backup = new FileInfo(Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.bak.zip"));
if (dst.Exists)
{
if (backup.Exists)
backup.Delete();
File.Replace(tmp.FullName, dst.FullName, backup.FullName, true);
if (backup.Exists)
backup.Delete();
}
else
{
tmp.MoveTo(dst.FullName);
}

Copilot uses AI. Check for mistakes.
Comment on lines 14 to +29
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);
}
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. On the server side, Save is treated as a forced join point (bypasses cooldown), so autosaves will also force join points. If the intent is to only force for explicit user saves, consider sending Unknown (or adding a dedicated Autosave enum value) from autosave code paths.

Copilot uses AI. Check for mistakes.
Comment on lines 248 to 252
}

// Not in a landing session, use vanilla logic for player control
__result = Current.Game.PlayerHasControl;
__result = true;
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.

This patch now unconditionally returns true for PlayerCanControl when not in a landing confirmation. That bypasses vanilla control gating entirely and may enable input in situations where control should be blocked (e.g., game over, forced pause, modal states). It seems safer to fall back to the original/vanilla logic here (previously Current.Game.PlayerHasControl) rather than always allowing control.

Copilot uses AI. Check for mistakes.
Comment on lines 83 to +90
[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}");
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.

On standalone, this now accepts world uploads from any playing client (no host/arbiter restriction). That allows any connected player to overwrite the server’s authoritative world/session/map state. If standalone supports multiple clients, consider restricting uploads to a designated owner (e.g., first non-arbiter/host) or enforcing a lease/authorization check before applying uploads.

Copilot uses AI. Check for mistakes.
Comment on lines +108 to +129
[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}");
}
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.

Standalone snapshot uploads are accepted from any playing client, and the server currently ignores leaseVersion for authorization/ownership. As a result, any player can overwrite persisted snapshots simply by sending a higher tick. Consider validating the sender (designated primary/host) and/or implementing lease negotiation/enforcement so only the current lease holder can update snapshots.

Copilot uses AI. Check for mistakes.

[TearDown]
public void TearDown()
{
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.

This test sets MultiplayerServer.instance but the TearDown only deletes the temp directory; it doesn’t reset MultiplayerServer.instance back to null. Other tests in the suite do clear the static instance in TearDown, so leaving it set here can leak state across tests and cause order-dependent failures.

Suggested change
{
{
MultiplayerServer.instance = null;

Copilot uses AI. Check for mistakes.
{
Log.Error($"Exception saving multiplayer game as {fileNameNoExtension}: {e}");
Messages.Message("MpGameSaveFailed".Translate(), MessageTypeDefOf.SilentInput, false);
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.

SaveGameToFile_Overwrite now swallows exceptions and returns false instead. There are call sites that appear to rely on exceptions for error handling (e.g., BootstrapConfiguratorWindow.BootstrapFlow catches exceptions around this call), so this behavior change can cause failures to be silently ignored and follow-up steps to run with a missing/invalid save. Consider either rethrowing after logging (so existing callers still work), or update all callers to check the returned bool and handle failure explicitly.

Suggested change
return false;
throw;

Copilot uses AI. Check for mistakes.
@notfood notfood added fix Fixes for a bug or desync. 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). fix Fixes for a bug or desync. 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