diff --git a/Source/Client/AsyncTime/SetMapTime.cs b/Source/Client/AsyncTime/SetMapTime.cs
index fb1044aea..3b40f57f3 100644
--- a/Source/Client/AsyncTime/SetMapTime.cs
+++ b/Source/Client/AsyncTime/SetMapTime.cs
@@ -205,11 +205,15 @@ public static TimeSnapshot Current()
{
if (map == null) return null;
- TimeSnapshot prev = Current();
-
var tickManager = Find.TickManager;
+ if (tickManager == null) return null;
+
+ TimeSnapshot prev = Current();
var mapComp = map.AsyncTime();
+ if (mapComp == null)
+ return prev;
+
tickManager.ticksGameInt = mapComp.mapTicks;
tickManager.slower = mapComp.slower;
tickManager.CurTimeSpeed = mapComp.DesiredTimeSpeed;
diff --git a/Source/Client/Comp/BootstrapCoordinator.cs b/Source/Client/Comp/BootstrapCoordinator.cs
new file mode 100644
index 000000000..d8b161fc9
--- /dev/null
+++ b/Source/Client/Comp/BootstrapCoordinator.cs
@@ -0,0 +1,42 @@
+using Verse;
+
+namespace Multiplayer.Client.Comp
+{
+ ///
+ /// Runs during bootstrap to detect when the new game has fully entered Playing and a map exists.
+ /// Keeps the save trigger logic reliable even when the bootstrap window may not receive regular updates.
+ ///
+ public class BootstrapCoordinator : GameComponent
+ {
+ private int nextCheckTick;
+ private const int CheckIntervalTicks = 60; // ~1s
+
+ public BootstrapCoordinator(Game game)
+ {
+ }
+
+ public override void GameComponentTick()
+ {
+ base.GameComponentTick();
+
+ var win = BootstrapConfiguratorWindow.Instance;
+ if (win == null)
+ return;
+
+ // Throttle checks
+ if (Find.TickManager != null && Find.TickManager.TicksGame < nextCheckTick)
+ return;
+
+ if (Find.TickManager != null)
+ nextCheckTick = Find.TickManager.TicksGame + CheckIntervalTicks;
+
+ win.BootstrapCoordinatorTick();
+ }
+
+ public override void ExposeData()
+ {
+ base.ExposeData();
+ Scribe_Values.Look(ref nextCheckTick, "mp_bootstrap_nextCheckTick", 0);
+ }
+ }
+}
diff --git a/Source/Client/MultiplayerStatic.cs b/Source/Client/MultiplayerStatic.cs
index 0eed8d81e..fb27a70aa 100644
--- a/Source/Client/MultiplayerStatic.cs
+++ b/Source/Client/MultiplayerStatic.cs
@@ -78,6 +78,7 @@ static MultiplayerStatic()
MpConnectionState.SetImplementation(ConnectionStateEnum.ClientJoining, typeof(ClientJoiningState));
MpConnectionState.SetImplementation(ConnectionStateEnum.ClientLoading, typeof(ClientLoadingState));
MpConnectionState.SetImplementation(ConnectionStateEnum.ClientPlaying, typeof(ClientPlayingState));
+ MpConnectionState.SetImplementation(ConnectionStateEnum.ClientBootstrap, typeof(ClientBootstrapState));
MultiplayerData.CollectCursorIcons();
diff --git a/Source/Client/Networking/State/ClientBaseState.cs b/Source/Client/Networking/State/ClientBaseState.cs
index 216f588b7..38e02c842 100644
--- a/Source/Client/Networking/State/ClientBaseState.cs
+++ b/Source/Client/Networking/State/ClientBaseState.cs
@@ -4,12 +4,13 @@
namespace Multiplayer.Client;
+[PacketHandlerClass]
public abstract class ClientBaseState(ConnectionBase connection) : MpConnectionState(connection)
{
protected MultiplayerSession Session => Multiplayer.session;
[TypedPacketHandler]
- public void HandleDisconnected(ServerDisconnectPacket packet)
+ public virtual void HandleDisconnected(ServerDisconnectPacket packet)
{
ConnectionStatusListeners.TryNotifyAll_Disconnected(SessionDisconnectInfo.From(packet.reason, new ByteReader(packet.data)));
Multiplayer.StopMultiplayer();
diff --git a/Source/Client/Networking/State/ClientBootstrapState.cs b/Source/Client/Networking/State/ClientBootstrapState.cs
new file mode 100644
index 000000000..d86e4a98e
--- /dev/null
+++ b/Source/Client/Networking/State/ClientBootstrapState.cs
@@ -0,0 +1,45 @@
+using Multiplayer.Common;
+using Multiplayer.Common.Networking.Packet;
+using RimWorld;
+using Verse;
+
+namespace Multiplayer.Client;
+
+///
+/// Client connection state used while configuring a bootstrap server.
+/// The server is in ServerBootstrap and expects upload packets; the client must keep the connection alive
+/// and handle bootstrap completion / disconnect packets.
+///
+[PacketHandlerClass]
+public class ClientBootstrapState(ConnectionBase connection) : ClientBaseState(connection)
+{
+ [TypedPacketHandler]
+ public void HandleBootstrap(ServerBootstrapPacket packet)
+ {
+ // The server sends this again when entering ServerBootstrapState.StartState().
+ // We already have the bootstrap info from ClientJoiningState; just ignore it.
+ }
+
+ [TypedPacketHandler]
+ public override void HandleDisconnected(ServerDisconnectPacket packet)
+ {
+ // If bootstrap completed successfully, show success message before closing the window
+ if (packet.reason == MpDisconnectReason.BootstrapCompleted)
+ {
+ OnMainThread.Enqueue(() => Messages.Message(
+ "Bootstrap configuration completed. The server will now shut down; please restart it manually to start normally.",
+ MessageTypeDefOf.PositiveEvent, false));
+ }
+
+ // Close the bootstrap configurator window now that the process is complete
+ OnMainThread.Enqueue(() =>
+ {
+ var window = Find.WindowStack.WindowOfType();
+ if (window != null)
+ Find.WindowStack.TryRemove(window);
+ });
+
+ // Let the base class handle the disconnect
+ base.HandleDisconnected(packet);
+ }
+}
diff --git a/Source/Client/Networking/State/ClientJoiningState.cs b/Source/Client/Networking/State/ClientJoiningState.cs
index 17782468e..24f03c88f 100644
--- a/Source/Client/Networking/State/ClientJoiningState.cs
+++ b/Source/Client/Networking/State/ClientJoiningState.cs
@@ -2,18 +2,27 @@
using HarmonyLib;
using Multiplayer.Client.Networking;
using Multiplayer.Common;
+using Multiplayer.Common.Networking.Packet;
using System.Linq;
using RimWorld;
using Verse;
namespace Multiplayer.Client
{
+ [PacketHandlerClass]
public class ClientJoiningState : ClientBaseState
{
public ClientJoiningState(ConnectionBase connection) : base(connection)
{
}
+ [TypedPacketHandler]
+ public void HandleBootstrap(ServerBootstrapPacket packet)
+ {
+ Multiplayer.session.serverIsInBootstrap = packet.bootstrap;
+ Multiplayer.session.serverBootstrapSettingsMissing = packet.settingsMissing;
+ }
+
public override void StartState()
{
connection.Send(Packets.Client_Protocol, MpVersion.Protocol);
@@ -132,6 +141,13 @@ void Complete()
void StartDownloading()
{
+ if (Multiplayer.session.serverIsInBootstrap)
+ {
+ connection.ChangeState(ConnectionStateEnum.ClientBootstrap);
+ Find.WindowStack.Add(new BootstrapConfiguratorWindow(connection));
+ return;
+ }
+
connection.Send(Packets.Client_WorldRequest);
connection.ChangeState(ConnectionStateEnum.ClientLoading);
}
diff --git a/Source/Client/Networking/State/ClientPlayingState.cs b/Source/Client/Networking/State/ClientPlayingState.cs
index 030569d1a..2593f3ac3 100644
--- a/Source/Client/Networking/State/ClientPlayingState.cs
+++ b/Source/Client/Networking/State/ClientPlayingState.cs
@@ -9,6 +9,7 @@
namespace Multiplayer.Client
{
+ [PacketHandlerClass]
public class ClientPlayingState : ClientBaseState
{
public ClientPlayingState(ConnectionBase connection) : base(connection)
diff --git a/Source/Client/Networking/State/ClientSteamState.cs b/Source/Client/Networking/State/ClientSteamState.cs
index f6896375c..f7ba15ee4 100644
--- a/Source/Client/Networking/State/ClientSteamState.cs
+++ b/Source/Client/Networking/State/ClientSteamState.cs
@@ -3,6 +3,7 @@
namespace Multiplayer.Client
{
+ [PacketHandlerClass]
public class ClientSteamState : MpConnectionState
{
public ClientSteamState(ConnectionBase connection) : base(connection)
diff --git a/Source/Client/OnMainThread.cs b/Source/Client/OnMainThread.cs
index f6d0e0999..90f3ff303 100644
--- a/Source/Client/OnMainThread.cs
+++ b/Source/Client/OnMainThread.cs
@@ -27,6 +27,10 @@ public void Update()
{
Multiplayer.session?.netClient?.PollEvents();
}
+ catch (InvalidOperationException e) when (e.Message == "Queue empty." || e.Message == "Coda vuota.")
+ {
+ // LiteNetLib can throw here during reconnect/disconnect teardown when its event queue races empty.
+ }
catch (Exception e)
{
Log.Error($"Exception handling network events: {e}");
diff --git a/Source/Client/Patches/BootstrapMapInitPatch.cs b/Source/Client/Patches/BootstrapMapInitPatch.cs
new file mode 100644
index 000000000..6052ef4a0
--- /dev/null
+++ b/Source/Client/Patches/BootstrapMapInitPatch.cs
@@ -0,0 +1,21 @@
+using HarmonyLib;
+using Verse;
+
+namespace Multiplayer.Client
+{
+ [HarmonyPatch(typeof(MapComponentUtility), nameof(MapComponentUtility.FinalizeInit))]
+ static class BootstrapMapInitPatch
+ {
+ static void Postfix(Map map)
+ {
+ if (BootstrapConfiguratorWindow.AwaitingBootstrapMapInit &&
+ BootstrapConfiguratorWindow.Instance != null)
+ {
+ OnMainThread.Enqueue(() =>
+ {
+ BootstrapConfiguratorWindow.Instance.OnBootstrapMapInitialized();
+ });
+ }
+ }
+ }
+}
diff --git a/Source/Client/Patches/BootstrapRootPlayPatch.cs b/Source/Client/Patches/BootstrapRootPlayPatch.cs
new file mode 100644
index 000000000..40bb0b010
--- /dev/null
+++ b/Source/Client/Patches/BootstrapRootPlayPatch.cs
@@ -0,0 +1,22 @@
+using HarmonyLib;
+using Verse;
+
+namespace Multiplayer.Client
+{
+ ///
+ /// Root_Play.Start is called when the game fully transitions into Playing.
+ /// This is a reliable signal for arming the bootstrap map init detection.
+ ///
+ [HarmonyPatch(typeof(Root_Play), nameof(Root_Play.Start))]
+ static class BootstrapRootPlayPatch
+ {
+ static void Postfix()
+ {
+ var inst = BootstrapConfiguratorWindow.Instance;
+ if (inst == null)
+ return;
+
+ inst.TryArmAwaitingBootstrapMapInit_FromRootPlay();
+ }
+ }
+}
diff --git a/Source/Client/Patches/BootstrapRootPlayUpdatePatch.cs b/Source/Client/Patches/BootstrapRootPlayUpdatePatch.cs
new file mode 100644
index 000000000..54e591947
--- /dev/null
+++ b/Source/Client/Patches/BootstrapRootPlayUpdatePatch.cs
@@ -0,0 +1,29 @@
+using HarmonyLib;
+using Verse;
+
+namespace Multiplayer.Client
+{
+ ///
+ /// Root_Play.Update runs through the whole transition from MapInitializing to Playing.
+ /// Arms the map-init trigger as soon as a map exists and ProgramState is Playing.
+ ///
+ [HarmonyPatch(typeof(Root_Play), nameof(Root_Play.Update))]
+ static class BootstrapRootPlayUpdatePatch
+ {
+ private static int nextCheckFrame;
+ private const int CheckEveryFrames = 10;
+
+ static void Postfix()
+ {
+ var win = BootstrapConfiguratorWindow.Instance;
+ if (win == null)
+ return;
+
+ if (UnityEngine.Time.frameCount < nextCheckFrame)
+ return;
+ nextCheckFrame = UnityEngine.Time.frameCount + CheckEveryFrames;
+
+ win.TryArmAwaitingBootstrapMapInit_FromRootPlayUpdate();
+ }
+ }
+}
diff --git a/Source/Client/Patches/BootstrapStartedNewGamePatch.cs b/Source/Client/Patches/BootstrapStartedNewGamePatch.cs
new file mode 100644
index 000000000..d55ca3717
--- /dev/null
+++ b/Source/Client/Patches/BootstrapStartedNewGamePatch.cs
@@ -0,0 +1,28 @@
+using HarmonyLib;
+using RimWorld;
+using Verse;
+
+namespace Multiplayer.Client
+{
+ ///
+ /// When the vanilla flow finishes generating the new game, this fires once the map and pawns are ready.
+ /// Backup signal to kick the bootstrap save pipeline in case FinalizeInit was missed or delayed.
+ ///
+ [HarmonyPatch(typeof(GameComponentUtility), nameof(GameComponentUtility.StartedNewGame))]
+ public static class BootstrapStartedNewGamePatch
+ {
+ static void Postfix()
+ {
+ var window = BootstrapConfiguratorWindow.Instance;
+ if (window == null)
+ return;
+
+ BootstrapConfiguratorWindow.AwaitingBootstrapMapInit = true;
+
+ OnMainThread.Enqueue(() =>
+ {
+ window.OnBootstrapMapInitialized();
+ });
+ }
+ }
+}
diff --git a/Source/Client/Session/Autosaving.cs b/Source/Client/Session/Autosaving.cs
index 499303b05..a3f1a767c 100644
--- a/Source/Client/Session/Autosaving.cs
+++ b/Source/Client/Session/Autosaving.cs
@@ -39,14 +39,24 @@ public static void SaveGameToFile_Overwrite(string fileNameNoExtension, bool cur
try
{
- new FileInfo(Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.zip")).Delete();
- Replay.ForSaving(fileNameNoExtension).WriteData(
+ // Ensure the replays directory exists even when not connected to a server
+ Directory.CreateDirectory(Multiplayer.ReplaysDir);
+
+ 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 dst = new FileInfo(Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.zip"));
+ if (!dst.Exists) dst.Open(FileMode.Create).Close();
+ tmp.Replace(dst.FullName, null);
+
Messages.Message("MpGameSaved".Translate(fileNameNoExtension), MessageTypeDefOf.SilentInput, false);
- Multiplayer.session.lastSaveAt = Time.realtimeSinceStartup;
+ // In bootstrap/offline mode there may be no active session
+ if (Multiplayer.session != null)
+ Multiplayer.session.lastSaveAt = Time.realtimeSinceStartup;
}
catch (Exception e)
{
diff --git a/Source/Client/Session/MultiplayerSession.cs b/Source/Client/Session/MultiplayerSession.cs
index 02d7a5e5f..651bf6888 100644
--- a/Source/Client/Session/MultiplayerSession.cs
+++ b/Source/Client/Session/MultiplayerSession.cs
@@ -61,6 +61,10 @@ public class MultiplayerSession : IConnectionStatusListener
public int port;
public CSteamID? steamHost;
+ // Set during handshake (see Server_Bootstrap packet) to indicate the server is waiting for configuration/upload.
+ public bool serverIsInBootstrap;
+ public bool serverBootstrapSettingsMissing;
+
public void Stop()
{
if (client != null)
diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs
new file mode 100644
index 000000000..06e4bc3a2
--- /dev/null
+++ b/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs
@@ -0,0 +1,463 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using LiteNetLib;
+using Multiplayer.Client.Comp;
+using Multiplayer.Client.Networking;
+using Multiplayer.Client.Util;
+using Multiplayer.Common;
+using Multiplayer.Common.Networking.Packet;
+using RimWorld;
+using RimWorld.Planet;
+using UnityEngine;
+using Verse;
+
+namespace Multiplayer.Client
+{
+ public partial class BootstrapConfiguratorWindow
+ {
+ private void DrawGenerateMap(Rect entry, Rect inRect)
+ {
+ Text.Font = GameFont.Small;
+ var statusHeight = Text.CalcHeight(statusText ?? string.Empty, entry.width);
+ Widgets.Label(entry.Height(statusHeight), statusText ?? string.Empty);
+ entry = entry.Down(statusHeight + 10);
+
+ if (!AwaitingBootstrapMapInit && !saveReady && !isUploadingSave && !isReconnecting)
+ {
+ DrawFactionOwnershipNotice(entry.Height(100f));
+ entry = entry.Down(110);
+ }
+
+ if (!string.IsNullOrEmpty(saveUploadStatus))
+ {
+ var saveStatusHeight = Text.CalcHeight(saveUploadStatus, entry.width);
+ Widgets.Label(entry.Height(saveStatusHeight), saveUploadStatus);
+ entry = entry.Down(saveStatusHeight + 4);
+ }
+
+ if (autoAdvanceArmed || isUploadingSave)
+ {
+ var barRect = entry.Height(18f);
+ Widgets.FillableBar(barRect, isUploadingSave ? saveUploadProgress : 0.1f);
+ entry = entry.Down(24);
+ }
+
+ if (ShouldShowGenerateMapButton() && Widgets.ButtonText(new Rect((inRect.width - 200f) / 2f, inRect.height - 45f, 200f, 40f), "Generate map"))
+ {
+ saveUploadAutoStarted = false;
+ hideWindowDuringMapGen = true;
+ StartVanillaNewColonyFlow();
+ }
+
+ if (ShouldAutoReconnectForSaveUpload())
+ {
+ saveUploadAutoStarted = true;
+ ReconnectAndUploadSave();
+ }
+ }
+
+ private void DrawFactionOwnershipNotice(Rect noticeRect)
+ {
+ GUI.color = new Color(1f, 0.85f, 0.5f);
+ Widgets.DrawBoxSolid(noticeRect, new Color(0.3f, 0.25f, 0.1f, 0.5f));
+ GUI.color = Color.white;
+
+ var noticeTextRect = noticeRect.ContractedBy(8f);
+ Text.Font = GameFont.Tiny;
+ GUI.color = new Color(1f, 0.9f, 0.6f);
+ Widgets.Label(noticeTextRect,
+ "IMPORTANT: The user who generates this map will own the main faction (colony).\n" +
+ "When setting up the server, make sure this user's username is listed as the host.\n" +
+ "Other players connecting to the server will be assigned as spectators or secondary factions.");
+ GUI.color = Color.white;
+ Text.Font = GameFont.Small;
+ }
+
+ private bool ShouldShowGenerateMapButton()
+ {
+ return !(autoAdvanceArmed || AwaitingBootstrapMapInit || saveReady || isUploadingSave || isReconnecting);
+ }
+
+ private bool ShouldAutoReconnectForSaveUpload()
+ {
+ return saveReady && !isUploadingSave && !saveUploadAutoStarted && Multiplayer.Client == null && Current.ProgramState != ProgramState.Playing;
+ }
+
+ private void StartVanillaNewColonyFlow()
+ {
+ if (Multiplayer.session != null)
+ {
+ Multiplayer.session.Stop();
+ Multiplayer.session = null;
+ }
+
+ try
+ {
+ Current.Game ??= new Game();
+ Current.Game.InitData ??= new GameInitData { startedFromEntry = true };
+
+ if (Current.Game.components.All(c => c is not BootstrapCoordinator))
+ Current.Game.components.Add(new BootstrapCoordinator(Current.Game));
+
+ var scenarioPage = new Page_SelectScenario();
+ Find.WindowStack.Add(PageUtility.StitchedPages(new System.Collections.Generic.List { scenarioPage }));
+
+ saveReady = false;
+ savedReplayPath = null;
+ autoAdvanceArmed = true;
+ AwaitingBootstrapMapInit = true;
+ saveUploadStatus = "Generating map...";
+ }
+ catch (Exception e)
+ {
+ Messages.Message($"Failed to start New Colony flow: {e.GetType().Name}: {e.Message}", MessageTypeDefOf.RejectInput, false);
+ }
+ }
+
+ private void TryArmAwaitingBootstrapMapInit()
+ {
+ if (AwaitingBootstrapMapInit)
+ return;
+
+ if (Multiplayer.Client != null || bootstrapSaveQueued || saveReady || isUploadingSave || isReconnecting || saveUploadAutoStarted)
+ return;
+
+ try
+ {
+ if (LongEventHandler.AnyEventNowOrWaiting)
+ return;
+ }
+ catch
+ {
+ }
+
+ if (Current.ProgramState != ProgramState.Playing)
+ return;
+
+ if (Find.Maps == null || Find.Maps.Count == 0)
+ return;
+
+ AwaitingBootstrapMapInit = true;
+ saveUploadStatus = "Entered map. Waiting for initialization to complete...";
+ autoAdvanceArmed = false;
+
+ if (Find.WindowStack != null && Find.WindowStack.WindowOfType() == null)
+ Find.WindowStack.Add(this);
+ }
+
+ internal void TryArmAwaitingBootstrapMapInit_FromRootPlay()
+ {
+ TryArmAwaitingBootstrapMapInit();
+ }
+
+ internal void TryArmAwaitingBootstrapMapInit_FromRootPlayUpdate()
+ {
+ TryArmAwaitingBootstrapMapInit();
+ TickPostMapEnterSaveDelayAndMaybeSave();
+ }
+
+ public void OnBootstrapMapInitialized()
+ {
+ if (!AwaitingBootstrapMapInit)
+ return;
+
+ hideWindowDuringMapGen = false;
+ AwaitingBootstrapMapInit = false;
+ postMapEnterSaveDelayRemaining = PostMapEnterSaveDelaySeconds;
+ awaitingControllablePawns = true;
+ awaitingControllablePawnsElapsed = 0f;
+ bootstrapSaveQueued = false;
+ saveUploadStatus = "Map initialized. Waiting before saving...";
+ }
+
+ private void TickPostMapEnterSaveDelayAndMaybeSave()
+ {
+ if (bootstrapSaveQueued || saveReady || isUploadingSave || isReconnecting)
+ return;
+
+ if (postMapEnterSaveDelayRemaining <= 0f && !awaitingControllablePawns)
+ return;
+
+ postMapEnterSaveDelayRemaining -= Time.deltaTime;
+ if (postMapEnterSaveDelayRemaining > 0f)
+ return;
+
+ if (!WaitForControllableColonists())
+ return;
+
+ postMapEnterSaveDelayRemaining = 0f;
+ bootstrapSaveQueued = true;
+ saveUploadStatus = "Map initialized. Starting hosted MP session...";
+
+ LongEventHandler.QueueLongEvent(StartHostedBootstrapSaveCreation, "Starting host", false, null);
+ }
+
+ private bool WaitForControllableColonists()
+ {
+ if (!awaitingControllablePawns)
+ return true;
+
+ awaitingControllablePawnsElapsed += Time.deltaTime;
+
+ if (Current.ProgramState == ProgramState.Playing && Find.CurrentMap != null)
+ {
+ var anyColonist = false;
+ try
+ {
+ anyColonist = Find.CurrentMap.mapPawns?.FreeColonistsSpawned != null
+ && Find.CurrentMap.mapPawns.FreeColonistsSpawned.Count > 0;
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"Exception checking for controllable colonists: {ex.GetType().Name}: {ex.Message}");
+ }
+
+ if (anyColonist)
+ {
+ awaitingControllablePawns = false;
+ try { Find.TickManager.CurTimeSpeed = TimeSpeed.Paused; } catch { }
+ }
+ }
+
+ if (!awaitingControllablePawns)
+ return true;
+
+ if (awaitingControllablePawnsElapsed > AwaitControllablePawnsTimeoutSeconds)
+ {
+ awaitingControllablePawns = false;
+ Log.Warning("Timed out waiting for controllable pawns during bootstrap; saving anyway");
+ return true;
+ }
+
+ saveUploadStatus = "Waiting for controllable colonists to spawn...";
+ return false;
+ }
+
+ private void StartHostedBootstrapSaveCreation()
+ {
+ try
+ {
+ var hostSettings = new ServerSettings
+ {
+ gameName = settings.gameName,
+ maxPlayers = 2,
+ direct = false,
+ lan = false,
+ steam = false,
+ };
+
+ if (!HostWindow.HostProgrammatically(hostSettings))
+ {
+ OnMainThread.Enqueue(() =>
+ {
+ saveUploadStatus = "Failed to host MP session.";
+ Log.Error("HostProgrammatically failed during bootstrap save creation");
+ bootstrapSaveQueued = false;
+ });
+ return;
+ }
+
+ OnMainThread.Enqueue(() =>
+ {
+ saveUploadStatus = "Hosted. Saving replay...";
+ LongEventHandler.QueueLongEvent(CreateBootstrapReplaySave, "Saving", false, null);
+ });
+ }
+ catch (Exception e)
+ {
+ OnMainThread.Enqueue(() =>
+ {
+ saveUploadStatus = $"Host failed: {e.GetType().Name}: {e.Message}";
+ Log.Error($"Bootstrap host exception: {e}");
+ bootstrapSaveQueued = false;
+ });
+ }
+ }
+
+ private void CreateBootstrapReplaySave()
+ {
+ try
+ {
+ Autosaving.SaveGameToFile_Overwrite(BootstrapSaveName, currentReplay: false);
+ var path = Path.Combine(Multiplayer.ReplaysDir, $"{BootstrapSaveName}.zip");
+ OnMainThread.Enqueue(() => FinalizeBootstrapSave(path));
+ }
+ catch (Exception e)
+ {
+ OnMainThread.Enqueue(() =>
+ {
+ saveUploadStatus = $"Save failed: {e.GetType().Name}: {e.Message}";
+ Log.Error($"Bootstrap save failed: {e}");
+ bootstrapSaveQueued = false;
+ });
+ }
+ }
+
+ private void FinalizeBootstrapSave(string path)
+ {
+ savedReplayPath = path;
+ saveReady = File.Exists(savedReplayPath);
+ lastSavedReplayPath = savedReplayPath;
+ lastSaveReady = saveReady;
+
+ if (!saveReady)
+ {
+ saveUploadStatus = $"Save finished but file not found: {path}";
+ Log.Error($"Bootstrap save finished but file was missing: {savedReplayPath}");
+ bootstrapSaveQueued = false;
+ return;
+ }
+
+ saveUploadAutoStarted = true;
+ saveUploadStatus = "Save created. Returning to menu...";
+ LongEventHandler.QueueLongEvent(ReturnToMenuAndReconnect, "Returning to menu", false, null);
+ }
+
+ private void ReturnToMenuAndReconnect()
+ {
+ GenScene.GoToMainMenu();
+ OnMainThread.Enqueue(() =>
+ {
+ saveUploadStatus = "Reconnecting to upload save...";
+ ReconnectAndUploadSave();
+ });
+ }
+
+ private void ReconnectAndUploadSave()
+ {
+ saveUploadStatus = "Reconnecting to server...";
+
+ try
+ {
+ Multiplayer.StopMultiplayer();
+
+ Multiplayer.session = new MultiplayerSession
+ {
+ address = serverAddress,
+ port = serverPort
+ };
+
+ var netClient = new NetManager(new MpClientNetListener())
+ {
+ EnableStatistics = true,
+ IPv6Enabled = MpUtil.SupportsIPv6() ? IPv6Mode.SeparateSocket : IPv6Mode.Disabled
+ };
+ netClient.Start();
+ netClient.ReconnectDelay = 300;
+ netClient.MaxConnectAttempts = 8;
+
+ Multiplayer.session.netClient = netClient;
+ netClient.Connect(serverAddress, serverPort, string.Empty);
+
+ isReconnecting = true;
+ reconnectCheckTimer = 0;
+ }
+ catch (Exception e)
+ {
+ saveUploadStatus = $"Reconnection failed: {e.GetType().Name}: {e.Message}";
+ isUploadingSave = false;
+ }
+ }
+
+ private void CheckReconnectionState()
+ {
+ reconnectCheckTimer++;
+
+ if (Multiplayer.Client?.State == ConnectionStateEnum.ClientBootstrap)
+ {
+ saveUploadStatus = "Reconnected. Starting upload...";
+ isReconnecting = false;
+ reconnectCheckTimer = 0;
+ StartUploadSaveZip();
+ }
+ else if (Multiplayer.Client?.State == ConnectionStateEnum.Disconnected || (Multiplayer.Client == null && reconnectCheckTimer > 300))
+ {
+ saveUploadStatus = "Reconnection failed. Cannot upload save.zip.";
+ isReconnecting = false;
+ reconnectCheckTimer = 0;
+ isUploadingSave = false;
+ }
+ else if (reconnectCheckTimer > 600)
+ {
+ saveUploadStatus = "Reconnection timeout. Cannot upload save.zip.";
+ isReconnecting = false;
+ reconnectCheckTimer = 0;
+ isUploadingSave = false;
+ }
+ }
+
+ private void StartUploadSaveZip()
+ {
+ if (string.IsNullOrWhiteSpace(savedReplayPath) || !File.Exists(savedReplayPath))
+ {
+ saveUploadStatus = "Can't upload: autosave file not found.";
+ return;
+ }
+
+ isUploadingSave = true;
+ saveUploadProgress = 0f;
+ saveUploadStatus = "Uploading save.zip...";
+
+ byte[] bytes;
+ try
+ {
+ bytes = File.ReadAllBytes(savedReplayPath);
+ }
+ catch (Exception e)
+ {
+ isUploadingSave = false;
+ saveUploadStatus = $"Failed to read autosave: {e.GetType().Name}: {e.Message}";
+ return;
+ }
+
+ var targetConn = Multiplayer.Client;
+ if (targetConn == null)
+ {
+ isUploadingSave = false;
+ saveUploadStatus = "No active connection. Cannot upload.";
+ return;
+ }
+
+ new System.Threading.Thread(() =>
+ {
+ try
+ {
+ targetConn.Send(new ClientBootstrapSaveStartPacket("save.zip", bytes.Length));
+
+ const int chunk = 256 * 1024;
+ var sent = 0;
+ while (sent < bytes.Length)
+ {
+ var len = Math.Min(chunk, bytes.Length - sent);
+ var part = new byte[len];
+ Buffer.BlockCopy(bytes, sent, part, 0, len);
+ targetConn.SendFragmented(new ClientBootstrapSaveDataPacket(part).Serialize());
+ sent += len;
+ var progress = bytes.Length == 0 ? 1f : (float)sent / bytes.Length;
+ OnMainThread.Enqueue(() => saveUploadProgress = Mathf.Clamp01(progress));
+ }
+
+ byte[] sha256Hash;
+ using (var hasher = SHA256.Create())
+ sha256Hash = hasher.ComputeHash(bytes);
+
+ targetConn.Send(new ClientBootstrapSaveEndPacket(sha256Hash));
+ OnMainThread.Enqueue(() =>
+ {
+ saveUploadStatus = "Upload finished. Waiting for server to confirm and shut down...";
+ });
+ }
+ catch (Exception e)
+ {
+ OnMainThread.Enqueue(() =>
+ {
+ isUploadingSave = false;
+ saveUploadStatus = $"Failed to upload save.zip: {e.GetType().Name}: {e.Message}";
+ });
+ }
+ }) { IsBackground = true, Name = "MP Bootstrap save upload" }.Start();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.SettingsUi.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.SettingsUi.cs
new file mode 100644
index 000000000..370f30e14
--- /dev/null
+++ b/Source/Client/Windows/BootstrapConfiguratorWindow.SettingsUi.cs
@@ -0,0 +1,200 @@
+using System;
+using Multiplayer.Client.Util;
+using Multiplayer.Common.Networking.Packet;
+using Multiplayer.Common.Util;
+using RimWorld;
+using UnityEngine;
+using Verse;
+using Verse.Sound;
+
+namespace Multiplayer.Client
+{
+ public partial class BootstrapConfiguratorWindow
+ {
+ private void DrawSettings(Rect entry, Rect inRect)
+ {
+ if (!string.IsNullOrEmpty(statusText))
+ {
+ var statusHeight = Text.CalcHeight(statusText, entry.width);
+ Widgets.Label(entry.Height(statusHeight), statusText);
+ entry = entry.Down(statusHeight + 4);
+ }
+
+ if (isUploadingToml)
+ {
+ var barRect = entry.Height(20f);
+ Widgets.FillableBar(barRect, uploadProgress);
+ entry = entry.Down(24);
+ }
+
+ using (MpStyle.Set(TextAnchor.MiddleLeft))
+ {
+ DoTabButton(entry.Width(140).Height(40f), Tab.Connecting);
+ DoTabButton(entry.Down(50f).Width(140).Height(40f), Tab.Gameplay);
+ if (Prefs.DevMode)
+ DoTabButton(entry.Down(100f).Width(140).Height(40f), Tab.Preview);
+ }
+
+ var contentRect = entry.MinX(entry.xMin + 150);
+ var buffers = new ServerSettingsUI.BufferSet
+ {
+ MaxPlayersBuffer = settingsUiBuffers.MaxPlayersBuffer,
+ AutosaveBuffer = settingsUiBuffers.AutosaveBuffer
+ };
+
+ if (tab == Tab.Connecting)
+ ServerSettingsUI.DrawNetworkingSettings(contentRect, settings, buffers);
+ else if (tab == Tab.Gameplay)
+ ServerSettingsUI.DrawGameplaySettingsOnly(contentRect, settings, buffers);
+ else if (tab == Tab.Preview)
+ DrawPreviewTab(contentRect, inRect.height);
+
+ settingsUiBuffers.MaxPlayersBuffer = buffers.MaxPlayersBuffer;
+ settingsUiBuffers.AutosaveBuffer = buffers.AutosaveBuffer;
+
+ DrawSettingsButtons(new Rect(0, inRect.height - 40f, inRect.width, 35f));
+ }
+
+ private void DrawPreviewTab(Rect contentRect, float windowHeight)
+ {
+ RebuildTomlPreview();
+ var previewRect = new Rect(contentRect.x, contentRect.y, contentRect.width, windowHeight - contentRect.y - 50f);
+ DrawTomlPreview(previewRect);
+ }
+
+ private void DoTabButton(Rect r, Tab tab)
+ {
+ Widgets.DrawOptionBackground(r, tab == this.tab);
+ if (Widgets.ButtonInvisible(r, true))
+ {
+ this.tab = tab;
+ SoundDefOf.Click.PlayOneShotOnCamera();
+ }
+
+ float x = r.x + 10f;
+ Texture2D icon;
+ string label;
+
+ if (tab == Tab.Connecting)
+ {
+ icon = ContentFinder.Get("UI/Icons/Options/OptionsGeneral");
+ label = "MpHostTabConnecting".Translate();
+ }
+ else if (tab == Tab.Gameplay)
+ {
+ icon = ContentFinder.Get("UI/Icons/Options/OptionsGameplay");
+ label = "MpHostTabGameplay".Translate();
+ }
+ else
+ {
+ icon = null;
+ label = "Preview";
+ }
+
+ if (icon != null)
+ {
+ var iconRect = new Rect(x, r.y + (r.height - 20f) / 2f, 20f, 20f);
+ GUI.DrawTexture(iconRect, icon);
+ x += 30f;
+ }
+
+ Widgets.Label(new Rect(x, r.y, r.width - x, r.height), label);
+ }
+
+ private void DrawSettingsButtons(Rect inRect)
+ {
+ Rect nextRect;
+ if (Prefs.DevMode)
+ {
+ var copyRect = new Rect(inRect.x, inRect.y, 150f, inRect.height);
+ if (Widgets.ButtonText(copyRect, "Copy TOML"))
+ {
+ RebuildTomlPreview();
+ GUIUtility.systemCopyBuffer = tomlPreview;
+ Messages.Message("Copied settings.toml to clipboard", MessageTypeDefOf.SilentInput, false);
+ }
+
+ nextRect = new Rect(inRect.xMax - 150f, inRect.y, 150f, inRect.height);
+ }
+ else
+ {
+ nextRect = new Rect((inRect.width - 150f) / 2f, inRect.y, 150f, inRect.height);
+ }
+
+ var nextLabel = settingsUploaded ? "Uploaded" : "Next";
+ var nextEnabled = !isUploadingToml && !settingsUploaded;
+
+ var prevColor = GUI.color;
+ if (!nextEnabled)
+ GUI.color = new Color(1f, 1f, 1f, 0.5f);
+
+ if (Widgets.ButtonText(nextRect, nextLabel) && nextEnabled)
+ {
+ RebuildTomlPreview();
+ StartUploadSettingsToml();
+ }
+
+ GUI.color = prevColor;
+ }
+
+ private void StartUploadSettingsToml()
+ {
+ isUploadingToml = true;
+ uploadProgress = 0f;
+ statusText = "Uploading server settings...";
+
+ new System.Threading.Thread(() =>
+ {
+ try
+ {
+ connection.Send(new ClientBootstrapSettingsPacket(settings));
+
+ OnMainThread.Enqueue(() =>
+ {
+ isUploadingToml = false;
+ settingsUploaded = true;
+ statusText = "Server settings configured correctly. Proceed with map generation.";
+ step = Step.GenerateMap;
+
+ if (Multiplayer.session != null)
+ Multiplayer.session.serverBootstrapSettingsMissing = false;
+ });
+ }
+ catch (Exception e)
+ {
+ Log.Error($"Bootstrap settings upload failed: {e}");
+ OnMainThread.Enqueue(() =>
+ {
+ isUploadingToml = false;
+ statusText = $"Failed to upload settings: {e.GetType().Name}: {e.Message}";
+ });
+ }
+ }) { IsBackground = true, Name = "MP Bootstrap settings upload" }.Start();
+ }
+
+ private void DrawTomlPreview(Rect inRect)
+ {
+ Widgets.DrawMenuSection(inRect);
+ var inner = inRect.ContractedBy(10f);
+
+ Text.Font = GameFont.Small;
+ Widgets.Label(inner.TopPartPixels(22f), "settings.toml preview");
+
+ var previewRect = new Rect(inner.x, inner.y + 26f, inner.width, inner.height - 26f);
+ var content = tomlPreview ?? string.Empty;
+ var contentHeight = Text.CalcHeight(content, previewRect.width - 16f) + 20f;
+ var viewRect = new Rect(0f, 0f, previewRect.width - 16f, Mathf.Max(previewRect.height, contentHeight));
+
+ Widgets.BeginScrollView(previewRect, ref tomlScroll, viewRect);
+ Widgets.Label(new Rect(0f, 0f, viewRect.width, viewRect.height), content);
+ Widgets.EndScrollView();
+ }
+
+ private void RebuildTomlPreview()
+ {
+ tomlPreview = "# Generated by Multiplayer bootstrap configurator\n"
+ + "# Serialized with TomlSettingsCommon\n\n"
+ + TomlSettingsCommon.Serialize(settings);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs
new file mode 100644
index 000000000..628b79d53
--- /dev/null
+++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs
@@ -0,0 +1,236 @@
+using System;
+using System.IO;
+using Multiplayer.Client.Networking;
+using Multiplayer.Client.Util;
+using Multiplayer.Common;
+using UnityEngine;
+using Verse;
+
+namespace Multiplayer.Client
+{
+ ///
+ /// Shown when connecting to a server that's in bootstrap/configuration mode.
+ /// Guides the user through uploading settings.toml (if needed) and then save.zip.
+ ///
+ public partial class BootstrapConfiguratorWindow : Window
+ {
+ private readonly ConnectionBase connection;
+ private string serverAddress;
+ private int serverPort;
+ private bool isReconnecting;
+ private int reconnectCheckTimer;
+
+ private ServerSettings settings;
+
+ private enum Step
+ {
+ Settings,
+ GenerateMap
+ }
+
+ private enum Tab
+ {
+ Connecting,
+ Gameplay,
+ Preview
+ }
+
+ private Step step;
+ private Tab tab;
+
+ // UI buffers
+ private ServerSettingsUI.BufferSet settingsUiBuffers = new();
+
+ // toml preview
+ private string tomlPreview;
+ private Vector2 tomlScroll;
+
+ private bool isUploadingToml;
+ private float uploadProgress;
+ private string statusText;
+ private bool settingsUploaded;
+
+ // Save.zip upload
+ private bool isUploadingSave;
+ private float saveUploadProgress;
+ private string saveUploadStatus;
+ private static string lastSavedReplayPath;
+ private static bool lastSaveReady;
+
+ // Autosave trigger (once) during bootstrap map generation
+ private bool saveReady;
+ private string savedReplayPath;
+
+ private const string BootstrapSaveName = "Bootstrap";
+ private bool saveUploadAutoStarted;
+ private bool autoUploadAttempted;
+
+ // Vanilla page auto-advance during bootstrap
+ private bool autoAdvanceArmed;
+
+ // Delay before saving after entering the map
+ private float postMapEnterSaveDelayRemaining;
+ private const float PostMapEnterSaveDelaySeconds = 1f;
+
+ // Ensure we don't queue multiple saves.
+ private bool bootstrapSaveQueued;
+
+ // After entering a map, wait until at least one controllable colonist pawn exists.
+ private bool awaitingControllablePawns;
+ private float awaitingControllablePawnsElapsed;
+ private const float AwaitControllablePawnsTimeoutSeconds = 30f;
+
+ // Static flag to track bootstrap map initialization
+ public static bool AwaitingBootstrapMapInit;
+ public static BootstrapConfiguratorWindow Instance;
+
+ // Hide window during map generation/tile selection
+ private bool hideWindowDuringMapGen;
+
+ private const float LabelWidth = 110f;
+ private const int MaxGameNameLength = 70;
+
+ public override Vector2 InitialSize => new(550f, 620f);
+
+ public BootstrapConfiguratorWindow(ConnectionBase connection)
+ {
+ this.connection = connection;
+ Instance = this;
+
+ // Save server address for reconnection after world generation
+ serverAddress = Multiplayer.session?.address;
+ serverPort = Multiplayer.session?.port ?? 0;
+
+ doCloseX = true;
+ closeOnClickedOutside = false;
+ absorbInputAroundWindow = false;
+ forcePause = false;
+
+ // Initialize with reasonable defaults for standalone/headless server
+ settings = new ServerSettings
+ {
+ gameName = $"{Multiplayer.username}'s Server",
+ direct = true,
+ lan = false,
+ steam = false,
+ arbiter = false,
+ maxPlayers = 8,
+ autosaveInterval = 1,
+ autosaveUnit = AutosaveUnit.Days
+ };
+
+ // Initialize UI buffers
+ settingsUiBuffers.MaxPlayersBuffer = settings.maxPlayers.ToString();
+ settingsUiBuffers.AutosaveBuffer = settings.autosaveInterval.ToString();
+
+ // Choose the initial step based on what the server told us.
+ step = Multiplayer.session?.serverBootstrapSettingsMissing == true ? Step.Settings : Step.GenerateMap;
+
+ statusText = step == Step.Settings
+ ? "Server settings.toml is missing. Configure and upload it."
+ : "Server settings.toml is already configured.";
+
+ // Check if we have a previously saved Bootstrap.zip from this session (reconnect case)
+ if (!autoUploadAttempted && lastSaveReady && !string.IsNullOrEmpty(lastSavedReplayPath) && File.Exists(lastSavedReplayPath))
+ {
+ savedReplayPath = lastSavedReplayPath;
+ saveReady = true;
+ saveUploadStatus = "Save ready from previous session. Uploading...";
+ saveUploadAutoStarted = true;
+ autoUploadAttempted = true;
+ StartUploadSaveZip();
+ }
+
+ RebuildTomlPreview();
+ }
+
+ public override void DoWindowContents(Rect inRect)
+ {
+ Text.Font = GameFont.Medium;
+ Text.Anchor = TextAnchor.UpperCenter;
+
+ // Title
+ Widgets.Label(inRect.Down(0), "Server Bootstrap Configuration");
+ Text.Anchor = TextAnchor.UpperLeft;
+ Text.Font = GameFont.Small;
+
+ var entry = new Rect(0, 45, inRect.width, 30f);
+ entry.xMin += 4;
+
+ // Game name
+ settings.gameName = MpUI.TextEntryLabeled(entry, $"{"MpGameName".Translate()}: ", settings.gameName, LabelWidth);
+ if (settings.gameName.Length > MaxGameNameLength)
+ settings.gameName = settings.gameName.Substring(0, MaxGameNameLength);
+
+ entry = entry.Down(40);
+
+ if (step == Step.Settings)
+ DrawSettings(entry, inRect);
+ else
+ DrawGenerateMap(entry, inRect);
+ }
+
+ public override void PreOpen()
+ {
+ base.PreOpen();
+ UpdateWindowVisibility();
+ }
+
+ public override void WindowUpdate()
+ {
+ base.WindowUpdate();
+
+ UpdateWindowVisibility();
+
+ // Always try to drive the save delay
+ TickPostMapEnterSaveDelayAndMaybeSave();
+
+ // Poll LiteNet during reconnection
+ if (isReconnecting && Multiplayer.session?.netClient != null)
+ Multiplayer.session.netClient.PollEvents();
+
+ if (isReconnecting)
+ CheckReconnectionState();
+
+ // If we've reconnected and server indicates settings are missing, reset to settings step
+ if (!isReconnecting && Multiplayer.session?.serverBootstrapSettingsMissing == true && step == Step.GenerateMap)
+ {
+ step = Step.Settings;
+ settingsUploaded = false;
+ statusText = "Server settings.toml is missing. Configure and upload it.";
+ }
+ }
+
+ private void UpdateWindowVisibility()
+ {
+ if (hideWindowDuringMapGen)
+ {
+ windowRect.width = 0;
+ windowRect.height = 0;
+ }
+ else
+ {
+ var size = InitialSize;
+ if (windowRect.width == 0)
+ {
+ windowRect.width = size.x;
+ windowRect.height = size.y;
+ windowRect.x = (UI.screenWidth - size.x) / 2f;
+ windowRect.y = (UI.screenHeight - size.y) / 2f;
+ }
+ }
+ }
+
+ ///
+ /// Called by once per second while the bootstrap window exists.
+ ///
+ internal void BootstrapCoordinatorTick()
+ {
+ if (!AwaitingBootstrapMapInit)
+ TryArmAwaitingBootstrapMapInit();
+
+ TickPostMapEnterSaveDelayAndMaybeSave();
+ }
+
+ }
+}
diff --git a/Source/Client/Windows/HostWindow.cs b/Source/Client/Windows/HostWindow.cs
index fbe44321c..a5792d960 100644
--- a/Source/Client/Windows/HostWindow.cs
+++ b/Source/Client/Windows/HostWindow.cs
@@ -290,5 +290,17 @@ private void HostFromReplay(ServerSettings settings)
ReplayLoaded();
}
}
+
+ ///
+ /// Programmatically host a multiplayer game from the current single-player state
+ /// without showing the host window. Used by the bootstrap flow.
+ ///
+ public static bool HostProgrammatically(ServerSettings settings)
+ {
+ var localServer = new MultiplayerServer(settings);
+ Multiplayer.LocalServer = localServer;
+ HostUtil.HostServer(settings, false);
+ return true;
+ }
}
}
diff --git a/Source/Client/Windows/ServerSettingsUI.cs b/Source/Client/Windows/ServerSettingsUI.cs
index c9314fdd5..ac6fa95b2 100644
--- a/Source/Client/Windows/ServerSettingsUI.cs
+++ b/Source/Client/Windows/ServerSettingsUI.cs
@@ -72,6 +72,12 @@ public static void DrawNetworkingSettings(Rect entry, ServerSettings settings, B
MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), $"{"MpSyncConfigs".Translate()}: ", ref settings.syncConfigs, order: ElementOrder.Right);
}
+ ///
+ /// Draw gameplay-related settings without lock parameters. Used by bootstrap configurator.
+ ///
+ public static void DrawGameplaySettingsOnly(Rect entry, ServerSettings settings, BufferSet buffers)
+ => DrawGameplaySettings(entry, settings, buffers);
+
///
/// Draw gameplay-related settings (autosave, multifaction, async time, time control, etc.).
///
diff --git a/Source/Common/Networking/State/ServerBootstrapState.cs b/Source/Common/Networking/State/ServerBootstrapState.cs
index ad5362705..0ecbde623 100644
--- a/Source/Common/Networking/State/ServerBootstrapState.cs
+++ b/Source/Common/Networking/State/ServerBootstrapState.cs
@@ -12,6 +12,7 @@ namespace Multiplayer.Common;
/// Once received, the server writes it to disk and then disconnects all clients and stops,
/// so an external supervisor can restart it in normal mode.
///
+[PacketHandlerClass]
public class ServerBootstrapState(ConnectionBase conn) : MpConnectionState(conn)
{
// Only one configurator at a time; track by username to survive reconnections
@@ -180,6 +181,14 @@ public void HandleSaveEnd(ClientBootstrapSaveEndPacket packet)
private bool IsConfigurator() => configuratorUsername == connection.username;
+ [PacketHandler(Packets.Client_Cursor)]
+ public void HandleCursor(ByteReader data)
+ {
+ // Drain the packet so the "not fully consumed" check passes
+ data.ReadByte(); // seq
+ data.ReadByte(); // map
+ }
+
private static void ResetUploadState()
{
pendingFileName = null;
diff --git a/Source/Common/Networking/State/ServerJoiningState.cs b/Source/Common/Networking/State/ServerJoiningState.cs
index ded55a57e..d8b79c295 100644
--- a/Source/Common/Networking/State/ServerJoiningState.cs
+++ b/Source/Common/Networking/State/ServerJoiningState.cs
@@ -4,6 +4,7 @@
namespace Multiplayer.Common;
+[PacketHandlerClass]
public class ServerJoiningState : AsyncConnectionState
{
public ServerJoiningState(ConnectionBase connection) : base(connection)
diff --git a/Source/Common/Networking/State/ServerLoadingState.cs b/Source/Common/Networking/State/ServerLoadingState.cs
index 0b5032074..0e602a140 100644
--- a/Source/Common/Networking/State/ServerLoadingState.cs
+++ b/Source/Common/Networking/State/ServerLoadingState.cs
@@ -3,6 +3,7 @@
namespace Multiplayer.Common;
+[PacketHandlerClass]
public class ServerLoadingState : AsyncConnectionState
{
public ServerLoadingState(ConnectionBase connection) : base(connection)
diff --git a/Source/Common/Networking/State/ServerPlayingState.cs b/Source/Common/Networking/State/ServerPlayingState.cs
index 04a65728a..bc832d57a 100644
--- a/Source/Common/Networking/State/ServerPlayingState.cs
+++ b/Source/Common/Networking/State/ServerPlayingState.cs
@@ -4,6 +4,7 @@
namespace Multiplayer.Common
{
+ [PacketHandlerClass]
public class ServerPlayingState(ConnectionBase conn) : MpConnectionState(conn)
{
[PacketHandler(Packets.Client_WorldReady)]
diff --git a/Source/Common/Networking/State/ServerSteamState.cs b/Source/Common/Networking/State/ServerSteamState.cs
index 414094a8f..214ff53df 100644
--- a/Source/Common/Networking/State/ServerSteamState.cs
+++ b/Source/Common/Networking/State/ServerSteamState.cs
@@ -1,6 +1,7 @@
namespace Multiplayer.Common
{
// Unused
+ [PacketHandlerClass]
public class ServerSteamState : MpConnectionState
{
public ServerSteamState(ConnectionBase conn) : base(conn)
diff --git a/Source/Common/PlayerManager.cs b/Source/Common/PlayerManager.cs
index 13bf67e11..7eaa31318 100644
--- a/Source/Common/PlayerManager.cs
+++ b/Source/Common/PlayerManager.cs
@@ -37,7 +37,7 @@ public void SendLatencies()
// id can be an IPAddress or CSteamID
public MpDisconnectReason? OnPreConnect(object id)
{
- if (server.FullyStarted is false)
+ if (server.FullyStarted is false && !server.BootstrapMode)
return MpDisconnectReason.ServerStarting;
if (id is IPAddress addr && IPAddress.IsLoopback(addr))