From f827b35b20f7ccc241fc1231ed32981ff3069df3 Mon Sep 17 00:00:00 2001 From: mibac138 <5672750+mibac138@users.noreply.github.com> Date: Wed, 17 Dec 2025 12:39:16 +0100 Subject: [PATCH 01/28] Name temporary configs more similarly to how vanilla does it The only remaining difference is that vanilla prefers to use a mod's steam id, while we always use the package name. --- Source/Client/EarlyPatches/SettingsPatches.cs | 3 +-- Source/Client/Windows/JoinDataWindow.cs | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Source/Client/EarlyPatches/SettingsPatches.cs b/Source/Client/EarlyPatches/SettingsPatches.cs index 0a10fcfa..e91f0d28 100644 --- a/Source/Client/EarlyPatches/SettingsPatches.cs +++ b/Source/Client/EarlyPatches/SettingsPatches.cs @@ -132,10 +132,9 @@ static void Postfix(string modIdentifier, string modHandleName, ref string __res if (JoinData.ignoredConfigsModIds.Contains(mod.ModMetaData.PackageIdNonUnique)) return; - // Example: MultiplayerTempConfigs/rwmt.multiplayer-Multiplayer var newPath = Path.Combine( GenFilePaths.FolderUnderSaveData(JoinData.TempConfigsDir), - GenText.SanitizeFilename(mod.PackageIdPlayerFacing.ToLowerInvariant() + "-" + modHandleName) + GenText.SanitizeFilename($"Mod_{mod.PackageIdPlayerFacing.ToLowerInvariant()}_{modHandleName}.xml") ); __result = newPath; diff --git a/Source/Client/Windows/JoinDataWindow.cs b/Source/Client/Windows/JoinDataWindow.cs index ffd2aff2..73451363 100644 --- a/Source/Client/Windows/JoinDataWindow.cs +++ b/Source/Client/Windows/JoinDataWindow.cs @@ -756,7 +756,7 @@ private void DoRestart() tempDir.Create(); foreach (var config in data.remoteModConfigs) - File.WriteAllText(Path.Combine(tempPath, $"{config.ModId}-{config.FileName}"), config.Contents); + File.WriteAllText(Path.Combine(tempPath, $"Mod_{config.ModId}_{config.FileName}.xml"), config.Contents); } var connectTo = data.remoteSteamHost != null From 200144613cfcf1f5ab77dad8cfabf9ff3972a76a Mon Sep 17 00:00:00 2001 From: mibac138 <5672750+mibac138@users.noreply.github.com> Date: Wed, 17 Dec 2025 12:32:41 +0100 Subject: [PATCH 02/28] Extract common logic into SyncConfigs --- Source/Client/EarlyPatches/SettingsPatches.cs | 8 ++----- Source/Client/Util/SyncConfigs.cs | 24 +++++++++++++++++++ Source/Client/Windows/JoinDataWindow.cs | 9 +------ 3 files changed, 27 insertions(+), 14 deletions(-) create mode 100644 Source/Client/Util/SyncConfigs.cs diff --git a/Source/Client/EarlyPatches/SettingsPatches.cs b/Source/Client/EarlyPatches/SettingsPatches.cs index e91f0d28..df96bb91 100644 --- a/Source/Client/EarlyPatches/SettingsPatches.cs +++ b/Source/Client/EarlyPatches/SettingsPatches.cs @@ -4,6 +4,7 @@ using System.Reflection; using HarmonyLib; using Multiplayer.Client.Patches; +using Multiplayer.Client.Util; using Verse; namespace Multiplayer.Client.EarlyPatches @@ -132,12 +133,7 @@ static void Postfix(string modIdentifier, string modHandleName, ref string __res if (JoinData.ignoredConfigsModIds.Contains(mod.ModMetaData.PackageIdNonUnique)) return; - var newPath = Path.Combine( - GenFilePaths.FolderUnderSaveData(JoinData.TempConfigsDir), - GenText.SanitizeFilename($"Mod_{mod.PackageIdPlayerFacing.ToLowerInvariant()}_{modHandleName}.xml") - ); - - __result = newPath; + __result = SyncConfigs.GetConfigPath(mod.PackageIdPlayerFacing.ToLowerInvariant(), modHandleName); } } diff --git a/Source/Client/Util/SyncConfigs.cs b/Source/Client/Util/SyncConfigs.cs new file mode 100644 index 00000000..a87e7e20 --- /dev/null +++ b/Source/Client/Util/SyncConfigs.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.IO; +using Verse; + +namespace Multiplayer.Client.Util; + +/// Responsible for saving a server's config files and retrieving them later. +public static class SyncConfigs +{ + private static readonly string TempConfigsPath = GenFilePaths.FolderUnderSaveData(JoinData.TempConfigsDir); + + public static void SaveConfigs(List configs) + { + var tempDir = new DirectoryInfo(TempConfigsPath); + tempDir.Delete(true); + tempDir.Create(); + + foreach (var config in configs) + File.WriteAllText(Path.Combine(TempConfigsPath, $"Mod_{config.ModId}_{config.FileName}.xml"), config.Contents); + } + + public static string GetConfigPath(string modId, string handleName) => + Path.Combine(TempConfigsPath, GenText.SanitizeFilename($"Mod_{modId}_{handleName}.xml")); +} diff --git a/Source/Client/Windows/JoinDataWindow.cs b/Source/Client/Windows/JoinDataWindow.cs index 73451363..56c369d4 100644 --- a/Source/Client/Windows/JoinDataWindow.cs +++ b/Source/Client/Windows/JoinDataWindow.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Linq; using Multiplayer.Client.Util; using Multiplayer.Common; @@ -750,13 +749,7 @@ private void DoRestart() if (applyConfigs) { - var tempPath = GenFilePaths.FolderUnderSaveData(JoinData.TempConfigsDir); - var tempDir = new DirectoryInfo(tempPath); - tempDir.Delete(true); - tempDir.Create(); - - foreach (var config in data.remoteModConfigs) - File.WriteAllText(Path.Combine(tempPath, $"Mod_{config.ModId}_{config.FileName}.xml"), config.Contents); + SyncConfigs.SaveConfigs(data.remoteModConfigs); } var connectTo = data.remoteSteamHost != null From 7c9752f6eb86ccc0b14fe817b0af6e743064c4a8 Mon Sep 17 00:00:00 2001 From: mibac138 <5672750+mibac138@users.noreply.github.com> Date: Sat, 20 Dec 2025 03:58:17 +0100 Subject: [PATCH 03/28] SyncConfigs: handle patches and env vars on it's own --- Source/Client/EarlyInit.cs | 8 +-- Source/Client/EarlyPatches/SettingsPatches.cs | 67 ----------------- Source/Client/Multiplayer.cs | 1 - Source/Client/Networking/JoinData.cs | 2 +- Source/Client/Util/SyncConfigs.cs | 71 +++++++++++++++++++ Source/Client/Windows/JoinDataWindow.cs | 2 +- 6 files changed, 74 insertions(+), 77 deletions(-) diff --git a/Source/Client/EarlyInit.cs b/Source/Client/EarlyInit.cs index 7e565f57..e7d6beb6 100644 --- a/Source/Client/EarlyInit.cs +++ b/Source/Client/EarlyInit.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Reflection; using HarmonyLib; using Multiplayer.Client.Patches; @@ -12,7 +11,6 @@ namespace Multiplayer.Client; public static class EarlyInit { public const string RestartConnectVariable = "MultiplayerRestartConnect"; - public const string RestartConfigsVariable = "MultiplayerRestartConfigs"; internal static void ProcessEnvironment() { @@ -22,11 +20,7 @@ internal static void ProcessEnvironment() Environment.SetEnvironmentVariable(RestartConnectVariable, ""); // Effectively unsets it } - if (!Environment.GetEnvironmentVariable(RestartConfigsVariable).NullOrEmpty()) - { - Multiplayer.restartConfigs = Environment.GetEnvironmentVariable(RestartConfigsVariable) == "true"; - Environment.SetEnvironmentVariable(RestartConfigsVariable, ""); - } + SyncConfigs.Init(); } internal static void EarlyPatches(Harmony harmony) diff --git a/Source/Client/EarlyPatches/SettingsPatches.cs b/Source/Client/EarlyPatches/SettingsPatches.cs index df96bb91..a5075807 100644 --- a/Source/Client/EarlyPatches/SettingsPatches.cs +++ b/Source/Client/EarlyPatches/SettingsPatches.cs @@ -1,10 +1,7 @@ using System.Collections.Generic; -using System.IO; -using System.Linq; using System.Reflection; using HarmonyLib; using Multiplayer.Client.Patches; -using Multiplayer.Client.Util; using Verse; namespace Multiplayer.Client.EarlyPatches @@ -105,68 +102,4 @@ static IEnumerable TargetMethods() static bool Prefix() => !TickPatch.Simulating; } - - // Affects both reading and writing - [EarlyPatch] - [HarmonyPatch(typeof(LoadedModManager), nameof(LoadedModManager.GetSettingsFilename))] - static class OverrideConfigsPatch - { - private static Dictionary<(string, string), ModContentPack> modCache = new(); - - static void Postfix(string modIdentifier, string modHandleName, ref string __result) - { - if (!Multiplayer.restartConfigs) - return; - - if (!modCache.TryGetValue((modIdentifier, modHandleName), out var mod)) - { - mod = modCache[(modIdentifier, modHandleName)] = - LoadedModManager.RunningModsListForReading.FirstOrDefault(m => - m.FolderName == modIdentifier - && m.assemblies.loadedAssemblies.Any(a => a.GetTypes().Any(t => t.Name == modHandleName)) - ); - } - - if (mod == null) - return; - - if (JoinData.ignoredConfigsModIds.Contains(mod.ModMetaData.PackageIdNonUnique)) - return; - - __result = SyncConfigs.GetConfigPath(mod.PackageIdPlayerFacing.ToLowerInvariant(), modHandleName); - } - } - - [EarlyPatch] - [HarmonyPatch] - static class HugsLib_OverrideConfigsPatch - { - public static string HugsLibConfigOverridenPath; - - private static MethodInfo MethodToPatch = AccessTools.Method("HugsLib.Core.PersistentDataManager:GetSettingsFilePath"); - - static bool Prepare() => MethodToPatch != null; - - static MethodInfo TargetMethod() => MethodToPatch; - - static void Prefix(object __instance) - { - if (!Multiplayer.restartConfigs) - return; - - if (__instance.GetType().Name != "ModSettingsManager") - return; - - var newPath = Path.Combine( - GenFilePaths.FolderUnderSaveData(JoinData.TempConfigsDir), - GenText.SanitizeFilename($"{JoinData.HugsLibId}-{JoinData.HugsLibSettingsFile}") - ); - - if (File.Exists(newPath)) - { - __instance.SetPropertyOrField("OverrideFilePath", newPath); - HugsLibConfigOverridenPath = newPath; - } - } - } } diff --git a/Source/Client/Multiplayer.cs b/Source/Client/Multiplayer.cs index 94ebc317..575526c7 100644 --- a/Source/Client/Multiplayer.cs +++ b/Source/Client/Multiplayer.cs @@ -75,7 +75,6 @@ public static class Multiplayer public static Stopwatch harmonyWatch = new(); public static string restartConnect; - public static bool restartConfigs; public static ModContentPack modContentPack; diff --git a/Source/Client/Networking/JoinData.cs b/Source/Client/Networking/JoinData.cs index 71fca8f2..90abbce2 100644 --- a/Source/Client/Networking/JoinData.cs +++ b/Source/Client/Networking/JoinData.cs @@ -6,7 +6,7 @@ using System.Linq; using HarmonyLib; using Ionic.Zlib; -using Multiplayer.Client.EarlyPatches; +using Multiplayer.Client.Util; using Multiplayer.Common; using RimWorld; using Steamworks; diff --git a/Source/Client/Util/SyncConfigs.cs b/Source/Client/Util/SyncConfigs.cs index a87e7e20..414a4c96 100644 --- a/Source/Client/Util/SyncConfigs.cs +++ b/Source/Client/Util/SyncConfigs.cs @@ -1,5 +1,10 @@ +using System; using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Reflection; +using HarmonyLib; +using Multiplayer.Client.Patches; using Verse; namespace Multiplayer.Client.Util; @@ -8,6 +13,18 @@ namespace Multiplayer.Client.Util; public static class SyncConfigs { private static readonly string TempConfigsPath = GenFilePaths.FolderUnderSaveData(JoinData.TempConfigsDir); + private const string RestartConfigsVariable = "MultiplayerRestartConfigs"; + + public static bool Applicable { private set; get; } + + // The env variable will get inherited by the child process started in GenCommandLine.Restart + public static void MarkApplicableForChildProcess() => Environment.SetEnvironmentVariable(RestartConfigsVariable, "true"); + + public static void Init() + { + Applicable = Environment.GetEnvironmentVariable(RestartConfigsVariable) is "true"; + Environment.SetEnvironmentVariable(RestartConfigsVariable, ""); + } public static void SaveConfigs(List configs) { @@ -22,3 +39,57 @@ public static void SaveConfigs(List configs) public static string GetConfigPath(string modId, string handleName) => Path.Combine(TempConfigsPath, GenText.SanitizeFilename($"Mod_{modId}_{handleName}.xml")); } + +// Affects both reading and writing +[EarlyPatch] +[HarmonyPatch(typeof(LoadedModManager), nameof(LoadedModManager.GetSettingsFilename))] +static class OverrideConfigsPatch +{ + private static Dictionary<(string, string), ModContentPack> modCache = new(); + + static void Postfix(string modIdentifier, string modHandleName, ref string __result) + { + if (!SyncConfigs.Applicable) + return; + + if (!modCache.TryGetValue((modIdentifier, modHandleName), out var mod)) + { + mod = modCache[(modIdentifier, modHandleName)] = + LoadedModManager.RunningModsListForReading.FirstOrDefault(m => + m.FolderName == modIdentifier + && m.assemblies.loadedAssemblies.Any(a => a.GetTypes().Any(t => t.Name == modHandleName)) + ); + } + + if (mod == null) + return; + + if (JoinData.ignoredConfigsModIds.Contains(mod.ModMetaData.PackageIdNonUnique)) + return; + + __result = SyncConfigs.GetConfigPath(mod.PackageIdPlayerFacing.ToLowerInvariant(), modHandleName); + } +} + +[EarlyPatch] +[HarmonyPatch] +static class HugsLib_OverrideConfigsPatch +{ + public static string HugsLibConfigOverridenPath = + SyncConfigs.GetConfigPath(JoinData.HugsLibId, JoinData.HugsLibSettingsFile); + + private static readonly MethodInfo MethodToPatch = + AccessTools.Method("HugsLib.Core.PersistentDataManager:GetSettingsFilePath"); + + static bool Prepare() => MethodToPatch != null; + + static MethodInfo TargetMethod() => MethodToPatch; + + static void Prefix(object __instance) + { + if (!SyncConfigs.Applicable) return; + if (__instance.GetType().Name != "ModSettingsManager") return; + if (!File.Exists(HugsLibConfigOverridenPath)) return; + __instance.SetPropertyOrField("OverrideFilePath", HugsLibConfigOverridenPath); + } +} diff --git a/Source/Client/Windows/JoinDataWindow.cs b/Source/Client/Windows/JoinDataWindow.cs index 56c369d4..ccb05c58 100644 --- a/Source/Client/Windows/JoinDataWindow.cs +++ b/Source/Client/Windows/JoinDataWindow.cs @@ -750,6 +750,7 @@ private void DoRestart() if (applyConfigs) { SyncConfigs.SaveConfigs(data.remoteModConfigs); + SyncConfigs.MarkApplicableForChildProcess(); } var connectTo = data.remoteSteamHost != null @@ -758,7 +759,6 @@ private void DoRestart() // The env variables will get inherited by the child process started in GenCommandLine.Restart Environment.SetEnvironmentVariable(EarlyInit.RestartConnectVariable, connectTo); - Environment.SetEnvironmentVariable(EarlyInit.RestartConfigsVariable, applyConfigs ? "true" : "false"); GenCommandLine.Restart(); } From 2785367ff82eb2e540c6668d002b30a650678261 Mon Sep 17 00:00:00 2001 From: mibac138 <5672750+mibac138@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:20:35 +0100 Subject: [PATCH 04/28] SyncConfigs: include reading local configs functionality Moved this responsibility from JoinData --- Source/Client/Networking/JoinData.cs | 86 +---------------------- Source/Client/Util/SyncConfigs.cs | 93 +++++++++++++++++++++++-- Source/Client/Windows/JoinDataWindow.cs | 2 +- 3 files changed, 90 insertions(+), 91 deletions(-) diff --git a/Source/Client/Networking/JoinData.cs b/Source/Client/Networking/JoinData.cs index 90abbce2..f8a08446 100644 --- a/Source/Client/Networking/JoinData.cs +++ b/Source/Client/Networking/JoinData.cs @@ -1,7 +1,5 @@ -using System; using System.Collections; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using HarmonyLib; @@ -49,7 +47,7 @@ public static byte[] WriteServerData(bool writeConfigs) data.WriteBool(writeConfigs); if (writeConfigs) { - var configs = GetSyncableConfigContents( + var configs = SyncConfigs.GetSyncableConfigContents( activeModsSnapshot.Select(m => m.PackageIdNonUnique).ToList() ); @@ -126,93 +124,13 @@ public static ModMetaData GetInstalledMod(string id) return ModLister.GetModWithIdentifier(id); } - [SuppressMessage("ReSharper", "StringLiteralTypo")] - public static string[] ignoredConfigsModIds = - { - // todo unhardcode it - "rwmt.multiplayer", - "hodlhodl.twitchtoolkit", // contains username - "dubwise.dubsmintmenus", - "dubwise.dubsmintminimap", - "arandomkiwi.rimthemes", - "brrainz.cameraplus", - "giantspacehamster.moody", - "fluffy.modmanager", - "jelly.modswitch", - "betterscenes.rimconnect", // contains secret key for streamer - "jaxe.rimhud", - "telefonmast.graphicssettings", - "derekbickley.ltocolonygroupsfinal", - "dra.multiplayercustomtickrates", // syncs its own settings - "merthsoft.designatorshapes", // settings for UI and stuff meaningless for MP - //"zetrith.prepatcher", - }; - - public const string TempConfigsDir = "MultiplayerTempConfigs"; - public const string HugsLibId = "unlimitedhugs.hugslib"; - public const string HugsLibSettingsFile = "ModSettings"; - - public static List GetSyncableConfigContents(List modIds) - { - var list = new List(); - - foreach (var modId in modIds) - { - if (ignoredConfigsModIds.Contains(modId)) continue; - - var mod = LoadedModManager.RunningModsListForReading.FirstOrDefault(m => m.PackageIdPlayerFacing.ToLowerInvariant() == modId); - if (mod == null) continue; - - foreach (var modInstance in LoadedModManager.runningModClasses.Values) - { - if (modInstance.modSettings == null) continue; - if (!mod.assemblies.loadedAssemblies.Contains(modInstance.GetType().Assembly)) continue; - - var instanceName = modInstance.GetType().Name; - - // This path may point to configs downloaded from the server - var file = LoadedModManager.GetSettingsFilename(mod.FolderName, instanceName); - - if (File.Exists(file)) - list.Add(GetConfigCatchError(file, modId, instanceName)); - } - } - - // Special case for HugsLib - if (modIds.Contains(HugsLibId) && GetInstalledMod(HugsLibId) is { Active: true }) - { - var hugsConfig = - HugsLib_OverrideConfigsPatch.HugsLibConfigOverridenPath ?? - Path.Combine(GenFilePaths.SaveDataFolderPath, "HugsLib", "ModSettings.xml"); - - if (File.Exists(hugsConfig)) - list.Add(GetConfigCatchError(hugsConfig, HugsLibId, HugsLibSettingsFile)); - } - - return list; - - ModConfig GetConfigCatchError(string path, string id, string file) - { - try - { - var configContents = File.ReadAllText(path); - return new ModConfig(id, file, configContents); - } - catch (Exception e) - { - Log.Error($"Exception getting config contents {file}: {e}"); - return new ModConfig(id, "ERROR", ""); - } - } - } - public static bool CompareToLocal(RemoteData remote) { return remote.remoteRwVersion == VersionControl.CurrentVersionString && remote.CompareMods(activeModsSnapshot) == ModListDiff.None && remote.remoteFiles.DictsEqual(modFilesSnapshot) && - (!remote.hasConfigs || remote.remoteModConfigs.EqualAsSets(GetSyncableConfigContents(remote.RemoteModIds.ToList()))); + (!remote.hasConfigs || remote.remoteModConfigs.EqualAsSets(SyncConfigs.GetSyncableConfigContents(remote.RemoteModIds.ToList()))); } internal static void TakeModDataSnapshot() diff --git a/Source/Client/Util/SyncConfigs.cs b/Source/Client/Util/SyncConfigs.cs index 414a4c96..f7bcefc9 100644 --- a/Source/Client/Util/SyncConfigs.cs +++ b/Source/Client/Util/SyncConfigs.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Reflection; @@ -12,7 +13,7 @@ namespace Multiplayer.Client.Util; /// Responsible for saving a server's config files and retrieving them later. public static class SyncConfigs { - private static readonly string TempConfigsPath = GenFilePaths.FolderUnderSaveData(JoinData.TempConfigsDir); + private static readonly string TempConfigsPath = GenFilePaths.FolderUnderSaveData("MultiplayerTempConfigs"); private const string RestartConfigsVariable = "MultiplayerRestartConfigs"; public static bool Applicable { private set; get; } @@ -36,6 +37,85 @@ public static void SaveConfigs(List configs) File.WriteAllText(Path.Combine(TempConfigsPath, $"Mod_{config.ModId}_{config.FileName}.xml"), config.Contents); } + [SuppressMessage("ReSharper", "StringLiteralTypo")] + public static string[] ignoredConfigsModIds = + [ + // todo unhardcode it + "rwmt.multiplayer", + "hodlhodl.twitchtoolkit", // contains username + "dubwise.dubsmintmenus", + "dubwise.dubsmintminimap", + "arandomkiwi.rimthemes", + "brrainz.cameraplus", + "giantspacehamster.moody", + "fluffy.modmanager", + "jelly.modswitch", + "betterscenes.rimconnect", // contains secret key for streamer + "jaxe.rimhud", + "telefonmast.graphicssettings", + "derekbickley.ltocolonygroupsfinal", + "dra.multiplayercustomtickrates", // syncs its own settings + "merthsoft.designatorshapes" // settings for UI and stuff meaningless for MP + //"zetrith.prepatcher", + ]; + + public const string HugsLibId = "unlimitedhugs.hugslib"; + public const string HugsLibSettingsFile = "ModSettings"; + + public static List GetSyncableConfigContents(List modIds) + { + var list = new List(); + + foreach (var modId in modIds) + { + if (ignoredConfigsModIds.Contains(modId)) continue; + + var mod = LoadedModManager.RunningMods.FirstOrDefault(m => + m.PackageIdPlayerFacing.ToLowerInvariant() == modId); + if (mod == null) continue; + + foreach (var modInstance in LoadedModManager.runningModClasses.Values) + { + if (modInstance.modSettings == null) continue; + if (!mod.assemblies.loadedAssemblies.Contains(modInstance.GetType().Assembly)) continue; + + var instanceName = modInstance.GetType().Name; + + // This path may point to configs downloaded from the server + var file = LoadedModManager.GetSettingsFilename(mod.FolderName, instanceName); + + if (File.Exists(file)) + list.Add(GetConfigCatchError(file, modId, instanceName)); + } + } + + // Special case for HugsLib + if (modIds.Contains(HugsLibId) && JoinData.GetInstalledMod(HugsLibId) is { Active: true }) + { + var hugsConfig = HugsLib_OverrideConfigsPatch.HugsLibConfigIsOverriden + ? HugsLib_OverrideConfigsPatch.HugsLibConfigOverridePath + : Path.Combine(GenFilePaths.SaveDataFolderPath, "HugsLib", "ModSettings.xml"); + + if (File.Exists(hugsConfig)) + list.Add(GetConfigCatchError(hugsConfig, HugsLibId, HugsLibSettingsFile)); + } + + return list; + + ModConfig GetConfigCatchError(string path, string id, string file) + { + try + { + return new ModConfig(id, file, Contents: File.ReadAllText(path)); + } + catch (Exception e) + { + Log.Error($"Exception getting config contents {file}: {e}"); + return new ModConfig(id, "ERROR", ""); + } + } + } + public static string GetConfigPath(string modId, string handleName) => Path.Combine(TempConfigsPath, GenText.SanitizeFilename($"Mod_{modId}_{handleName}.xml")); } @@ -64,7 +144,7 @@ static void Postfix(string modIdentifier, string modHandleName, ref string __res if (mod == null) return; - if (JoinData.ignoredConfigsModIds.Contains(mod.ModMetaData.PackageIdNonUnique)) + if (SyncConfigs.ignoredConfigsModIds.Contains(mod.ModMetaData.PackageIdNonUnique)) return; __result = SyncConfigs.GetConfigPath(mod.PackageIdPlayerFacing.ToLowerInvariant(), modHandleName); @@ -75,8 +155,9 @@ static void Postfix(string modIdentifier, string modHandleName, ref string __res [HarmonyPatch] static class HugsLib_OverrideConfigsPatch { - public static string HugsLibConfigOverridenPath = - SyncConfigs.GetConfigPath(JoinData.HugsLibId, JoinData.HugsLibSettingsFile); + public static string HugsLibConfigOverridePath = + SyncConfigs.GetConfigPath(SyncConfigs.HugsLibId, SyncConfigs.HugsLibSettingsFile); + public static bool HugsLibConfigIsOverriden => File.Exists(HugsLibConfigOverridePath); private static readonly MethodInfo MethodToPatch = AccessTools.Method("HugsLib.Core.PersistentDataManager:GetSettingsFilePath"); @@ -89,7 +170,7 @@ static void Prefix(object __instance) { if (!SyncConfigs.Applicable) return; if (__instance.GetType().Name != "ModSettingsManager") return; - if (!File.Exists(HugsLibConfigOverridenPath)) return; - __instance.SetPropertyOrField("OverrideFilePath", HugsLibConfigOverridenPath); + if (!HugsLibConfigIsOverriden) return; + __instance.SetPropertyOrField("OverrideFilePath", HugsLibConfigOverridePath); } } diff --git a/Source/Client/Windows/JoinDataWindow.cs b/Source/Client/Windows/JoinDataWindow.cs index ccb05c58..31a73cc8 100644 --- a/Source/Client/Windows/JoinDataWindow.cs +++ b/Source/Client/Windows/JoinDataWindow.cs @@ -167,7 +167,7 @@ void AddConfigs(List one, List two, NodeStatus notInTwo) } } - var localConfigs = JoinData.GetSyncableConfigContents(remote.RemoteModIds.ToList()); + var localConfigs = SyncConfigs.GetSyncableConfigContents(remote.RemoteModIds.ToList()); if (remote.hasConfigs) { From cd59333f3b30b683c73966d40d7ed84f5de93928 Mon Sep 17 00:00:00 2001 From: mibac138 <5672750+mibac138@users.noreply.github.com> Date: Fri, 19 Dec 2025 22:49:40 +0100 Subject: [PATCH 05/28] SyncConfigs: minor style changes --- Source/Client/Util/SyncConfigs.cs | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/Source/Client/Util/SyncConfigs.cs b/Source/Client/Util/SyncConfigs.cs index f7bcefc9..f03ef9cf 100644 --- a/Source/Client/Util/SyncConfigs.cs +++ b/Source/Client/Util/SyncConfigs.cs @@ -19,7 +19,8 @@ public static class SyncConfigs public static bool Applicable { private set; get; } // The env variable will get inherited by the child process started in GenCommandLine.Restart - public static void MarkApplicableForChildProcess() => Environment.SetEnvironmentVariable(RestartConfigsVariable, "true"); + public static void MarkApplicableForChildProcess() => + Environment.SetEnvironmentVariable(RestartConfigsVariable, "true"); public static void Init() { @@ -66,12 +67,10 @@ public static List GetSyncableConfigContents(List modIds) { var list = new List(); - foreach (var modId in modIds) + foreach (var modId in modIds.Except(ignoredConfigsModIds)) { - if (ignoredConfigsModIds.Contains(modId)) continue; - - var mod = LoadedModManager.RunningMods.FirstOrDefault(m => - m.PackageIdPlayerFacing.ToLowerInvariant() == modId); + var mod = LoadedModManager.RunningMods + .FirstOrDefault(m => m.PackageIdPlayerFacing.EqualsIgnoreCase(modId)); if (mod == null) continue; foreach (var modInstance in LoadedModManager.runningModClasses.Values) @@ -129,9 +128,7 @@ static class OverrideConfigsPatch static void Postfix(string modIdentifier, string modHandleName, ref string __result) { - if (!SyncConfigs.Applicable) - return; - + if (!SyncConfigs.Applicable) return; if (!modCache.TryGetValue((modIdentifier, modHandleName), out var mod)) { mod = modCache[(modIdentifier, modHandleName)] = @@ -141,11 +138,8 @@ static void Postfix(string modIdentifier, string modHandleName, ref string __res ); } - if (mod == null) - return; - - if (SyncConfigs.ignoredConfigsModIds.Contains(mod.ModMetaData.PackageIdNonUnique)) - return; + if (mod == null) return; + if (SyncConfigs.ignoredConfigsModIds.Contains(mod.ModMetaData.PackageIdNonUnique)) return; __result = SyncConfigs.GetConfigPath(mod.PackageIdPlayerFacing.ToLowerInvariant(), modHandleName); } From 239b63acb748ee622ae36e64aa674bae84b840c2 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Thu, 25 Dec 2025 22:35:10 +0300 Subject: [PATCH 06/28] Server: bootstrap mode when save.zip missing --- Source/Server/BootstrapMode.cs | 26 ++++++++++++++++++++++++++ Source/Server/Server.cs | 21 ++++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 Source/Server/BootstrapMode.cs diff --git a/Source/Server/BootstrapMode.cs b/Source/Server/BootstrapMode.cs new file mode 100644 index 00000000..614321d8 --- /dev/null +++ b/Source/Server/BootstrapMode.cs @@ -0,0 +1,26 @@ +using Multiplayer.Common; + +namespace Server; + +/// +/// Helpers for running the server in bootstrap mode (no save loaded yet). +/// +public static class BootstrapMode +{ + /// + /// Keeps the process alive while the server is waiting for a client to provide the initial world data. + /// + /// This is intentionally minimal for now: it just sleeps and checks the stop flag. + /// The networking + actual upload handling happens in the server thread/state machine. + /// + public static void WaitForClient(MultiplayerServer server, CancellationToken token) + { + ServerLog.Log("Bootstrap: waiting for first client connection..."); + + // Keep the process alive. The server's net loop runs on its own thread. + while (server.running && !token.IsCancellationRequested) + { + Thread.Sleep(250); + } + } +} diff --git a/Source/Server/Server.cs b/Source/Server/Server.cs index 9132ed3b..cd600bf9 100644 --- a/Source/Server/Server.cs +++ b/Source/Server/Server.cs @@ -29,9 +29,23 @@ running = true, }; +var bootstrap = false; + var consoleSource = new ConsoleSource(); -LoadSave(server, saveFile); +if (File.Exists(saveFile)) +{ + LoadSave(server, saveFile); +} +else +{ + bootstrap = true; + ServerLog.Log($"Bootstrap mode: '{saveFile}' not found. Server will start without a loaded save."); + ServerLog.Log("Waiting for a client to upload world data."); +} + +if (bootstrap) + ServerLog.Detail("Bootstrap flag is enabled."); if (settings.direct) { var badEndpoint = settings.TryParseEndpoints(out var endpoints); @@ -68,6 +82,11 @@ new Thread(server.Run) { Name = "Server thread" }.Start(); +// In bootstrap mode we keep the server alive and wait for any client to connect. +// The actual world data upload is handled by the normal networking code paths. +if (bootstrap) + BootstrapMode.WaitForClient(server, CancellationToken.None); + while (true) { var cmd = Console.ReadLine(); From 3a57c641249072935255213f62b63f5eb1f56050 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Thu, 25 Dec 2025 23:09:34 +0300 Subject: [PATCH 07/28] Server: add bootstrap mode for save.zip provisioning - Server no longer crashes when save.zip is missing; enters bootstrap mode\n- Allow a single configurator client to connect while server isn't FullyStarted\n- Notify client early that server is in bootstrap\n- Add upload protocol (start/data/finish + sha256) to provision a ready-made save.zip\n- Atomically write save.zip then notify clients, disconnect, and stop server to allow external restart\n\nFiles touched: Server.cs, MultiplayerServer.cs, PlayerManager.cs, NetworkingLiteNet.cs, Packets.cs, state/packet implementations, plus small test updates. --- .../Networking/State/ClientJoiningState.cs | 9 ++ Source/Client/Session/MultiplayerSession.cs | 3 + Source/Common/MultiplayerServer.cs | 9 +- .../Common/Networking/ConnectionStateEnum.cs | 1 + Source/Common/Networking/NetworkingLiteNet.cs | 4 +- .../Networking/Packet/BootstrapPacket.cs | 17 ++ .../Packet/BootstrapUploadPackets.cs | 63 ++++++++ Source/Common/Networking/Packets.cs | 7 + .../Networking/State/ServerBootstrapState.cs | 149 ++++++++++++++++++ .../Networking/State/ServerJoiningState.cs | 6 + Source/Common/PlayerManager.cs | 2 +- Source/Server/Server.cs | 1 + Source/Tests/Helper/TestJoiningState.cs | 4 + 13 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 Source/Common/Networking/Packet/BootstrapPacket.cs create mode 100644 Source/Common/Networking/Packet/BootstrapUploadPackets.cs create mode 100644 Source/Common/Networking/State/ServerBootstrapState.cs diff --git a/Source/Client/Networking/State/ClientJoiningState.cs b/Source/Client/Networking/State/ClientJoiningState.cs index 5bf363ec..1420cdbc 100644 --- a/Source/Client/Networking/State/ClientJoiningState.cs +++ b/Source/Client/Networking/State/ClientJoiningState.cs @@ -17,6 +17,15 @@ public ClientJoiningState(ConnectionBase connection) : base(connection) { } + [TypedPacketHandler] + public void HandleBootstrap(ServerBootstrapPacket packet) + { + // Server informs us early that it's in bootstrap/configuration mode. + // Full UI/flow is handled on the client side; for now we just persist the flag + // so receiving the packet doesn't error during join (tests rely on this). + Multiplayer.session.serverIsInBootstrap = packet.bootstrap; + } + [TypedPacketHandler] public new void HandleDisconnected(ServerDisconnectPacket packet) => base.HandleDisconnected(packet); diff --git a/Source/Client/Session/MultiplayerSession.cs b/Source/Client/Session/MultiplayerSession.cs index 21b1c908..bb4ef466 100644 --- a/Source/Client/Session/MultiplayerSession.cs +++ b/Source/Client/Session/MultiplayerSession.cs @@ -56,6 +56,9 @@ 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 void Stop() { if (client != null) diff --git a/Source/Common/MultiplayerServer.cs b/Source/Common/MultiplayerServer.cs index 2a89035f..441b4a64 100644 --- a/Source/Common/MultiplayerServer.cs +++ b/Source/Common/MultiplayerServer.cs @@ -16,6 +16,7 @@ static MultiplayerServer() { MpConnectionState.SetImplementation(ConnectionStateEnum.ServerSteam, typeof(ServerSteamState)); MpConnectionState.SetImplementation(ConnectionStateEnum.ServerJoining, typeof(ServerJoiningState)); + MpConnectionState.SetImplementation(ConnectionStateEnum.ServerBootstrap, typeof(ServerBootstrapState)); MpConnectionState.SetImplementation(ConnectionStateEnum.ServerLoading, typeof(ServerLoadingState)); MpConnectionState.SetImplementation(ConnectionStateEnum.ServerPlaying, typeof(ServerPlayingState)); } @@ -60,10 +61,16 @@ static MultiplayerServer() public volatile bool running; + /// + /// True when the server is running without an initial save loaded. + /// In this mode the first connected client is expected to configure/upload the world. + /// + public bool BootstrapMode { get; set; } + public bool ArbiterPlaying => PlayingPlayers.Any(p => p.IsArbiter && p.status == PlayerStatus.Playing); public ServerPlayer HostPlayer => PlayingPlayers.First(p => p.IsHost); - public bool FullyStarted => running && worldData.savedGame != null; + public bool FullyStarted => running && worldData.savedGame != null; public const float StandardTimePerTick = 1000.0f / 60.0f; diff --git a/Source/Common/Networking/ConnectionStateEnum.cs b/Source/Common/Networking/ConnectionStateEnum.cs index 8d7c04f1..f3f1521b 100644 --- a/Source/Common/Networking/ConnectionStateEnum.cs +++ b/Source/Common/Networking/ConnectionStateEnum.cs @@ -8,6 +8,7 @@ public enum ConnectionStateEnum : byte ClientSteam, ServerJoining, + ServerBootstrap, ServerLoading, ServerPlaying, ServerSteam, // unused diff --git a/Source/Common/Networking/NetworkingLiteNet.cs b/Source/Common/Networking/NetworkingLiteNet.cs index faed7396..717326f1 100644 --- a/Source/Common/Networking/NetworkingLiteNet.cs +++ b/Source/Common/Networking/NetworkingLiteNet.cs @@ -21,7 +21,9 @@ public void OnConnectionRequest(ConnectionRequest req) public void OnPeerConnected(NetPeer peer) { var conn = new LiteNetConnection(peer); - conn.ChangeState(ConnectionStateEnum.ServerJoining); + conn.ChangeState(server.BootstrapMode + ? ConnectionStateEnum.ServerBootstrap + : ConnectionStateEnum.ServerJoining); peer.SetConnection(conn); var player = server.playerManager.OnConnected(conn); diff --git a/Source/Common/Networking/Packet/BootstrapPacket.cs b/Source/Common/Networking/Packet/BootstrapPacket.cs new file mode 100644 index 00000000..2eacbbd5 --- /dev/null +++ b/Source/Common/Networking/Packet/BootstrapPacket.cs @@ -0,0 +1,17 @@ +namespace Multiplayer.Common.Networking.Packet; + +/// +/// Sent by the server during the initial connection handshake. +/// When enabled, the server is running in "bootstrap" mode (no save loaded yet) +/// and the client should enter the configuration flow instead of normal join. +/// +[PacketDefinition(Packets.Server_Bootstrap)] +public record struct ServerBootstrapPacket(bool bootstrap) : IPacket +{ + public bool bootstrap = bootstrap; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref bootstrap); + } +} diff --git a/Source/Common/Networking/Packet/BootstrapUploadPackets.cs b/Source/Common/Networking/Packet/BootstrapUploadPackets.cs new file mode 100644 index 00000000..d5a4fe91 --- /dev/null +++ b/Source/Common/Networking/Packet/BootstrapUploadPackets.cs @@ -0,0 +1,63 @@ +using System; + +namespace Multiplayer.Common.Networking.Packet; + +/// +/// Upload start metadata for bootstrap configuration. +/// The client will send exactly one file: a pre-built save.zip (server format). +/// +[PacketDefinition(Packets.Client_BootstrapUploadStart)] +public record struct ClientBootstrapUploadStartPacket(string fileName, int length) : IPacket +{ + public string fileName = fileName; + public int length = length; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref fileName); + buf.Bind(ref length); + } +} + +/// +/// Upload raw bytes for the save.zip. +/// This packet is expected to be delivered fragmented due to size. +/// +[PacketDefinition(Packets.Client_BootstrapUploadData, allowFragmented: true)] +public record struct ClientBootstrapUploadDataPacket(byte[] data) : IPacket +{ + public byte[] data = data; + + public void Bind(PacketBuffer buf) + { + buf.BindBytes(ref data, maxLength: -1); + } +} + +/// +/// Notify the server the upload has completed. +/// +[PacketDefinition(Packets.Client_BootstrapUploadFinish)] +public record struct ClientBootstrapUploadFinishPacket(string sha256Hex) : IPacket +{ + public string sha256Hex = sha256Hex; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref sha256Hex); + } +} + +/// +/// Server informs connected clients that bootstrap configuration finished and it will restart. +/// +[PacketDefinition(Packets.Server_BootstrapComplete)] +public record struct ServerBootstrapCompletePacket(string message) : IPacket +{ + public string message = message; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref message); + } +} diff --git a/Source/Common/Networking/Packets.cs b/Source/Common/Networking/Packets.cs index 698d8790..69cdd31d 100644 --- a/Source/Common/Networking/Packets.cs +++ b/Source/Common/Networking/Packets.cs @@ -35,6 +35,13 @@ public enum Packets : byte Client_SetFaction, Client_FrameTime, + // Bootstrap + Client_BootstrapUploadStart, + Client_BootstrapUploadData, + Client_BootstrapUploadFinish, + Server_Bootstrap, + Server_BootstrapComplete, + // Joining Server_ProtocolOk, Server_InitDataRequest, diff --git a/Source/Common/Networking/State/ServerBootstrapState.cs b/Source/Common/Networking/State/ServerBootstrapState.cs new file mode 100644 index 00000000..b9533e39 --- /dev/null +++ b/Source/Common/Networking/State/ServerBootstrapState.cs @@ -0,0 +1,149 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using Multiplayer.Common.Networking.Packet; + +namespace Multiplayer.Common; + +/// +/// Server state used when the server is started in bootstrap mode (no save loaded). +/// It waits for a configuration client to upload a server-formatted save.zip. +/// 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. +/// +public class ServerBootstrapState(ConnectionBase conn) : MpConnectionState(conn) +{ + // Only one configurator at a time. + private static int? configuratorPlayerId; + + private static string? pendingFileName; + private static int pendingLength; + private static byte[]? pendingZipBytes; + + public override void StartState() + { + // If we're not actually in bootstrap mode anymore, fall back. + if (!Server.BootstrapMode) + { + connection.ChangeState(ConnectionStateEnum.ServerJoining); + return; + } + + // If someone already is configuring, keep this connection idle. + if (configuratorPlayerId != null && configuratorPlayerId != Player.id) + { + // Still tell them we're in bootstrap, so clients can show a helpful UI. + connection.Send(new ServerBootstrapPacket(true)); + return; + } + + configuratorPlayerId = Player.id; + connection.Send(new ServerBootstrapPacket(true)); + ServerLog.Log($"Bootstrap: configurator connected (playerId={Player.id}). Waiting for upload..."); + } + + public override void OnDisconnect() + { + if (configuratorPlayerId == Player.id) + { + ServerLog.Log("Bootstrap: configurator disconnected; returning to waiting state."); + ResetUploadState(); + configuratorPlayerId = null; + } + } + + [TypedPacketHandler] + public void HandleUploadStart(ClientBootstrapUploadStartPacket packet) + { + if (!IsConfigurator()) + return; + + if (packet.length <= 0) + throw new PacketReadException("Bootstrap upload has invalid length"); + + pendingFileName = packet.fileName; + pendingLength = packet.length; + pendingZipBytes = null; + + ServerLog.Log($"Bootstrap: upload start '{pendingFileName}' ({pendingLength} bytes)"); + } + + [TypedPacketHandler] + public void HandleUploadData(ClientBootstrapUploadDataPacket packet) + { + if (!IsConfigurator()) + return; + + // Expect the full zip bytes in this packet (delivered fragmented). + pendingZipBytes = packet.data; + ServerLog.Log($"Bootstrap: upload data received ({pendingZipBytes?.Length ?? 0} bytes)"); + } + + [TypedPacketHandler] + public void HandleUploadFinish(ClientBootstrapUploadFinishPacket packet) + { + if (!IsConfigurator()) + return; + + if (pendingZipBytes == null) + throw new PacketReadException("Bootstrap upload finish without data"); + + if (pendingLength > 0 && pendingZipBytes.Length != pendingLength) + ServerLog.Log($"Bootstrap: warning - expected {pendingLength} bytes but got {pendingZipBytes.Length}"); + + var actualHash = ComputeSha256Hex(pendingZipBytes); + if (!string.IsNullOrWhiteSpace(packet.sha256Hex) && + !actualHash.Equals(packet.sha256Hex, StringComparison.OrdinalIgnoreCase)) + { + throw new PacketReadException($"Bootstrap upload hash mismatch. expected={packet.sha256Hex} actual={actualHash}"); + } + + // Persist save.zip + var targetPath = Path.Combine(AppContext.BaseDirectory, "save.zip"); + var tempPath = targetPath + ".tmp"; + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllBytes(tempPath, pendingZipBytes); + if (File.Exists(targetPath)) + File.Delete(targetPath); + File.Move(tempPath, targetPath); + + ServerLog.Log($"Bootstrap: wrote '{targetPath}'. Configuration complete; disconnecting clients and stopping."); + + // Notify and disconnect all clients. + Server.SendToPlaying(new ServerBootstrapCompletePacket("Server configured. Restarting.")); + foreach (var p in Server.playerManager.Players.ToArray()) + p.conn.Close(MpDisconnectReason.ServerClosed); + + // Stop the server loop; an external supervisor should restart. + Server.running = false; + } + + private bool IsConfigurator() => configuratorPlayerId == Player.id; + + private static void ResetUploadState() + { + pendingFileName = null; + pendingLength = 0; + pendingZipBytes = null; + } + + private static string ComputeSha256Hex(byte[] data) + { + using var sha = SHA256.Create(); + var hash = sha.ComputeHash(data); + return ToHexString(hash); + } + + private static string ToHexString(byte[] bytes) + { + const string hex = "0123456789ABCDEF"; + var chars = new char[bytes.Length * 2]; + for (var i = 0; i < bytes.Length; i++) + { + var b = bytes[i]; + chars[i * 2] = hex[b >> 4]; + chars[i * 2 + 1] = hex[b & 0x0F]; + } + return new string(chars); + } +} diff --git a/Source/Common/Networking/State/ServerJoiningState.cs b/Source/Common/Networking/State/ServerJoiningState.cs index aa448594..8c60de2c 100644 --- a/Source/Common/Networking/State/ServerJoiningState.cs +++ b/Source/Common/Networking/State/ServerJoiningState.cs @@ -43,7 +43,13 @@ private void HandleProtocol(ClientProtocolPacket packet) if (packet.protocolVersion != MpVersion.Protocol) Player.Disconnect(MpDisconnectReason.Protocol, ByteWriter.GetBytes(MpVersion.Version, MpVersion.Protocol)); else + { Player.conn.Send(new ServerProtocolOkPacket(Server.settings.hasPassword)); + + // Let the client know early when the server is in bootstrap mode so it can switch + // to server-configuration flow while keeping the connection open. + Player.conn.Send(new ServerBootstrapPacket(Server.BootstrapMode)); + } } private void HandleUsername(ClientUsernamePacket packet) diff --git a/Source/Common/PlayerManager.cs b/Source/Common/PlayerManager.cs index 75e19780..acc55e7e 100644 --- a/Source/Common/PlayerManager.cs +++ b/Source/Common/PlayerManager.cs @@ -30,7 +30,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 is false) return MpDisconnectReason.ServerStarting; if (id is IPAddress addr && IPAddress.IsLoopback(addr)) diff --git a/Source/Server/Server.cs b/Source/Server/Server.cs index cd600bf9..be8b7066 100644 --- a/Source/Server/Server.cs +++ b/Source/Server/Server.cs @@ -43,6 +43,7 @@ ServerLog.Log($"Bootstrap mode: '{saveFile}' not found. Server will start without a loaded save."); ServerLog.Log("Waiting for a client to upload world data."); } + server.BootstrapMode = bootstrap; if (bootstrap) ServerLog.Detail("Bootstrap flag is enabled."); diff --git a/Source/Tests/Helper/TestJoiningState.cs b/Source/Tests/Helper/TestJoiningState.cs index e1f0bb6f..16d15917 100644 --- a/Source/Tests/Helper/TestJoiningState.cs +++ b/Source/Tests/Helper/TestJoiningState.cs @@ -16,6 +16,10 @@ protected override async Task RunState() connection.Send(ClientProtocolPacket.Current()); await TypedPacket(); + // Newer protocol: server can additionally inform us during handshake that it's in bootstrap mode. + // Consume it to keep the test handshake robust across versions. + await TypedPacket(); + connection.Send(new ClientUsernamePacket(connection.username!)); await TypedPacket(); From 2b3711c047d05e23966ff619b0b67db6c07a10b8 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Thu, 25 Dec 2025 23:32:21 +0300 Subject: [PATCH 08/28] Server: shutdown after bootstrap (manual restart) - Bootstrap completion message now explicitly says the server will shut down and must be restarted manually\n- Ensure the standalone server process exits after bootstrap completes (avoid blocking on console loop) --- Source/Common/Networking/State/ServerBootstrapState.cs | 2 +- Source/Server/Server.cs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Source/Common/Networking/State/ServerBootstrapState.cs b/Source/Common/Networking/State/ServerBootstrapState.cs index b9533e39..07e04830 100644 --- a/Source/Common/Networking/State/ServerBootstrapState.cs +++ b/Source/Common/Networking/State/ServerBootstrapState.cs @@ -110,7 +110,7 @@ public void HandleUploadFinish(ClientBootstrapUploadFinishPacket packet) ServerLog.Log($"Bootstrap: wrote '{targetPath}'. Configuration complete; disconnecting clients and stopping."); // Notify and disconnect all clients. - Server.SendToPlaying(new ServerBootstrapCompletePacket("Server configured. Restarting.")); + Server.SendToPlaying(new ServerBootstrapCompletePacket("Server configured. The server will now shut down; please restart it manually to start normally.")); foreach (var p in Server.playerManager.Players.ToArray()) p.conn.Close(MpDisconnectReason.ServerClosed); diff --git a/Source/Server/Server.cs b/Source/Server/Server.cs index be8b7066..a56edf2d 100644 --- a/Source/Server/Server.cs +++ b/Source/Server/Server.cs @@ -88,6 +88,11 @@ if (bootstrap) BootstrapMode.WaitForClient(server, CancellationToken.None); +// If bootstrap mode completed (a client uploaded save.zip) the server thread will have set +// server.running = false. In that case, exit so the user can restart the server normally. +if (bootstrap && !server.running) + return; + while (true) { var cmd = Console.ReadLine(); From 404093664cae3325723fd4062e0efd4ee2b175b7 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Fri, 26 Dec 2025 00:02:22 +0300 Subject: [PATCH 09/28] Bootstrap: allow uploading settings.toml before save.zip - If settings.toml is missing, server enters bootstrap and waits for a configurator client to upload it\n- Add dedicated bootstrap settings upload packets (start/data/finish) with size/hash validation\n- Enforce settings.toml presence before accepting save.zip upload\n- Skip settings upload if settings.toml already exists\n- Avoid generating default settings.toml automatically on server start\n- Accept Server_Bootstrap packet also during ClientJoiningState --- .../Networking/State/ClientJoiningState.cs | 8 ++ .../Packet/BootstrapUploadPackets.cs | 46 ++++++++ Source/Common/Networking/Packets.cs | 3 + .../Networking/State/ServerBootstrapState.cs | 109 +++++++++++++++++- Source/Server/Server.cs | 8 +- 5 files changed, 169 insertions(+), 5 deletions(-) diff --git a/Source/Client/Networking/State/ClientJoiningState.cs b/Source/Client/Networking/State/ClientJoiningState.cs index 1420cdbc..bee508b2 100644 --- a/Source/Client/Networking/State/ClientJoiningState.cs +++ b/Source/Client/Networking/State/ClientJoiningState.cs @@ -26,6 +26,14 @@ public void HandleBootstrap(ServerBootstrapPacket packet) Multiplayer.session.serverIsInBootstrap = packet.bootstrap; } + [TypedPacketHandler] + public void HandleBootstrapFlag(ServerBootstrapPacket packet) + { + // Some codepaths (tests included) can receive the bootstrap flag while still in joining. + // Keep it lenient: store the info and continue the normal join flow. + Multiplayer.session.serverIsInBootstrap = packet.bootstrap; + } + [TypedPacketHandler] public new void HandleDisconnected(ServerDisconnectPacket packet) => base.HandleDisconnected(packet); diff --git a/Source/Common/Networking/Packet/BootstrapUploadPackets.cs b/Source/Common/Networking/Packet/BootstrapUploadPackets.cs index d5a4fe91..418476ef 100644 --- a/Source/Common/Networking/Packet/BootstrapUploadPackets.cs +++ b/Source/Common/Networking/Packet/BootstrapUploadPackets.cs @@ -2,6 +2,52 @@ namespace Multiplayer.Common.Networking.Packet; +/// +/// Upload start metadata for bootstrap settings configuration. +/// The client may send exactly one file: settings.toml. +/// +[PacketDefinition(Packets.Client_BootstrapSettingsUploadStart)] +public record struct ClientBootstrapSettingsUploadStartPacket(string fileName, int length) : IPacket +{ + public string fileName = fileName; + public int length = length; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref fileName); + buf.Bind(ref length); + } +} + +/// +/// Upload raw bytes for settings.toml. +/// This packet can be fragmented. +/// +[PacketDefinition(Packets.Client_BootstrapSettingsUploadData, allowFragmented: true)] +public record struct ClientBootstrapSettingsUploadDataPacket(byte[] data) : IPacket +{ + public byte[] data = data; + + public void Bind(PacketBuffer buf) + { + buf.BindBytes(ref data, maxLength: -1); + } +} + +/// +/// Notify the server the settings.toml upload has completed. +/// +[PacketDefinition(Packets.Client_BootstrapSettingsUploadFinish)] +public record struct ClientBootstrapSettingsUploadFinishPacket(string sha256Hex) : IPacket +{ + public string sha256Hex = sha256Hex; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref sha256Hex); + } +} + /// /// Upload start metadata for bootstrap configuration. /// The client will send exactly one file: a pre-built save.zip (server format). diff --git a/Source/Common/Networking/Packets.cs b/Source/Common/Networking/Packets.cs index 69cdd31d..ba07cd2c 100644 --- a/Source/Common/Networking/Packets.cs +++ b/Source/Common/Networking/Packets.cs @@ -36,6 +36,9 @@ public enum Packets : byte Client_FrameTime, // Bootstrap + Client_BootstrapSettingsUploadStart, + Client_BootstrapSettingsUploadData, + Client_BootstrapSettingsUploadFinish, Client_BootstrapUploadStart, Client_BootstrapUploadData, Client_BootstrapUploadFinish, diff --git a/Source/Common/Networking/State/ServerBootstrapState.cs b/Source/Common/Networking/State/ServerBootstrapState.cs index 07e04830..77c331f1 100644 --- a/Source/Common/Networking/State/ServerBootstrapState.cs +++ b/Source/Common/Networking/State/ServerBootstrapState.cs @@ -16,6 +16,14 @@ public class ServerBootstrapState(ConnectionBase conn) : MpConnectionState(conn) // Only one configurator at a time. private static int? configuratorPlayerId; + private const int MaxSettingsTomlBytes = 64 * 1024; + + // Settings upload (settings.toml) + private static string? pendingSettingsFileName; + private static int pendingSettingsLength; + private static byte[]? pendingSettingsBytes; + + // Save upload (save.zip) private static string? pendingFileName; private static int pendingLength; private static byte[]? pendingZipBytes; @@ -39,7 +47,16 @@ public override void StartState() configuratorPlayerId = Player.id; connection.Send(new ServerBootstrapPacket(true)); - ServerLog.Log($"Bootstrap: configurator connected (playerId={Player.id}). Waiting for upload..."); + + var settingsPath = Path.Combine(AppContext.BaseDirectory, "settings.toml"); + var savePath = Path.Combine(AppContext.BaseDirectory, "save.zip"); + + if (!File.Exists(settingsPath)) + ServerLog.Log($"Bootstrap: configurator connected (playerId={Player.id}). Waiting for 'settings.toml' upload..."); + else if (!File.Exists(savePath)) + ServerLog.Log($"Bootstrap: configurator connected (playerId={Player.id}). settings.toml already present; waiting for 'save.zip' upload..."); + else + ServerLog.Log($"Bootstrap: configurator connected (playerId={Player.id}). All files already present; waiting for shutdown."); } public override void OnDisconnect() @@ -52,12 +69,94 @@ public override void OnDisconnect() } } + [TypedPacketHandler] + public void HandleSettingsUploadStart(ClientBootstrapSettingsUploadStartPacket packet) + { + if (!IsConfigurator()) + return; + + var settingsPath = Path.Combine(AppContext.BaseDirectory, "settings.toml"); + if (File.Exists(settingsPath)) + { + ServerLog.Log("Bootstrap: settings.toml already exists; ignoring settings upload start."); + return; + } + + if (packet.length <= 0 || packet.length > MaxSettingsTomlBytes) + throw new PacketReadException($"Bootstrap settings upload has invalid length ({packet.length})"); + + pendingSettingsFileName = packet.fileName; + pendingSettingsLength = packet.length; + pendingSettingsBytes = null; + + ServerLog.Log($"Bootstrap: settings upload start '{pendingSettingsFileName}' ({pendingSettingsLength} bytes)"); + } + + [TypedPacketHandler] + public void HandleSettingsUploadData(ClientBootstrapSettingsUploadDataPacket packet) + { + if (!IsConfigurator()) + return; + + var settingsPath = Path.Combine(AppContext.BaseDirectory, "settings.toml"); + if (File.Exists(settingsPath)) + return; + + pendingSettingsBytes = packet.data; + ServerLog.Log($"Bootstrap: settings upload data received ({pendingSettingsBytes?.Length ?? 0} bytes)"); + } + + [TypedPacketHandler] + public void HandleSettingsUploadFinish(ClientBootstrapSettingsUploadFinishPacket packet) + { + if (!IsConfigurator()) + return; + + var settingsPath = Path.Combine(AppContext.BaseDirectory, "settings.toml"); + if (File.Exists(settingsPath)) + { + ServerLog.Log("Bootstrap: settings.toml already exists; ignoring settings upload finish."); + return; + } + + if (pendingSettingsBytes == null) + throw new PacketReadException("Bootstrap settings upload finish without data"); + + if (pendingSettingsLength > 0 && pendingSettingsBytes.Length != pendingSettingsLength) + ServerLog.Log($"Bootstrap: warning - expected {pendingSettingsLength} settings bytes but got {pendingSettingsBytes.Length}"); + + var actualHash = ComputeSha256Hex(pendingSettingsBytes); + if (!string.IsNullOrWhiteSpace(packet.sha256Hex) && + !actualHash.Equals(packet.sha256Hex, StringComparison.OrdinalIgnoreCase)) + { + throw new PacketReadException($"Bootstrap settings upload hash mismatch. expected={packet.sha256Hex} actual={actualHash}"); + } + + // Persist settings.toml + var tempPath = settingsPath + ".tmp"; + Directory.CreateDirectory(Path.GetDirectoryName(settingsPath)!); + File.WriteAllBytes(tempPath, pendingSettingsBytes); + if (File.Exists(settingsPath)) + File.Delete(settingsPath); + File.Move(tempPath, settingsPath); + + ServerLog.Log($"Bootstrap: wrote '{settingsPath}'. Waiting for save.zip upload..."); + + pendingSettingsFileName = null; + pendingSettingsLength = 0; + pendingSettingsBytes = null; + } + [TypedPacketHandler] public void HandleUploadStart(ClientBootstrapUploadStartPacket packet) { if (!IsConfigurator()) return; + var settingsPath = Path.Combine(AppContext.BaseDirectory, "settings.toml"); + if (!File.Exists(settingsPath)) + throw new PacketReadException("Bootstrap requires settings.toml before save.zip"); + if (packet.length <= 0) throw new PacketReadException("Bootstrap upload has invalid length"); @@ -85,6 +184,10 @@ public void HandleUploadFinish(ClientBootstrapUploadFinishPacket packet) if (!IsConfigurator()) return; + var settingsPath = Path.Combine(AppContext.BaseDirectory, "settings.toml"); + if (!File.Exists(settingsPath)) + throw new PacketReadException("Bootstrap requires settings.toml before save.zip"); + if (pendingZipBytes == null) throw new PacketReadException("Bootstrap upload finish without data"); @@ -122,6 +225,10 @@ public void HandleUploadFinish(ClientBootstrapUploadFinishPacket packet) private static void ResetUploadState() { + pendingSettingsFileName = null; + pendingSettingsLength = 0; + pendingSettingsBytes = null; + pendingFileName = null; pendingLength = 0; pendingZipBytes = null; diff --git a/Source/Server/Server.cs b/Source/Server/Server.cs index a56edf2d..38fb7c7b 100644 --- a/Source/Server/Server.cs +++ b/Source/Server/Server.cs @@ -19,7 +19,7 @@ if (File.Exists(settingsFile)) settings = TomlSettings.Load(settingsFile); else - TomlSettings.Save(settings, settingsFile); // Save default settings + ServerLog.Log($"Bootstrap mode: '{settingsFile}' not found. Waiting for a client to upload it."); if (settings.steam) ServerLog.Error("Steam is not supported in standalone server."); if (settings.arbiter) ServerLog.Error("Arbiter is not supported in standalone server."); @@ -29,11 +29,11 @@ running = true, }; -var bootstrap = false; +var bootstrap = !File.Exists(settingsFile); var consoleSource = new ConsoleSource(); -if (File.Exists(saveFile)) +if (!bootstrap && File.Exists(saveFile)) { LoadSave(server, saveFile); } @@ -43,7 +43,7 @@ ServerLog.Log($"Bootstrap mode: '{saveFile}' not found. Server will start without a loaded save."); ServerLog.Log("Waiting for a client to upload world data."); } - server.BootstrapMode = bootstrap; +server.BootstrapMode = bootstrap; if (bootstrap) ServerLog.Detail("Bootstrap flag is enabled."); From 6c8c52161ca2d61a4028f65b96cbb2ced976510f Mon Sep 17 00:00:00 2001 From: MhaWay Date: Mon, 29 Dec 2025 17:42:40 +0300 Subject: [PATCH 10/28] Client(session): remove obsolete vanilla save conversion from Autosaving.cs --- Source/Client/Session/Autosaving.cs | 156 +++++++++++++++++++++++++++- 1 file changed, 155 insertions(+), 1 deletion(-) diff --git a/Source/Client/Session/Autosaving.cs b/Source/Client/Session/Autosaving.cs index 35f8dc22..b5df9319 100644 --- a/Source/Client/Session/Autosaving.cs +++ b/Source/Client/Session/Autosaving.cs @@ -1,7 +1,9 @@ using System; using System.IO; +using System.IO.Compression; using System.Linq; using Multiplayer.Common; +using Multiplayer.Common.Util; using RimWorld; using UnityEngine; using Verse; @@ -39,6 +41,9 @@ public static void SaveGameToFile_Overwrite(string fileNameNoExtension, bool cur try { + // 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 ? @@ -51,7 +56,9 @@ public static void SaveGameToFile_Overwrite(string fileNameNoExtension, bool cur 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) { @@ -59,4 +66,151 @@ public static void SaveGameToFile_Overwrite(string fileNameNoExtension, bool cur Messages.Message("MpGameSaveFailed".Translate(), MessageTypeDefOf.SilentInput, false); } } + + /// + /// Debug helper: try multiple save strategies to compare outputs. + /// Generates three zips: {baseName}-snap.zip, {baseName}-snap_nocurmap.zip, {baseName}-manual.zip + /// + public static void SaveVanillaGameDebugVariants(string baseName) + { + try + { + Log.Message($"Bootstrap-debug: starting multi-variant save for {baseName}"); + + // Variant 1: normal snapshot + try + { + // Use the standard MP save pipeline for the snap variant + SaveGameToFile_Overwrite(baseName + "-snap", currentReplay: false); + } + catch (Exception e) + { + Log.Error($"Bootstrap-debug: snap variant failed: {e}"); + } + + // Variant 2: snapshot with currentMapIndex removed + try + { + Directory.CreateDirectory(Multiplayer.ReplaysDir); + + var tmpData = SaveLoad.SaveGameData(); + var snapshot = SaveLoad.CreateGameDataSnapshot(tmpData, removeCurrentMapId: true); + WriteSnapshotToReplay(snapshot, baseName + "-snap_nocurmap"); + } + catch (Exception e) + { + Log.Error($"Bootstrap-debug: snap_nocurmap variant failed: {e}"); + } + + // Variant 3: manual extraction only + try + { + Directory.CreateDirectory(Multiplayer.ReplaysDir); + + var gameDoc = SaveLoad.SaveGameToDoc(); + var tempData = new TempGameData(gameDoc, Array.Empty()); + var snapshot = ExtractSnapshotManually(tempData); + WriteSnapshotToReplay(snapshot, baseName + "-manual"); + } + catch (Exception e) + { + Log.Error($"Bootstrap-debug: manual variant failed: {e}"); + } + } + catch (Exception e) + { + Log.Error($"Bootstrap-debug: fatal error during multi-variant save: {e}"); + } + } + + private static GameDataSnapshot ExtractSnapshotManually(TempGameData tempData) + { + var root = tempData.SaveData.DocumentElement; + var gameNode = root != null ? root["game"] : null; + var mapsNode = gameNode != null ? gameNode["maps"] : null; + + Log.Message($"Bootstrap-manual: document has gameNode={gameNode != null}, mapsNode={mapsNode != null}, mapsNode.ChildNodes.Count={mapsNode?.ChildNodes.Count ?? -1}"); + + var mapDataDict = new System.Collections.Generic.Dictionary(); + var mapCmdsDict = new System.Collections.Generic.Dictionary>(); + + if (mapsNode != null) + { + foreach (System.Xml.XmlNode mapNode in mapsNode) + { + var idNode = mapNode?["uniqueID"]; + if (idNode == null) + { + Log.Warning($"Bootstrap-manual: skipping map node without uniqueID"); + continue; + } + int id = int.Parse(idNode.InnerText); + mapDataDict[id] = ScribeUtil.XmlToByteArray(mapNode); + mapCmdsDict[id] = new System.Collections.Generic.List(); + Log.Message($"Bootstrap-manual: extracted map {id} ({mapDataDict[id].Length} bytes)"); + } + mapsNode.RemoveAll(); + } + + mapCmdsDict[ScheduledCommand.Global] = new System.Collections.Generic.List(); + + var gameBytes = ScribeUtil.XmlToByteArray(tempData.SaveData); + Log.Message($"Bootstrap-manual: manual snapshot extracted: maps={mapDataDict.Count}, world bytes={gameBytes.Length}"); + return new GameDataSnapshot(TickPatch.Timer, gameBytes, tempData.SessionData, mapDataDict, mapCmdsDict); + } + + private static void WriteSnapshotToReplay(GameDataSnapshot snapshot, string fileNameNoExtension) + { + var zipPath = Path.Combine(Multiplayer.ReplaysDir, fileNameNoExtension + ".zip"); + var tmpZipPath = Path.Combine(Multiplayer.ReplaysDir, fileNameNoExtension + ".tmp.zip"); + + if (File.Exists(tmpZipPath)) + { + try { File.Delete(tmpZipPath); } catch (Exception delEx) { Log.Warning($"Bootstrap-debug: couldn't delete existing tmp zip {tmpZipPath}: {delEx}"); } + } + + using (var replayZip = MpZipFile.Open(tmpZipPath, ZipArchiveMode.Create)) + { + const string sectionIdStr = "000"; + + replayZip.AddEntry($"world/{sectionIdStr}_save", snapshot.GameData); + + if (!snapshot.MapCmds.TryGetValue(ScheduledCommand.Global, out var worldCmds)) + worldCmds = new System.Collections.Generic.List(); + replayZip.AddEntry($"world/{sectionIdStr}_cmds", ScheduledCommand.SerializeCmds(worldCmds)); + + int writtenMaps = 0; + foreach (var kv in snapshot.MapData) + { + int mapId = kv.Key; + byte[] mapData = kv.Value; + replayZip.AddEntry($"maps/{sectionIdStr}_{mapId}_save", mapData); + + if (!snapshot.MapCmds.TryGetValue(mapId, out var cmds)) + cmds = new System.Collections.Generic.List(); + replayZip.AddEntry($"maps/{sectionIdStr}_{mapId}_cmds", ScheduledCommand.SerializeCmds(cmds)); + writtenMaps++; + } + + replayZip.AddEntry("info", ReplayInfo.Write(new ReplayInfo + { + name = fileNameNoExtension, + playerFaction = -1, + spectatorFaction = -1, + protocol = MpVersion.Protocol, + rwVersion = VersionControl.CurrentVersionStringWithRev, + modIds = LoadedModManager.RunningModsListForReading.Select(m => m.PackageId).ToList(), + modNames = LoadedModManager.RunningModsListForReading.Select(m => m.Name).ToList(), + asyncTime = false, + multifaction = false, + sections = new() { new ReplaySection(0, TickPatch.Timer) } + })); + + Log.Message($"Bootstrap-debug: wrote replay {fileNameNoExtension}: maps={writtenMaps}, world bytes={snapshot.GameData?.Length ?? -1}"); + } + + var dstZip = new FileInfo(zipPath); + if (dstZip.Exists) dstZip.Delete(); + File.Move(tmpZipPath, zipPath); + } } From 93f4155c3df0d565449ef62b290a89c5ab89a229 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Mon, 29 Dec 2025 17:42:51 +0300 Subject: [PATCH 11/28] Client(saving): remove obsolete vanilla save conversion helpers from SaveLoad.cs --- Source/Client/Saving/SaveLoad.cs | 81 +++++++++++++++++++++++++------- 1 file changed, 65 insertions(+), 16 deletions(-) diff --git a/Source/Client/Saving/SaveLoad.cs b/Source/Client/Saving/SaveLoad.cs index 37f5ab66..f327be88 100644 --- a/Source/Client/Saving/SaveLoad.cs +++ b/Source/Client/Saving/SaveLoad.cs @@ -1,8 +1,12 @@ using Ionic.Zlib; using Multiplayer.Common; +using Multiplayer.Common.Util; using RimWorld; using RimWorld.Planet; +using System; using System.Collections.Generic; +using System.IO; +using System.IO.Compression; using System.Linq; using System.Threading; using System.Xml; @@ -136,15 +140,25 @@ private static void ClearState() sustainer.Cleanup(); // todo destroy other game objects? - Object.Destroy(pool.sourcePoolCamera.cameraSourcesContainer); - Object.Destroy(pool.sourcePoolWorld.sourcesWorld[0].gameObject); + UnityEngine.Object.Destroy(pool.sourcePoolCamera.cameraSourcesContainer); + UnityEngine.Object.Destroy(pool.sourcePoolWorld.sourcesWorld[0].gameObject); } } public static TempGameData SaveGameData() { var gameDoc = SaveGameToDoc(); - var sessionData = SessionData.WriteSessionData(); + + byte[] sessionData; + try + { + sessionData = SessionData.WriteSessionData(); + } + catch (Exception e) + { + Log.Error($"Bootstrap: Exception writing session data, session will be empty: {e}"); + sessionData = Array.Empty(); + } return new TempGameData(gameDoc, sessionData); } @@ -165,6 +179,7 @@ public static XmlDocument SaveGameToDoc() World world = Current.Game.World; Scribe_Deep.Look(ref world, "world"); List maps = Find.Maps; + Log.Message($"Bootstrap: SaveGameToDoc is serializing {maps?.Count ?? 0} maps"); Scribe_Collections.Look(ref maps, "maps", LookMode.Deep); Find.CameraDriver.Expose(); Scribe.ExitNode(); @@ -179,27 +194,61 @@ public static XmlDocument SaveGameToDoc() public static GameDataSnapshot CreateGameDataSnapshot(TempGameData data, bool removeCurrentMapId) { - XmlNode gameNode = data.SaveData.DocumentElement["game"]; - XmlNode mapsNode = gameNode["maps"]; + // Be defensive: XML may be missing nodes in bootstrap or due to mod patches + XmlElement root = data.SaveData.DocumentElement; + XmlNode gameNode = root != null ? root["game"] : null; + XmlNode mapsNode = gameNode != null ? gameNode["maps"] : null; + + Log.Message($"Bootstrap: CreateGameDataSnapshot XML structure - root={root?.Name}, game={gameNode?.Name}, maps={mapsNode?.Name}"); + Log.Message($"Bootstrap: CreateGameDataSnapshot mapsNode children={mapsNode?.ChildNodes?.Count ?? 0}, Find.Maps.Count={Find.Maps?.Count ?? -1}"); var mapCmdsDict = new Dictionary>(); var mapDataDict = new Dictionary(); - foreach (XmlNode mapNode in mapsNode) + if (mapsNode != null) { - int id = int.Parse(mapNode["uniqueID"].InnerText); - byte[] mapData = ScribeUtil.XmlToByteArray(mapNode); - mapDataDict[id] = mapData; - mapCmdsDict[id] = new List(Find.Maps.First(m => m.uniqueID == id).AsyncTime().cmds); + foreach (XmlNode mapNode in mapsNode) + { + // Skip malformed map nodes + var idNode = mapNode?["uniqueID"]; + if (idNode == null) continue; + + int id = int.Parse(idNode.InnerText); + byte[] mapData = ScribeUtil.XmlToByteArray(mapNode); + mapDataDict[id] = mapData; + + Log.Message($"Bootstrap: CreateGameDataSnapshot extracted map {id} ({mapData.Length} bytes)"); + + // Offline bootstrap can run without async-time comps; guard nulls and write empty cmd lists + var map = Find.Maps.FirstOrDefault(m => m.uniqueID == id); + var mapAsync = map?.AsyncTime(); + if (mapAsync?.cmds != null) + mapCmdsDict[id] = new List(mapAsync.cmds); + else + mapCmdsDict[id] = new List(); + } } if (removeCurrentMapId) - gameNode["currentMapIndex"].RemoveFromParent(); + { + var currentMapIndexNode = gameNode?["currentMapIndex"]; + if (currentMapIndexNode != null) + currentMapIndexNode.RemoveFromParent(); + } - mapsNode.RemoveAll(); + // Remove map nodes from the game XML to form world-only data + mapsNode?.RemoveAll(); byte[] gameData = ScribeUtil.XmlToByteArray(data.SaveData); - mapCmdsDict[ScheduledCommand.Global] = new List(Multiplayer.AsyncWorldTime.cmds); + // World/global commands may be unavailable offline; default to empty list + if (Multiplayer.AsyncWorldTime != null) + mapCmdsDict[ScheduledCommand.Global] = new List(Multiplayer.AsyncWorldTime.cmds); + else + mapCmdsDict[ScheduledCommand.Global] = new List(); + + // Note: We no longer fall back to vanilla .rws extraction here. + // The bootstrap flow now hosts a temporary MP session and uses the standard MP save, + // which reliably produces proper replay snapshots including maps. return new GameDataSnapshot( TickPatch.Timer, @@ -224,11 +273,11 @@ void Send() foreach (var mapData in mapsData) { writer.WriteInt32(mapData.Key); - writer.WritePrefixedBytes(GZipStream.CompressBuffer(mapData.Value)); + writer.WritePrefixedBytes(Ionic.Zlib.GZipStream.CompressBuffer(mapData.Value)); } - writer.WritePrefixedBytes(GZipStream.CompressBuffer(gameData)); - writer.WritePrefixedBytes(GZipStream.CompressBuffer(sessionData)); + writer.WritePrefixedBytes(Ionic.Zlib.GZipStream.CompressBuffer(gameData)); + writer.WritePrefixedBytes(Ionic.Zlib.GZipStream.CompressBuffer(sessionData)); byte[] data = writer.ToArray(); From 169b34899c3f0dd90dfdaa91c9ae569712fc3e76 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Mon, 29 Dec 2025 17:52:11 +0300 Subject: [PATCH 12/28] Client(network): add ClientBootstrapState (bootstrap join flow) --- .../Networking/State/ClientBootstrapState.cs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 Source/Client/Networking/State/ClientBootstrapState.cs diff --git a/Source/Client/Networking/State/ClientBootstrapState.cs b/Source/Client/Networking/State/ClientBootstrapState.cs new file mode 100644 index 00000000..01a1b000 --- /dev/null +++ b/Source/Client/Networking/State/ClientBootstrapState.cs @@ -0,0 +1,30 @@ +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; + +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(inheritHandlers: true)] +public class ClientBootstrapState(ConnectionBase connection) : ClientBaseState(connection) +{ + [TypedPacketHandler] + public void HandleBootstrapComplete(ServerBootstrapCompletePacket packet) + { + // The server will close shortly after sending this. Surface the message as an in-game notification. + // (BootstrapConfiguratorWindow already tells the user what to do next.) + if (!string.IsNullOrWhiteSpace(packet.message)) + OnMainThread.Enqueue(() => Verse.Messages.Message(packet.message, RimWorld.MessageTypeDefOf.PositiveEvent, false)); + + // Close the bootstrap configurator window now that the process is complete + OnMainThread.Enqueue(() => + { + var window = Verse.Find.WindowStack.WindowOfType(); + if (window != null) + Verse.Find.WindowStack.TryRemove(window); + }); + } +} From 363fb7da4889848e39f7f82eb32671c41c2782f5 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Mon, 29 Dec 2025 17:52:21 +0300 Subject: [PATCH 13/28] Client(windows): add BootstrapConfiguratorWindow (bootstrap UI) --- .../Windows/BootstrapConfiguratorWindow.cs | 1682 +++++++++++++++++ 1 file changed, 1682 insertions(+) create mode 100644 Source/Client/Windows/BootstrapConfiguratorWindow.cs diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs new file mode 100644 index 00000000..91eefeb9 --- /dev/null +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -0,0 +1,1682 @@ +using System; +using System.IO; +using System.Text; +using System.Security.Cryptography; +using Multiplayer.Client.Comp; +using Multiplayer.Client.Networking; +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; +using Multiplayer.Common.Util; +using RimWorld; +using RimWorld.Planet; +using UnityEngine; +using Verse; + +namespace Multiplayer.Client +{ + /// + /// Shown when connecting to a server that's in bootstrap/configuration mode. + /// This window will guide the user through uploading settings.toml (if needed) + /// and then save.zip. + /// + /// NOTE: This is currently a minimal placeholder to wire the new join-flow. + /// + public class BootstrapConfiguratorWindow : Window + { + private readonly ConnectionBase connection; + private string serverAddress; + private int serverPort; + private bool isReconnecting; + private int reconnectCheckTimer; + private ConnectionBase reconnectingConn; + + private ServerSettings settings; + + private enum Step + { + Settings, + GenerateMap + } + + private Step step; + + private Vector2 scroll; + + // numeric buffers + private string maxPlayersBuffer; + private string autosaveIntervalBuffer; + + // 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; + private float nextPressCooldown; + private float randomTileCooldown; + private const float NextPressCooldownSeconds = 0.45f; + private const float RandomTileCooldownSeconds = 0.9f; + private const float AutoAdvanceTimeoutSeconds = 180f; + private float autoAdvanceElapsed; + private bool worldGenDetected; + private float worldGenDelayRemaining; + private const float WorldGenDelaySeconds = 1f; + + // Diagnostics for the vanilla page driver. Throttled to avoid spamming logs. + // Kept always-on because bootstrap issues are commonly hit by non-DevMode users. + private float autoAdvanceDiagCooldown; + private const float AutoAdvanceDiagCooldownSeconds = 2.0f; + + // Comprehensive tracing (opt-in via Prefs.DevMode OR always-on during bootstrap if needed). + // We keep it enabled during bootstrap because it's a one-off flow and helps diagnose stuck states. + private bool bootstrapTraceEnabled = false; + private float bootstrapTraceSnapshotCooldown; + private const float BootstrapTraceSnapshotSeconds = 2.5f; + private string lastTraceKey; + private string lastPageName; + + // 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, also wait until at least one controllable colonist pawn exists. + // This is a more reliable "we're really in the map" signal than FinalizeInit alone, + // especially with heavy modlists/long spawns. + private bool awaitingControllablePawns; + private float awaitingControllablePawnsElapsed; + private const float AwaitControllablePawnsTimeoutSeconds = 30f; + private bool startingLettersCleared; + private bool landingDialogsCleared; + + // Static flag to track bootstrap map initialization + public static bool AwaitingBootstrapMapInit = false; + public static BootstrapConfiguratorWindow Instance; + + private const float LabelWidth = 210f; + private const float RowHeight = 28f; + private const float GapY = 6f; + + public override Vector2 InitialSize => new(700f, 520f); + + 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; + + // Defaults aimed at standalone/headless: + settings = new ServerSettings + { + direct = true, + lan = false, + steam = false, + arbiter = false + }; + + // Choose the initial step based on what the server told us. + // If we don't have an explicit "settings missing" signal, assume settings are already configured + // and proceed to map generation. + 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."; + + if (Prefs.DevMode) + { + Log.Message($"[Bootstrap UI] Window created - step={step}, serverBootstrapSettingsMissing={Multiplayer.session?.serverBootstrapSettingsMissing}"); + Log.Message($"[Bootstrap UI] Initial status: {statusText}"); + } + + Trace("WindowCreated"); + + // Check if we have a previously saved Bootstrap.zip from this session (reconnect case) + if (!autoUploadAttempted && lastSaveReady && !string.IsNullOrEmpty(lastSavedReplayPath) && File.Exists(lastSavedReplayPath)) + { + Log.Message($"[Bootstrap] Found previous Bootstrap.zip at {lastSavedReplayPath}, auto-uploading..."); + savedReplayPath = lastSavedReplayPath; + saveReady = true; + saveUploadStatus = "Save ready from previous session. Uploading..."; + saveUploadAutoStarted = true; + autoUploadAttempted = true; + StartUploadSaveZip(); + } + + RebuildTomlPreview(); + } + + public override void DoWindowContents(Rect inRect) + { + var headerRect = inRect.TopPartPixels(120f); + Rect bodyRect; + Rect buttonsRect = default; + + if (step == Step.Settings) + { + buttonsRect = inRect.BottomPartPixels(40f); + bodyRect = new Rect(inRect.x, headerRect.yMax + 6f, inRect.width, inRect.height - headerRect.height - buttonsRect.height - 12f); + } + else + { + bodyRect = new Rect(inRect.x, headerRect.yMax + 6f, inRect.width, inRect.height - headerRect.height - 6f); + } + + Text.Font = GameFont.Medium; + Widgets.Label(headerRect.TopPartPixels(32f), "Server bootstrap configuration"); + Text.Font = GameFont.Small; + + var infoRect = headerRect.BottomPartPixels(80f); + var info = + "The server is running in bootstrap mode (no settings.toml and/or save.zip).\n" + + "Fill out the settings below to generate a complete settings.toml.\n" + + "After applying settings, you'll upload save.zip in the next step."; + Widgets.Label(infoRect, info); + + Rect leftRect; + Rect rightRect; + + if (step == Step.Settings) + { + leftRect = bodyRect.LeftPart(0.58f).ContractedBy(4f); + rightRect = bodyRect.RightPart(0.42f).ContractedBy(4f); + + DrawSettings(leftRect); + DrawTomlPreview(rightRect); + DrawSettingsButtons(buttonsRect); + } + else + { + // Single-column layout for map generation; remove the right-side steps box + leftRect = bodyRect.ContractedBy(4f); + rightRect = Rect.zero; + DrawGenerateMap(leftRect, rightRect); + } + } + + private void DrawGenerateMap(Rect leftRect, Rect rightRect) + { + Widgets.DrawMenuSection(leftRect); + + var left = leftRect.ContractedBy(10f); + Text.Font = GameFont.Medium; + Widgets.Label(left.TopPartPixels(32f), "Server settings configured"); + Text.Font = GameFont.Small; + + // Important notice about faction ownership + var noticeRect = new Rect(left.x, left.y + 40f, left.width, 80f); + GUI.color = new Color(1f, 0.85f, 0.5f); // Warning yellow + 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; + + Widgets.Label(new Rect(left.x, noticeRect.yMax + 10f, left.width, 110f), + "Click 'Generate map' to automatically create a world and settlement.\n" + + "The process will:\n" + + "1) Start vanilla world generation (you'll see the scenario/world pages)\n" + + "2) After you complete world setup, automatically select a suitable tile\n" + + "3) Generate a colony map and host a temporary multiplayer session\n" + + "4) Save the game as a replay and upload save.zip to the server"); + + // Hide the 'Generate map' button once the vanilla generation flow has started + var btn = new Rect(left.x, noticeRect.yMax + 130f, 200f, 40f); + bool showGenerateButton = !(autoAdvanceArmed || AwaitingBootstrapMapInit || saveReady || isUploadingSave || isReconnecting); + if (showGenerateButton && Widgets.ButtonText(btn, "Generate map")) + { + saveUploadAutoStarted = false; + StartVanillaNewColonyFlow(); + } + + var saveStatusY = (showGenerateButton ? btn.yMax : btn.y) + 10f; + var statusRect = new Rect(left.x, saveStatusY, left.width, 60f); + Widgets.Label(statusRect, saveUploadStatus ?? statusText ?? ""); + + if (autoAdvanceArmed) + { + var barRect = new Rect(left.x, statusRect.yMax + 4f, left.width, 18f); + Widgets.FillableBar(barRect, 0.1f); + } + + if (isUploadingSave) + { + var barRect = new Rect(left.x, statusRect.yMax + 4f, left.width, 18f); + Widgets.FillableBar(barRect, saveUploadProgress); + } + + // Auto-start upload when save is ready + if (saveReady && !isUploadingSave && !saveUploadAutoStarted) + { + saveUploadAutoStarted = true; + ReconnectAndUploadSave(); + } + + // Right-side steps box removed per request + } + + private void DrawSettings(Rect inRect) + { + Widgets.DrawMenuSection(inRect); + var inner = inRect.ContractedBy(10f); + + // Status + progress + var statusRect = new Rect(inner.x, inner.y, inner.width, 54f); + Widgets.Label(statusRect.TopPartPixels(28f), statusText ?? ""); + if (isUploadingToml) + { + var barRect = statusRect.BottomPartPixels(20f); + Widgets.FillableBar(barRect, uploadProgress); + } + + var contentRect = new Rect(inner.x, inner.y + 60f, inner.width, inner.height - 60f); + + // Keep the layout stable with a scroll view. + var viewRect = new Rect(0f, 0f, contentRect.width - 16f, 760f); + + Widgets.BeginScrollView(contentRect, ref scroll, viewRect); + + float y = 0f; + void Gap() => y += GapY; + Rect Row() => new Rect(0f, y, viewRect.width, RowHeight); + + void Header(string label) + { + Text.Font = GameFont.Medium; + Widgets.Label(new Rect(0f, y, viewRect.width, 32f), label); + Text.Font = GameFont.Small; + y += 34f; + } + + Header("Networking"); + + // direct + { + var r = Row(); + TooltipHandler.TipRegion(r, "Enable Direct hosting (recommended for standalone/headless)."); + CheckboxLabeled(r, "Direct", ref settings.direct); + y += RowHeight; + + r = Row(); + TooltipHandler.TipRegion(r, "One or more endpoints, separated by ';'. Example: 0.0.0.0:30502"); + TextFieldLabeled(r, "Direct address", ref settings.directAddress); + y += RowHeight; + Gap(); + } + + // lan + { + var r = Row(); + TooltipHandler.TipRegion(r, "Enable LAN broadcasting (typically off for headless servers)."); + CheckboxLabeled(r, "LAN", ref settings.lan); + y += RowHeight; + Gap(); + } + + // steam + { + var r = Row(); + TooltipHandler.TipRegion(r, "Steam hosting is not supported by the standalone server."); + CheckboxLabeled(r, "Steam", ref settings.steam); + y += RowHeight; + Gap(); + } + + Header("Server limits"); + + // max players + { + var r = Row(); + TooltipHandler.TipRegion(r, "Maximum number of players allowed to connect."); + TextFieldNumericLabeled(r, "Max players", ref settings.maxPlayers, ref maxPlayersBuffer, 1, 999); + y += RowHeight; + Gap(); + } + + // password + { + var r = Row(); + TooltipHandler.TipRegion(r, "Require a password to join."); + CheckboxLabeled(r, "Has password", ref settings.hasPassword); + y += RowHeight; + + r = Row(); + TooltipHandler.TipRegion(r, "Password (only used if Has password is enabled)."); + TextFieldLabeled(r, "Password", ref settings.password); + y += RowHeight; + Gap(); + } + + Header("Saves / autosaves"); + + // autosave interval + unit + { + var r = Row(); + TooltipHandler.TipRegion(r, "Autosave interval. Unit is configured separately below."); + TextFieldNumericLabeled(r, "Autosave interval", ref settings.autosaveInterval, ref autosaveIntervalBuffer, 0f, 999f); + y += RowHeight; + + r = Row(); + TooltipHandler.TipRegion(r, "Autosave unit."); + EnumDropdownLabeled(r, "Autosave unit", settings.autosaveUnit, v => settings.autosaveUnit = v); + y += RowHeight; + Gap(); + } + + Header("Gameplay options"); + + // async time + { + var r = Row(); + TooltipHandler.TipRegion(r, "Allow async time. (Once enabled in a save, usually can't be disabled.)"); + CheckboxLabeled(r, "Async time", ref settings.asyncTime); + y += RowHeight; + } + + // multifaction + { + var r = Row(); + TooltipHandler.TipRegion(r, "Enable multi-faction play."); + CheckboxLabeled(r, "Multifaction", ref settings.multifaction); + y += RowHeight; + Gap(); + } + + // time control + { + var r = Row(); + TooltipHandler.TipRegion(r, "Who controls game speed."); + EnumDropdownLabeled(r, "Time control", settings.timeControl, v => settings.timeControl = v); + y += RowHeight; + Gap(); + } + + // auto join point + { + var r = Row(); + TooltipHandler.TipRegion(r, "When clients automatically join (flags). Stored as a string in TOML."); + TextFieldLabeled(r, "Auto join point (flags)", ref settings.autoJoinPoint); + y += RowHeight; + Gap(); + } + + // pause behavior + { + var r = Row(); + TooltipHandler.TipRegion(r, "When to automatically pause on letters."); + EnumDropdownLabeled(r, "Pause on letter", settings.pauseOnLetter, v => settings.pauseOnLetter = v); + y += RowHeight; + + r = Row(); + TooltipHandler.TipRegion(r, "Pause when a player joins."); + CheckboxLabeled(r, "Pause on join", ref settings.pauseOnJoin); + y += RowHeight; + + r = Row(); + TooltipHandler.TipRegion(r, "Pause on desync."); + CheckboxLabeled(r, "Pause on desync", ref settings.pauseOnDesync); + y += RowHeight; + Gap(); + } + + Header("Debug / development"); + + // debug mode + { + var r = Row(); + TooltipHandler.TipRegion(r, "Enable debug mode."); + CheckboxLabeled(r, "Debug mode", ref settings.debugMode); + y += RowHeight; + + r = Row(); + TooltipHandler.TipRegion(r, "Include desync traces to help debugging."); + CheckboxLabeled(r, "Desync traces", ref settings.desyncTraces); + y += RowHeight; + + r = Row(); + TooltipHandler.TipRegion(r, "Sync mod configs to clients."); + CheckboxLabeled(r, "Sync configs", ref settings.syncConfigs); + y += RowHeight; + + r = Row(); + TooltipHandler.TipRegion(r, "Dev mode scope."); + EnumDropdownLabeled(r, "Dev mode scope", settings.devModeScope, v => settings.devModeScope = v); + y += RowHeight; + Gap(); + } + + // unsupported settings but still in schema + Header("Standalone limitations"); + { + var r = Row(); + TooltipHandler.TipRegion(r, "Arbiter is not supported in standalone server."); + CheckboxLabeled(r, "Arbiter (unsupported)", ref settings.arbiter); + y += RowHeight; + } + + Widgets.EndScrollView(); + } + + private void DrawSettingsButtons(Rect inRect) + { + var buttons = inRect.ContractedBy(4f); + + var copyRect = buttons.LeftPart(0.5f).ContractedBy(2f); + if (Widgets.ButtonText(copyRect, "Copy TOML")) + { + RebuildTomlPreview(); + GUIUtility.systemCopyBuffer = tomlPreview; + Messages.Message("Copied settings.toml to clipboard", MessageTypeDefOf.SilentInput, false); + } + + var nextRect = buttons.RightPart(0.5f).ContractedBy(2f); + var nextLabel = settingsUploaded ? "Uploaded" : "Next"; + var nextEnabled = !isUploadingToml && !settingsUploaded; + + // Always show the button, just change color when disabled + var prevColor = GUI.color; + if (!nextEnabled) + GUI.color = new Color(1f, 1f, 1f, 0.5f); + + if (Widgets.ButtonText(nextRect, nextLabel)) + { + if (nextEnabled) + { + // Upload generated settings.toml to the server. + RebuildTomlPreview(); + StartUploadSettingsToml(tomlPreview); + } + } + + GUI.color = prevColor; + } + + private void StartUploadSettingsToml(string tomlText) + { + isUploadingToml = true; + uploadProgress = 0f; + statusText = "Uploading settings.toml..."; + + // Upload on a background thread; network send is safe (it will be queued by the underlying net impl). + var bytes = Encoding.UTF8.GetBytes(tomlText); + var fileName = "settings.toml"; + string sha256; + using (var hasher = SHA256.Create()) + sha256 = hasher.ComputeHash(bytes).ToHexString(); + + new System.Threading.Thread(() => + { + try + { + connection.Send(new ClientBootstrapSettingsUploadStartPacket(fileName, bytes.Length)); + + const int chunk = 64 * 1024; // safe: packet will be fragmented by ConnectionBase + 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); + connection.SendFragmented(new ClientBootstrapSettingsUploadDataPacket(part).Serialize()); + sent += len; + var progress = bytes.Length == 0 ? 1f : (float)sent / bytes.Length; + OnMainThread.Enqueue(() => uploadProgress = Mathf.Clamp01(progress)); + } + + connection.Send(new ClientBootstrapSettingsUploadFinishPacket(sha256)); + + OnMainThread.Enqueue(() => + { + isUploadingToml = false; + settingsUploaded = true; + statusText = "Server settings configured correctly. Proceed with map generation."; + step = Step.GenerateMap; + }); + } + catch (Exception e) + { + OnMainThread.Enqueue(() => + { + isUploadingToml = false; + statusText = $"Failed to upload settings.toml: {e.GetType().Name}: {e.Message}"; + }); + } + }) { IsBackground = true, Name = "MP Bootstrap TOML upload" }.Start(); + } + + private void StartVanillaNewColonyFlow() + { + // Disconnect from server before world generation to avoid sync conflicts. + // We'll reconnect after the autosave is complete to upload save.zip. + if (Multiplayer.session != null) + { + Multiplayer.session.Stop(); + Multiplayer.session = null; + } + + // Start the vanilla flow offline + try + { + // Ensure InitData exists for the page flow; RimWorld uses this heavily during new game setup. + Current.Game ??= new Game(); + Current.Game.InitData ??= new GameInitData { startedFromEntry = true }; + + // Do NOT change programState; let vanilla handle it during the page flow + var scenarioPage = new Page_SelectScenario(); + + // StitchedPages handles correct "Next" navigation between Page(s). + Find.WindowStack.Add(PageUtility.StitchedPages(new System.Collections.Generic.List { scenarioPage })); + + // Start watching for page flow + map entry. + saveReady = false; + savedReplayPath = null; + saveUploadStatus = "Waiting for world generation..."; + + // Arm the vanilla page auto-advance driver + autoAdvanceArmed = true; + nextPressCooldown = 0f; + randomTileCooldown = 0f; + autoAdvanceElapsed = 0f; + worldGenDetected = false; + worldGenDelayRemaining = WorldGenDelaySeconds; + autoAdvanceDiagCooldown = 0f; + startingLettersCleared = false; + landingDialogsCleared = false; + + Trace("StartVanillaNewColonyFlow"); + } + catch (Exception e) + { + Messages.Message($"Failed to start New Colony flow: {e.GetType().Name}: {e.Message}", MessageTypeDefOf.RejectInput, false); + Trace($"StartVanillaNewColonyFlow:EX:{e.GetType().Name}"); + } + } + + /// + /// Drives the vanilla "New colony" page flow by pressing "Random" on tile selection + /// pages and auto-advancing with "Next" until we enter Playing with a map. + /// Uses reflection to avoid hard dependencies on specific RimWorld versions / page classes. + /// + private void TryAutoAdvanceVanillaPages() + { + if (!autoAdvanceArmed) + return; + + // If we've already reached Playing with a map, stop driving pages immediately. + if (Current.ProgramState == ProgramState.Playing && Find.Maps != null && Find.Maps.Count > 0) + { + autoAdvanceArmed = false; + return; + } + + TraceSnapshotTick(); + + if (autoAdvanceDiagCooldown > 0f) + autoAdvanceDiagCooldown -= Time.deltaTime; + + // Don't start auto-advancing until the world is generated. The user can still interact + // with the scenario + world generation pages manually; we only take over after the world exists. + if (Find.World == null || Find.World.grid == null) + { + if (string.IsNullOrEmpty(saveUploadStatus) || saveUploadStatus.StartsWith("Advancing:") || saveUploadStatus.StartsWith("Entered map")) + saveUploadStatus = "Waiting for world generation..."; + + if (autoAdvanceDiagCooldown <= 0f) + { + autoAdvanceDiagCooldown = AutoAdvanceDiagCooldownSeconds; + Log.Message($"[Bootstrap] Auto-advance armed; waiting world. ProgramState={Current.ProgramState}"); + } + + Trace("WaitWorld"); + return; + } + + // World is generated: wait a small grace period before starting to press Next. + if (!worldGenDetected) + { + worldGenDetected = true; + worldGenDelayRemaining = WorldGenDelaySeconds; + Trace("WorldDetected"); + } + + if (worldGenDelayRemaining > 0f) + { + worldGenDelayRemaining -= Time.deltaTime; + saveUploadStatus = "World generated. Waiting..."; + + if (autoAdvanceDiagCooldown <= 0f) + { + autoAdvanceDiagCooldown = AutoAdvanceDiagCooldownSeconds; + Log.Message($"[Bootstrap] World detected; delaying {worldGenDelayRemaining:0.00}s before auto-next"); + } + + Trace("WorldDelay"); + return; + } + + // Stop after some time to avoid infinite looping if the UI is blocked by an error dialog. + autoAdvanceElapsed += Time.deltaTime; + if (autoAdvanceElapsed > AutoAdvanceTimeoutSeconds) + { + autoAdvanceArmed = false; + saveUploadStatus = "Auto-advance timed out. Please complete world setup manually."; + Trace("AutoAdvanceTimeout"); + return; + } + + // Once we're playing and have a map, arm the save hook and stop auto-advance. + if (Current.ProgramState == ProgramState.Playing && Find.Maps != null && Find.Maps.Count > 0) + { + if (!AwaitingBootstrapMapInit) + { + AwaitingBootstrapMapInit = true; + saveUploadStatus = "Entered map. Waiting for initialization to complete..."; + Log.Message($"[Bootstrap] Reached Playing. maps={Find.Maps.Count}, currentMap={(Find.CurrentMap != null ? Find.CurrentMap.ToString() : "")}"); + Trace("EnteredPlaying"); + } + + autoAdvanceArmed = false; + return; + } + + // Cooldowns to avoid spamming actions every frame + if (nextPressCooldown > 0f) + nextPressCooldown -= Time.deltaTime; + if (randomTileCooldown > 0f) + randomTileCooldown -= Time.deltaTime; + + // Find the top-most Page in the window stack + Page page = null; + var windows = Find.WindowStack?.Windows; + if (windows != null) + { + for (int i = windows.Count - 1; i >= 0; i--) + { + if (windows[i] is Page p) + { + page = p; + break; + } + } + } + + if (page == null) + { + if (autoAdvanceDiagCooldown <= 0f) + { + autoAdvanceDiagCooldown = AutoAdvanceDiagCooldownSeconds; + Log.Message($"[Bootstrap] Auto-advance: no Page found. ProgramState={Current.ProgramState}"); + } + + Trace("NoPage"); + return; + } + + // Some tiles prompt a confirmation dialog (e.g., harsh conditions / nearby faction). Accept it automatically. + TryAutoAcceptTileWarnings(page); + + if (autoAdvanceDiagCooldown <= 0f) + { + autoAdvanceDiagCooldown = AutoAdvanceDiagCooldownSeconds; + Log.Message($"[Bootstrap] Auto-advance on page {page.GetType().Name}; CanDoNext={CanDoNextQuick(page)}; nextCooldown={nextPressCooldown:0.00}"); + } + + // Avoid spamming page trace if we sit on the same page for multiple ticks + var curPageName = page.GetType().Name; + if (curPageName != lastPageName) + { + lastPageName = curPageName; + Trace($"Page:{curPageName}:CanNext={CanDoNextQuick(page)}"); + } + + // If we're on a starting-site selection page, try to choose a random tile by setting GameInitData.startingTile. + // This mimics "Choose random" behavior without needing to locate UI widgets. + if (randomTileCooldown <= 0f && Find.GameInitData != null && Find.World?.grid != null) + { + var typeName = page.GetType().Name; + if (typeName.IndexOf("Starting", StringComparison.OrdinalIgnoreCase) >= 0 && + (typeName.IndexOf("Site", StringComparison.OrdinalIgnoreCase) >= 0 || typeName.IndexOf("Landing", StringComparison.OrdinalIgnoreCase) >= 0)) + { + int tile = FindSuitableTile(); + Find.GameInitData.startingTile = tile; + randomTileCooldown = RandomTileCooldownSeconds; + if (Prefs.DevMode) + Log.Message($"[Bootstrap] Picked random starting tile {tile} on page {typeName}"); + } + } + + if (nextPressCooldown > 0f) + return; + + // Press Next via reflection. + if (TryInvokePageNext(page)) + { + nextPressCooldown = NextPressCooldownSeconds; + saveUploadStatus = $"Advancing: {page.GetType().Name}..."; + Trace($"DoNext:{page.GetType().Name}"); + + // If this Next starts the actual new game initialization (InitNewGame long event), + // WindowUpdate can stall for a while. Schedule a post-long-event check so we can + // reliably arm the FinalizeInit trigger once the game switches to Playing. + var pageName = page.GetType().Name; + if (pageName.IndexOf("ConfigureStartingPawns", StringComparison.OrdinalIgnoreCase) >= 0) + { + LongEventHandler.ExecuteWhenFinished(() => + { + OnMainThread.Enqueue(() => + { + Trace("PostInitNewGameCheck"); + TryArmAwaitingBootstrapMapInit("ExecuteWhenFinished"); + }); + }); + } + } + } + + private void TryArmAwaitingBootstrapMapInit(string source) + { + // This is safe to call repeatedly. + if (AwaitingBootstrapMapInit) + return; + + // Avoid arming while long events are still running. During heavy initialization + // we can briefly observe Playing+map before MapComponentUtility.FinalizeInit + // runs; arming too early risks missing the FinalizeInit signal. + try + { + if (LongEventHandler.AnyEventNowOrWaiting) + { + if (bootstrapTraceEnabled) + Log.Message($"[BootstrapTrace] mapInit not armed yet ({source}): long event running"); + return; + } + } + catch + { + // If the API isn't available in a specific RW version, fail open. + } + + if (Current.ProgramState != ProgramState.Playing) + { + if (bootstrapTraceEnabled) + Log.Message($"[BootstrapTrace] mapInit not armed yet ({source}): ProgramState={Current.ProgramState}"); + return; + } + + if (Find.Maps == null || Find.Maps.Count == 0) + { + if (bootstrapTraceEnabled) + Log.Message($"[BootstrapTrace] mapInit not armed yet ({source}): no maps"); + return; + } + + AwaitingBootstrapMapInit = true; + saveUploadStatus = "Entered map. Waiting for initialization to complete..."; + // Keep this log lightweight (avoid Verse.Log stack traces). + UnityEngine.Debug.Log($"[Bootstrap] Entered map detected via {source}. maps={Find.Maps.Count}"); + Trace("EnteredPlaying"); + + // Stop page driver at this point. + autoAdvanceArmed = false; + } + + // Called from Root_Play.Start postfix (outside of the window update loop) + internal void TryArmAwaitingBootstrapMapInit_FromRootPlay() + { + Trace("RootPlayStart"); + TryArmAwaitingBootstrapMapInit("Root_Play.Start"); + } + + // Called from Root_Play.Update postfix. This is the main reliable arming mechanism. + internal void TryArmAwaitingBootstrapMapInit_FromRootPlayUpdate() + { + // If we're not in bootstrap flow there is nothing to do. + // We treat the existence of the window as "bootstrap active". + TryArmAwaitingBootstrapMapInit("Root_Play.Update"); + + // Also drive the post-map save pipeline from this reliable update loop. + TickPostMapEnterSaveDelayAndMaybeSave(); + + // Once we have a reliable arming mechanism, we can reduce noisy periodic snapshots. + // (We still keep event logs.) + if (AwaitingBootstrapMapInit || postMapEnterSaveDelayRemaining > 0f || saveReady || isUploadingSave || isReconnecting) + bootstrapTraceSnapshotCooldown = BootstrapTraceSnapshotSeconds; // delay next snapshot + } + + private static bool? CanDoNextQuick(Page page) + { + try + { + var t = page.GetType(); + var canDoNextMethod = t.GetMethod("CanDoNext", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + if (canDoNextMethod != null && canDoNextMethod.ReturnType == typeof(bool) && canDoNextMethod.GetParameters().Length == 0) + return (bool)canDoNextMethod.Invoke(page, null); + } + catch + { + // ignore + } + + return null; + } + + private static bool TryInvokePageNext(Page page) + { + try + { + var t = page.GetType(); + + // Common patterns across RW versions: + // - CanDoNext() + DoNext() + // - CanDoNext (property) + DoNext() + // - DoNext() only + bool canNext = true; + var canDoNextMethod = t.GetMethod("CanDoNext", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + if (canDoNextMethod != null && canDoNextMethod.ReturnType == typeof(bool) && canDoNextMethod.GetParameters().Length == 0) + canNext = (bool)canDoNextMethod.Invoke(page, null); + + if (!canNext) + return false; + + var doNextMethod = t.GetMethod("DoNext", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + if (doNextMethod != null && doNextMethod.GetParameters().Length == 0) + { + doNextMethod.Invoke(page, null); + return true; + } + + // Fallback: try Next() method name + var nextMethod = t.GetMethod("Next", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + if (nextMethod != null && nextMethod.GetParameters().Length == 0) + { + nextMethod.Invoke(page, null); + return true; + } + } + catch (Exception e) + { + if (Prefs.DevMode) + Log.Warning($"[Bootstrap] Failed to invoke Next on page {page?.GetType().Name}: {e.GetType().Name}: {e.Message}"); + } + + return false; + } + + private void TryAutoAcceptTileWarnings(Page page) + { + try + { + // Only relevant on starting-site selection pages. + var typeName = page.GetType().Name; + if (typeName.IndexOf("Starting", StringComparison.OrdinalIgnoreCase) < 0) + return; + if (typeName.IndexOf("Site", StringComparison.OrdinalIgnoreCase) < 0 && typeName.IndexOf("Landing", StringComparison.OrdinalIgnoreCase) < 0) + return; + + var windows = Find.WindowStack?.Windows; + if (windows == null || windows.Count == 0) + return; + + for (int i = windows.Count - 1; i >= 0; i--) + { + if (windows[i] is Dialog_MessageBox msg) + { + // Prefer button A if present; otherwise try button B. + if (!string.IsNullOrEmpty(msg.buttonAText)) + { + msg.buttonAAction?.Invoke(); + msg.Close(true); + if (Prefs.DevMode) + Log.Message("[Bootstrap] Auto-accepted tile warning dialog (button A)"); + return; + } + + if (!string.IsNullOrEmpty(msg.buttonBText)) + { + msg.buttonBAction?.Invoke(); + msg.Close(true); + if (Prefs.DevMode) + Log.Message("[Bootstrap] Auto-accepted tile warning dialog (button B)"); + return; + } + } + } + } + catch + { + // Fail open; we don't want to block automation because of a dialog. + } + } + + internal void TryClearStartingLettersOnce() + { + if (startingLettersCleared) + return; + + var letterStack = Find.LetterStack; + var letters = letterStack?.LettersListForReading; + if (letters == null || letters.Count == 0) + return; + + // Remove from the end to avoid mutation issues. + for (int i = letters.Count - 1; i >= 0; i--) + { + var letter = letters[i]; + letterStack.RemoveLetter(letter); + } + + startingLettersCleared = true; + if (Prefs.DevMode) + Log.Message("[Bootstrap] Cleared starting letters/messages"); + } + + internal void TryCloseLandingDialogsOnce() + { + if (landingDialogsCleared) + return; + + var windows = Find.WindowStack?.Windows; + if (windows == null || windows.Count == 0) + return; + + // Close common blocking dialogs shown at landing (message boxes, node trees) + for (int i = windows.Count - 1; i >= 0; i--) + { + var w = windows[i]; + if (w is Dialog_MessageBox || w is Dialog_NodeTree) + { + try + { + w.Close(true); + } + catch { } + } + } + + landingDialogsCleared = true; + if (Prefs.DevMode) + Log.Message("[Bootstrap] Closed landing dialogs (message boxes / node trees)"); + } + + private int FindSuitableTile() + { + var world = Find.World; + var grid = world.grid; + + // Try to find a temperate, flat tile without extreme conditions + for (int i = 0; i < grid.TilesCount; i++) + { + var tile = grid[i]; + + // Skip water tiles + if (tile.biome.canBuildBase == false) + continue; + + // Skip tiles with settlements or world objects + if (Find.WorldObjects.AnyWorldObjectAt(i)) + continue; + + // Prefer temperate, flat tiles + if (tile.hilliness == Hilliness.Flat || tile.hilliness == Hilliness.SmallHills) + { + // Check temperature (tile.temperature is the annual average) + if (tile.temperature > -10f && tile.temperature < 40f) + return i; + } + } + + // Fallback: find any buildable tile + for (int i = 0; i < grid.TilesCount; i++) + { + var tile = grid[i]; + if (tile.biome.canBuildBase && !Find.WorldObjects.AnyWorldObjectAt(i)) + return i; + } + + // Last resort: use tile 0 (should never happen) + return 0; + } + + public void OnBootstrapMapInitialized() + { + if (!AwaitingBootstrapMapInit) + return; + + AwaitingBootstrapMapInit = false; + + // Wait a bit after entering the map before saving, to let final UI/world settle. + postMapEnterSaveDelayRemaining = PostMapEnterSaveDelaySeconds; + awaitingControllablePawns = true; + awaitingControllablePawnsElapsed = 0f; + bootstrapSaveQueued = false; + saveUploadStatus = "Map initialized. Waiting before saving..."; + + Trace("FinalizeInit"); + + if (Prefs.DevMode) + Log.Message("[Bootstrap] Map initialized, waiting for controllable pawns before saving"); + + // Saving is driven by a tick loop (WindowUpdate + BootstrapCoordinator + Root_Play.Update). + // Do not assume WindowUpdate keeps ticking during/after long events. + } + + private void TickPostMapEnterSaveDelayAndMaybeSave() + { + // This is called from multiple tick sources; keep it idempotent. + if (bootstrapSaveQueued || saveReady || isUploadingSave || isReconnecting) + return; + + // Only run once we have been signalled by FinalizeInit. + if (postMapEnterSaveDelayRemaining <= 0f) + return; + + // Clear initial letters/messages that can appear right after landing. + TryClearStartingLettersOnce(); + TryCloseLandingDialogsOnce(); + + TraceSnapshotTick(); + + // Drive the post-map delay. Use real time, not game ticks; during map init we still want + // the save to happen shortly after the map becomes controllable. + postMapEnterSaveDelayRemaining -= Time.deltaTime; + if (postMapEnterSaveDelayRemaining > 0f) + return; + + // We reached the post-map-entry delay, now wait until we actually have spawned pawns. + // This avoids saving too early in cases where the map exists but the colony isn't ready. + if (awaitingControllablePawns) + { + awaitingControllablePawnsElapsed += Time.deltaTime; + + if (Current.ProgramState == ProgramState.Playing && Find.CurrentMap != null) + { + var anyColonist = false; + try + { + // Prefer FreeColonists: these are player controllable pawns. + // (Some versions/modlists may temporarily have an empty list during generation.) + anyColonist = Find.CurrentMap.mapPawns?.FreeColonistsSpawned != null && + Find.CurrentMap.mapPawns.FreeColonistsSpawned.Count > 0; + } + catch + { + // ignored; we'll just keep waiting + } + + if (anyColonist) + { + awaitingControllablePawns = false; + + // Pause the game as soon as colonists are controllable so the snapshot is stable + try { Find.TickManager.CurTimeSpeed = TimeSpeed.Paused; } catch { } + + if (Prefs.DevMode) + Log.Message("[Bootstrap] Controllable colonists detected, starting save"); + } + } + + if (awaitingControllablePawns) + { + if (awaitingControllablePawnsElapsed > AwaitControllablePawnsTimeoutSeconds) + { + // Fallback: don't block forever; save anyway. + awaitingControllablePawns = false; + if (Prefs.DevMode) + Log.Warning("[Bootstrap] Timed out waiting for controllable pawns; saving anyway"); + } + else + { + saveUploadStatus = "Entered map. Waiting for colonists to spawn..."; + Trace("WaitColonists"); + return; + } + } + } + + // Ensure we don't re-enter this function multiple times and queue multiple saves. + postMapEnterSaveDelayRemaining = 0f; + bootstrapSaveQueued = true; + + saveUploadStatus = "Map initialized. Starting hosted MP session..."; + Trace("StartHost"); + + // NEW FLOW: instead of vanilla save + manual repackaging, + // 1) Host a local MP game programmatically (random port to avoid conflicts) + // 2) Call standard MP save (SaveGameToFile_Overwrite) which produces a proper replay + // 3) Close session and return to menu + // Result: clean replay.zip ready to upload + + LongEventHandler.QueueLongEvent(() => + { + try + { + // 1. Host multiplayer game on random free port (avoid collisions with user's server) + var hostSettings = new ServerSettings + { + gameName = "BootstrapHost", + maxPlayers = 2, + direct = true, + lan = false, + steam = false, + // directAddress will be set by HostProgrammatically to a free port + }; + + bool hosted = HostWindow.HostProgrammatically(hostSettings, file: null, randomDirectPort: true); + if (!hosted) + { + OnMainThread.Enqueue(() => + { + saveUploadStatus = "Failed to host MP session."; + Log.Error("[Bootstrap] HostProgrammatically failed"); + Trace("HostFailed"); + bootstrapSaveQueued = false; + }); + return; + } + + Log.Message("[Bootstrap] Hosted MP session successfully. Now saving replay..."); + + OnMainThread.Enqueue(() => + { + saveUploadStatus = "Hosted. Saving replay..."; + Trace("HostSuccess"); + + // 2. Save as multiplayer replay (this uses the standard MP snapshot which includes maps correctly) + LongEventHandler.QueueLongEvent(() => + { + try + { + Autosaving.SaveGameToFile_Overwrite(BootstrapSaveName, currentReplay: false); + + var path = System.IO.Path.Combine(Multiplayer.ReplaysDir, $"{BootstrapSaveName}.zip"); + + OnMainThread.Enqueue(() => + { + savedReplayPath = path; + saveReady = System.IO.File.Exists(savedReplayPath); + lastSavedReplayPath = savedReplayPath; + lastSaveReady = saveReady; + + if (saveReady) + { + saveUploadStatus = "Save complete. Exiting to menu..."; + Trace("SaveComplete"); + + // 3. Exit to main menu (this also cleans up the local server) + LongEventHandler.QueueLongEvent(() => + { + GenScene.GoToMainMenu(); + + OnMainThread.Enqueue(() => + { + saveUploadStatus = "Reconnecting to upload save..."; + Trace("GoToMenuComplete"); + ReconnectAndUploadSave(); + }); + }, "Returning to menu", false, null); + } + else + { + saveUploadStatus = "Save failed - file not found."; + Log.Error($"[Bootstrap] Save finished but file missing: {savedReplayPath}"); + Trace("SaveMissingFile"); + bootstrapSaveQueued = false; + } + }); + } + catch (Exception e) + { + OnMainThread.Enqueue(() => + { + saveUploadStatus = $"Save failed: {e.GetType().Name}: {e.Message}"; + Log.Error($"[Bootstrap] Save failed: {e}"); + Trace($"SaveEX:{e.GetType().Name}"); + bootstrapSaveQueued = false; + }); + } + }, "Saving", false, null); + }); + } + catch (Exception e) + { + OnMainThread.Enqueue(() => + { + saveUploadStatus = $"Host failed: {e.GetType().Name}: {e.Message}"; + Log.Error($"[Bootstrap] Host exception: {e}"); + Trace($"HostEX:{e.GetType().Name}"); + bootstrapSaveQueued = false; + }); + } + }, "Starting host", false, null); + } + + public override void WindowUpdate() + { + base.WindowUpdate(); + + TickPostMapEnterSaveDelayAndMaybeSave(); + + if (isReconnecting) + CheckReconnectionState(); + + // Drive the vanilla page flow automatically (random tile + next) + TryAutoAdvanceVanillaPages(); + } + + /// + /// Called by once per second while the bootstrap window exists. + /// This survives long events / MapInitializing where WindowUpdate may not tick reliably. + /// + internal void BootstrapCoordinatorTick() + { + // Try to arm map init reliably once the game has actually entered Playing. + if (!AwaitingBootstrapMapInit) + TryArmAwaitingBootstrapMapInit("BootstrapCoordinator"); + + // Drive the post-map-entry save delay even if the window update isn't running smoothly. + TickPostMapEnterSaveDelayAndMaybeSave(); + } + + private void TraceSnapshotTick() + { + if (!bootstrapTraceEnabled) + return; + + if (bootstrapTraceSnapshotCooldown > 0f) + { + bootstrapTraceSnapshotCooldown -= Time.deltaTime; + return; + } + + bootstrapTraceSnapshotCooldown = BootstrapTraceSnapshotSeconds; + + var pageName = GetTopPageName(); + var mapCount = Find.Maps?.Count ?? 0; + var curMap = Find.CurrentMap; + var colonists = 0; + try + { + colonists = curMap?.mapPawns?.FreeColonistsSpawned?.Count ?? 0; + } + catch + { + // ignored + } + + Log.Message( + $"[BootstrapTrace] state={Current.ProgramState} " + + $"autoAdvance={autoAdvanceArmed} elapsed={autoAdvanceElapsed:0.0}s " + + $"world={(Find.World != null ? "Y" : "N")} " + + $"page={pageName} " + + $"maps={mapCount} colonists={colonists} " + + $"awaitMapInit={AwaitingBootstrapMapInit} postDelay={postMapEnterSaveDelayRemaining:0.00} " + + $"saveReady={saveReady} uploading={isUploadingSave} reconnecting={isReconnecting}"); + } + + private void Trace(string key) + { + if (!bootstrapTraceEnabled) + return; + + // Only print on transitions to keep logs readable. + if (lastTraceKey == key) + return; + + lastTraceKey = key; + var pageName = GetTopPageName(); + Log.Message($"[BootstrapTrace] event={key} state={Current.ProgramState} page={pageName}"); + } + + private static string GetTopPageName() + { + try + { + var windows = Find.WindowStack?.Windows; + if (windows == null) + return ""; + + for (int i = windows.Count - 1; i >= 0; i--) + if (windows[i] is Page p) + return p.GetType().Name; + + return ""; + } + catch + { + return ""; + } + } + + // Legacy polling method removed: we now use the vanilla page flow + auto Next. + + private void ReconnectAndUploadSave() + { + saveUploadStatus = "Reconnecting to server..."; + + try + { + // Reconnect to the server (playerId will always be 0 in bootstrap) + Multiplayer.StopMultiplayer(); + + Multiplayer.session = new MultiplayerSession(); + Multiplayer.session.address = serverAddress; + Multiplayer.session.port = serverPort; + + var conn = ClientLiteNetConnection.Connect(serverAddress, serverPort); + conn.username = Multiplayer.username; + Multiplayer.session.client = conn; + + // Start polling in WindowUpdate + isReconnecting = true; + reconnectCheckTimer = 0; + reconnectingConn = conn; + } + catch (Exception e) + { + saveUploadStatus = $"Reconnection failed: {e.GetType().Name}: {e.Message}"; + isUploadingSave = false; + } + } + + private void CheckReconnectionState() + { + reconnectCheckTimer++; + + if (reconnectingConn.State == ConnectionStateEnum.ClientBootstrap) + { + saveUploadStatus = "Reconnected. Starting upload..."; + isReconnecting = false; + reconnectingConn = null; + reconnectCheckTimer = 0; + StartUploadSaveZip(); + } + else if (reconnectingConn.State == ConnectionStateEnum.Disconnected) + { + saveUploadStatus = "Reconnection failed. Cannot upload save.zip."; + isReconnecting = false; + reconnectingConn = null; + reconnectCheckTimer = 0; + isUploadingSave = false; + } + else if (reconnectCheckTimer > 600) // 10 seconds at 60fps + { + saveUploadStatus = "Reconnection timeout. Cannot upload save.zip."; + isReconnecting = false; + reconnectingConn = null; + reconnectCheckTimer = 0; + isUploadingSave = false; + } + } + + private void StartUploadSaveZip() + { + if (string.IsNullOrWhiteSpace(savedReplayPath) || !System.IO.File.Exists(savedReplayPath)) + { + saveUploadStatus = "Can't upload: autosave file not found."; + return; + } + + isUploadingSave = true; + saveUploadProgress = 0f; + saveUploadStatus = "Uploading save.zip..."; + + byte[] bytes; + try + { + bytes = System.IO.File.ReadAllBytes(savedReplayPath); + } + catch (Exception e) + { + isUploadingSave = false; + saveUploadStatus = $"Failed to read autosave: {e.GetType().Name}: {e.Message}"; + return; + } + + string sha256; + using (var hasher = SHA256.Create()) + sha256 = hasher.ComputeHash(bytes).ToHexString(); + + new System.Threading.Thread(() => + { + try + { + connection.Send(new ClientBootstrapUploadStartPacket("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); + connection.SendFragmented(new ClientBootstrapUploadDataPacket(part).Serialize()); + sent += len; + var progress = bytes.Length == 0 ? 1f : (float)sent / bytes.Length; + OnMainThread.Enqueue(() => saveUploadProgress = Mathf.Clamp01(progress)); + } + + connection.Send(new ClientBootstrapUploadFinishPacket(sha256)); + + OnMainThread.Enqueue(() => + { + // Server will send ServerBootstrapCompletePacket and close connections. + 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(); + } + + 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 ?? ""; + + var viewRect = new Rect(0f, 0f, previewRect.width - 16f, Mathf.Max(previewRect.height, Text.CalcHeight(content, previewRect.width - 16f) + 20f)); + Widgets.BeginScrollView(previewRect, ref tomlScroll, viewRect); + Widgets.Label(new Rect(0f, 0f, viewRect.width, viewRect.height), content); + Widgets.EndScrollView(); + } + + private void RebuildTomlPreview() + { + var sb = new StringBuilder(); + + // Important: This must mirror ServerSettings.ExposeData() keys. + sb.AppendLine("# Generated by Multiplayer bootstrap configurator"); + sb.AppendLine("# Keys must match ServerSettings.ExposeData()\n"); + + // ExposeData() order + AppendKv(sb, "directAddress", settings.directAddress); + AppendKv(sb, "maxPlayers", settings.maxPlayers); + AppendKv(sb, "autosaveInterval", settings.autosaveInterval); + AppendKv(sb, "autosaveUnit", settings.autosaveUnit.ToString()); + AppendKv(sb, "steam", settings.steam); + AppendKv(sb, "direct", settings.direct); + AppendKv(sb, "lan", settings.lan); + AppendKv(sb, "asyncTime", settings.asyncTime); + AppendKv(sb, "multifaction", settings.multifaction); + AppendKv(sb, "debugMode", settings.debugMode); + AppendKv(sb, "desyncTraces", settings.desyncTraces); + AppendKv(sb, "syncConfigs", settings.syncConfigs); + AppendKv(sb, "autoJoinPoint", settings.autoJoinPoint.ToString()); + AppendKv(sb, "devModeScope", settings.devModeScope.ToString()); + AppendKv(sb, "hasPassword", settings.hasPassword); + AppendKv(sb, "password", settings.password ?? ""); + AppendKv(sb, "pauseOnLetter", settings.pauseOnLetter.ToString()); + AppendKv(sb, "pauseOnJoin", settings.pauseOnJoin); + AppendKv(sb, "pauseOnDesync", settings.pauseOnDesync); + AppendKv(sb, "timeControl", settings.timeControl.ToString()); + + tomlPreview = sb.ToString(); + } + + private static void AppendKv(StringBuilder sb, string key, string value) + { + sb.Append(key); + sb.Append(" = "); + + // Basic TOML escaping for strings + var escaped = value.Replace("\\", "\\\\").Replace("\"", "\\\""); + sb.Append('"').Append(escaped).Append('"'); + sb.AppendLine(); + } + + private static void AppendKv(StringBuilder sb, string key, bool value) + { + sb.Append(key); + sb.Append(" = "); + sb.AppendLine(value ? "true" : "false"); + } + + private static void AppendKv(StringBuilder sb, string key, int value) + { + sb.Append(key); + sb.Append(" = "); + sb.AppendLine(value.ToString()); + } + + private static void AppendKv(StringBuilder sb, string key, float value) + { + // TOML uses '.' decimal separator + sb.Append(key); + sb.Append(" = "); + sb.AppendLine(value.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + private void CheckboxLabeled(Rect r, string label, ref bool value) + { + var labelRect = r.LeftPartPixels(LabelWidth); + var boxRect = r.RightPartPixels(r.width - LabelWidth); + Widgets.Label(labelRect, label); + var oldValue = value; + Widgets.Checkbox(boxRect.x, boxRect.y + (boxRect.height - 24f) / 2f, ref value, 24f); + if (value != oldValue) + RebuildTomlPreview(); + } + + private void TextFieldLabeled(Rect r, string label, ref string value) + { + var labelRect = r.LeftPartPixels(LabelWidth); + var fieldRect = r.RightPartPixels(r.width - LabelWidth); + Widgets.Label(labelRect, label); + var oldValue = value; + value = Widgets.TextField(fieldRect, value ?? ""); + if (value != oldValue) + RebuildTomlPreview(); + } + + private void TextFieldLabeled(Rect r, string label, ref AutoJoinPointFlags value) + { + var labelRect = r.LeftPartPixels(LabelWidth); + var fieldRect = r.RightPartPixels(r.width - LabelWidth); + Widgets.Label(labelRect, label); + + // Keep it simple for now: user edits the enum string ("Join, Desync"). + // We'll still emit it as string exactly like Server.TomlSettings.Save would. + var oldValue = value; + var str = Widgets.TextField(fieldRect, value.ToString()); + if (Enum.TryParse(str, out AutoJoinPointFlags parsed)) + value = parsed; + if (value != oldValue) + RebuildTomlPreview(); + } + + private void TextFieldNumericLabeled(Rect r, string label, ref int value, ref string buffer, int min, int max) + { + var labelRect = r.LeftPartPixels(LabelWidth); + var fieldRect = r.RightPartPixels(r.width - LabelWidth); + Widgets.Label(labelRect, label); + var oldValue = value; + Widgets.TextFieldNumeric(fieldRect, ref value, ref buffer, min, max); + if (value != oldValue) + RebuildTomlPreview(); + } + + private void TextFieldNumericLabeled(Rect r, string label, ref float value, ref string buffer, float min, float max) + { + var labelRect = r.LeftPartPixels(LabelWidth); + var fieldRect = r.RightPartPixels(r.width - LabelWidth); + Widgets.Label(labelRect, label); + var oldValue = value; + Widgets.TextFieldNumeric(fieldRect, ref value, ref buffer, min, max); + if (value != oldValue) + RebuildTomlPreview(); + } + + private void EnumDropdownLabeled(Rect r, string label, T value, Action setValue) where T : struct, Enum + { + var labelRect = r.LeftPartPixels(LabelWidth); + var buttonRect = r.RightPartPixels(r.width - LabelWidth); + Widgets.Label(labelRect, label); + + var buttonLabel = value.ToString(); + if (!Widgets.ButtonText(buttonRect, buttonLabel)) + return; + + var options = new System.Collections.Generic.List(); + foreach (var v in Enum.GetValues(typeof(T))) + { + var cast = (T)v; + var captured = cast; + options.Add(new FloatMenuOption(captured.ToString(), () => + { + setValue(captured); + RebuildTomlPreview(); + })); + } + + Find.WindowStack.Add(new FloatMenu(options)); + } + } +} From e8486794ab43a2cd6738d7be3acc7958fce5cdb6 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Mon, 29 Dec 2025 17:52:33 +0300 Subject: [PATCH 14/28] Client(windows): add BootstrapCoordinator GameComponent --- Source/Client/Windows/BootstrapCoordinator.cs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 Source/Client/Windows/BootstrapCoordinator.cs diff --git a/Source/Client/Windows/BootstrapCoordinator.cs b/Source/Client/Windows/BootstrapCoordinator.cs new file mode 100644 index 00000000..275a87c7 --- /dev/null +++ b/Source/Client/Windows/BootstrapCoordinator.cs @@ -0,0 +1,45 @@ +using System; +using Verse; + +namespace Multiplayer.Client +{ + /// + /// Runs during bootstrap to detect when the new game has fully entered Playing and a map exists, + /// even while the bootstrap window may not receive regular updates (e.g. during long events). + /// Keeps the save trigger logic reliable. + /// + public class BootstrapCoordinator : GameComponent + { + private int nextCheckTick; + private const int CheckIntervalTicks = 60; // ~1s + + public BootstrapCoordinator(Game game) + { + } + + public override void GameComponentTick() + { + base.GameComponentTick(); + + // Only relevant if the bootstrap window exists + 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); + } + } +} From 3cb52c12e708991c4379aec341c44b01437fc9a6 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Mon, 29 Dec 2025 17:52:43 +0300 Subject: [PATCH 15/28] Client(patches): add BootstrapMapInitPatch (FinalizeInit hook) --- .../Client/Windows/BootstrapMapInitPatch.cs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 Source/Client/Windows/BootstrapMapInitPatch.cs diff --git a/Source/Client/Windows/BootstrapMapInitPatch.cs b/Source/Client/Windows/BootstrapMapInitPatch.cs new file mode 100644 index 00000000..c3b57f5d --- /dev/null +++ b/Source/Client/Windows/BootstrapMapInitPatch.cs @@ -0,0 +1,23 @@ +using HarmonyLib; +using Verse; + +namespace Multiplayer.Client +{ + [HarmonyPatch(typeof(MapComponentUtility), nameof(MapComponentUtility.FinalizeInit))] + static class BootstrapMapInitPatch + { + static void Postfix(Map map) + { + // Check if we're waiting for bootstrap map initialization + if (BootstrapConfiguratorWindow.AwaitingBootstrapMapInit && + BootstrapConfiguratorWindow.Instance != null) + { + // Trigger save sequence on main thread + OnMainThread.Enqueue(() => + { + BootstrapConfiguratorWindow.Instance.OnBootstrapMapInitialized(); + }); + } + } + } +} From 447d52135c752aaf4b6fe7fa40cc777f9d45a0c0 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Mon, 29 Dec 2025 17:52:56 +0300 Subject: [PATCH 16/28] Client(patches): add BootstrapRootPlayPatch (Root_Play.Start hook) --- .../Client/Windows/BootstrapRootPlayPatch.cs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 Source/Client/Windows/BootstrapRootPlayPatch.cs diff --git a/Source/Client/Windows/BootstrapRootPlayPatch.cs b/Source/Client/Windows/BootstrapRootPlayPatch.cs new file mode 100644 index 00000000..4f83a534 --- /dev/null +++ b/Source/Client/Windows/BootstrapRootPlayPatch.cs @@ -0,0 +1,23 @@ +using HarmonyLib; +using Verse; + +namespace Multiplayer.Client +{ + /// + /// Robust bootstrap trigger: Root_Play.Start is called when the game fully transitions into Playing. + /// This is a better signal than ExecuteWhenFinished which can run before ProgramState switches. + /// + [HarmonyPatch(typeof(Root_Play), nameof(Root_Play.Start))] + static class BootstrapRootPlayPatch + { + static void Postfix() + { + var inst = BootstrapConfiguratorWindow.Instance; + if (inst == null) + return; + + // If bootstrap flow is active, this is the perfect time to arm map init. + inst.TryArmAwaitingBootstrapMapInit_FromRootPlay(); + } + } +} From 467c03525d71919d5e7a7349c48dcb1e79891e4f Mon Sep 17 00:00:00 2001 From: MhaWay Date: Mon, 29 Dec 2025 17:53:06 +0300 Subject: [PATCH 17/28] Client(patches): add BootstrapRootPlayUpdatePatch (Root_Play.Update hook) --- .../Windows/BootstrapRootPlayUpdatePatch.cs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 Source/Client/Windows/BootstrapRootPlayUpdatePatch.cs diff --git a/Source/Client/Windows/BootstrapRootPlayUpdatePatch.cs b/Source/Client/Windows/BootstrapRootPlayUpdatePatch.cs new file mode 100644 index 00000000..88d20675 --- /dev/null +++ b/Source/Client/Windows/BootstrapRootPlayUpdatePatch.cs @@ -0,0 +1,33 @@ +using HarmonyLib; +using Verse; + +namespace Multiplayer.Client +{ + /// + /// Reliable bootstrap arming: Root_Play.Update runs through the whole transition from MapInitializing to Playing. + /// We use it to arm the map-init (FinalizeInit) trigger as soon as a map exists and ProgramState is Playing. + /// + [HarmonyPatch(typeof(Root_Play), nameof(Root_Play.Update))] + static class BootstrapRootPlayUpdatePatch + { + // Throttle checks to avoid per-frame overhead. + private static int nextCheckFrame; + private const int CheckEveryFrames = 10; + + static void Postfix() + { + // Only run while the bootstrap window exists. + var win = BootstrapConfiguratorWindow.Instance; + if (win == null) + return; + + // Throttle. + if (UnityEngine.Time.frameCount < nextCheckFrame) + return; + nextCheckFrame = UnityEngine.Time.frameCount + CheckEveryFrames; + + // Once we're playing and have a map, arm the FinalizeInit-based save flow. + win.TryArmAwaitingBootstrapMapInit_FromRootPlayUpdate(); + } + } +} From c4c805055a0635329f19774c286236a2491191fa Mon Sep 17 00:00:00 2001 From: MhaWay Date: Mon, 29 Dec 2025 17:53:14 +0300 Subject: [PATCH 18/28] Client(patches): add BootstrapStartedNewGamePatch (StartedNewGame hook) --- .../Windows/BootstrapStartedNewGamePatch.cs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 Source/Client/Windows/BootstrapStartedNewGamePatch.cs diff --git a/Source/Client/Windows/BootstrapStartedNewGamePatch.cs b/Source/Client/Windows/BootstrapStartedNewGamePatch.cs new file mode 100644 index 00000000..10cfeb7c --- /dev/null +++ b/Source/Client/Windows/BootstrapStartedNewGamePatch.cs @@ -0,0 +1,37 @@ +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. + /// Use it as a 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) + { + UnityEngine.Debug.Log("[Bootstrap] StartedNewGame called but bootstrap window not present"); + return; + } + + // Arm bootstrap map init regardless of previous timing. + BootstrapConfiguratorWindow.AwaitingBootstrapMapInit = true; + + // Run on main thread: close landing popups and proceed to save pipeline. + OnMainThread.Enqueue(() => + { + window.TryClearStartingLettersOnce(); + window.TryCloseLandingDialogsOnce(); + window.OnBootstrapMapInitialized(); + }); + + UnityEngine.Debug.Log("[Bootstrap] StartedNewGame: armed + cleanup queued"); + } + } +} From c034dd9a6146c12e31553928943a43642dd46b24 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Mon, 29 Dec 2025 21:51:33 +0300 Subject: [PATCH 19/28] Bootstrap server flow: protocol, state machine, and upload logic. Minimal required changes only. --- .../Networking/State/ClientJoiningState.cs | 25 ++++---- Source/Client/Session/MultiplayerSession.cs | 8 ++- .../Common/Networking/ConnectionStateEnum.cs | 1 + Source/Common/Networking/NetworkingLiteNet.cs | 33 +++++++++-- .../Networking/Packet/BootstrapPacket.cs | 4 +- .../Networking/State/ServerBootstrapState.cs | 59 ++++++++++++++----- .../Networking/State/ServerJoiningState.cs | 18 +++++- .../Networking/State/ServerLoadingState.cs | 9 ++- Source/Common/PlayerManager.cs | 5 +- Source/Common/ServerPlayer.cs | 3 +- Source/Server/Server.cs | 25 ++++++-- 11 files changed, 145 insertions(+), 45 deletions(-) diff --git a/Source/Client/Networking/State/ClientJoiningState.cs b/Source/Client/Networking/State/ClientJoiningState.cs index bee508b2..d8962dc6 100644 --- a/Source/Client/Networking/State/ClientJoiningState.cs +++ b/Source/Client/Networking/State/ClientJoiningState.cs @@ -10,7 +10,9 @@ namespace Multiplayer.Client { - [PacketHandlerClass(inheritHandlers: false)] + // We want to inherit the shared typed packet handlers from ClientBaseState (keepalive, time control, disconnect). + // Disabling inheritance can cause missing core handlers during joining and lead to early disconnects / broken UI. + [PacketHandlerClass(inheritHandlers: true)] public class ClientJoiningState : ClientBaseState { public ClientJoiningState(ConnectionBase connection) : base(connection) @@ -24,19 +26,9 @@ public void HandleBootstrap(ServerBootstrapPacket packet) // Full UI/flow is handled on the client side; for now we just persist the flag // so receiving the packet doesn't error during join (tests rely on this). Multiplayer.session.serverIsInBootstrap = packet.bootstrap; + Multiplayer.session.serverBootstrapSettingsMissing = packet.settingsMissing; } - [TypedPacketHandler] - public void HandleBootstrapFlag(ServerBootstrapPacket packet) - { - // Some codepaths (tests included) can receive the bootstrap flag while still in joining. - // Keep it lenient: store the info and continue the normal join flow. - Multiplayer.session.serverIsInBootstrap = packet.bootstrap; - } - - [TypedPacketHandler] - public new void HandleDisconnected(ServerDisconnectPacket packet) => base.HandleDisconnected(packet); - public override void StartState() { connection.Send(ClientProtocolPacket.Current()); @@ -139,6 +131,15 @@ void Complete() void StartDownloading() { + if (Multiplayer.session.serverIsInBootstrap) + { + // Server is in bootstrap/configuration mode: don't request world data. + // Instead, show a dedicated configuration UI. + 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/Session/MultiplayerSession.cs b/Source/Client/Session/MultiplayerSession.cs index bb4ef466..735d7581 100644 --- a/Source/Client/Session/MultiplayerSession.cs +++ b/Source/Client/Session/MultiplayerSession.cs @@ -14,9 +14,10 @@ namespace Multiplayer.Client { public class MultiplayerSession : IConnectionStatusListener { - public string gameName; public int playerId; + public string gameName; + public int receivedCmds; public int remoteTickUntil; public int remoteSentCmds; @@ -56,8 +57,9 @@ 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; + // 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() { diff --git a/Source/Common/Networking/ConnectionStateEnum.cs b/Source/Common/Networking/ConnectionStateEnum.cs index f3f1521b..cf9df2cb 100644 --- a/Source/Common/Networking/ConnectionStateEnum.cs +++ b/Source/Common/Networking/ConnectionStateEnum.cs @@ -4,6 +4,7 @@ public enum ConnectionStateEnum : byte { ClientJoining, ClientLoading, + ClientBootstrap, ClientPlaying, ClientSteam, diff --git a/Source/Common/Networking/NetworkingLiteNet.cs b/Source/Common/Networking/NetworkingLiteNet.cs index 717326f1..c561b0bc 100644 --- a/Source/Common/Networking/NetworkingLiteNet.cs +++ b/Source/Common/Networking/NetworkingLiteNet.cs @@ -21,12 +21,16 @@ public void OnConnectionRequest(ConnectionRequest req) public void OnPeerConnected(NetPeer peer) { var conn = new LiteNetConnection(peer); - conn.ChangeState(server.BootstrapMode - ? ConnectionStateEnum.ServerBootstrap - : ConnectionStateEnum.ServerJoining); - peer.SetConnection(conn); + // The connection state constructors (and StartState) often rely on connection.serverPlayer / Player.id. + // Ensure the ServerPlayer is created before we enter any server state. var player = server.playerManager.OnConnected(conn); + + // Always start with the standard joining handshake (protocol/username/join-data). + // ServerJoiningState already sends ServerBootstrapPacket early when BootstrapMode is enabled, + // so a configurator client can switch UI flows without us skipping the handshake. + conn.ChangeState(ConnectionStateEnum.ServerJoining); + peer.SetConnection(conn); if (arbiter) { player.type = PlayerType.Arbiter; @@ -53,13 +57,30 @@ public void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo) public void OnNetworkLatencyUpdate(NetPeer peer, int latency) { - peer.GetConnection().Latency = latency; + // LiteNetLib can emit latency updates very early or during shutdown. + // At that time the NetPeer might not yet have our ConnectionBase attached. + var conn = peer.GetConnection(); + if (conn == null) + return; + + conn.Latency = latency; } public void OnNetworkReceive(NetPeer peer, NetPacketReader reader, byte channelNumber, DeliveryMethod method) { byte[] data = reader.GetRemainingBytes(); - peer.GetConnection().serverPlayer.HandleReceive(new ByteReader(data), method == DeliveryMethod.ReliableOrdered); + + var conn = peer.GetConnection(); + var player = conn?.serverPlayer; + if (player == null) + { + // Shouldn't normally happen because we create the ServerPlayer before changing state, + // but guard anyway to avoid taking down the server tick. + ServerLog.Error($"Received packet from peer without a bound ServerPlayer ({peer}). Dropping {data.Length} bytes"); + return; + } + + player.HandleReceive(new ByteReader(data), method == DeliveryMethod.ReliableOrdered); } public void OnNetworkError(IPEndPoint endPoint, SocketError socketError) { } diff --git a/Source/Common/Networking/Packet/BootstrapPacket.cs b/Source/Common/Networking/Packet/BootstrapPacket.cs index 2eacbbd5..7146ef83 100644 --- a/Source/Common/Networking/Packet/BootstrapPacket.cs +++ b/Source/Common/Networking/Packet/BootstrapPacket.cs @@ -6,12 +6,14 @@ namespace Multiplayer.Common.Networking.Packet; /// and the client should enter the configuration flow instead of normal join. /// [PacketDefinition(Packets.Server_Bootstrap)] -public record struct ServerBootstrapPacket(bool bootstrap) : IPacket +public record struct ServerBootstrapPacket(bool bootstrap, bool settingsMissing = false) : IPacket { public bool bootstrap = bootstrap; + public bool settingsMissing = settingsMissing; public void Bind(PacketBuffer buf) { buf.Bind(ref bootstrap); + buf.Bind(ref settingsMissing); } } diff --git a/Source/Common/Networking/State/ServerBootstrapState.cs b/Source/Common/Networking/State/ServerBootstrapState.cs index 77c331f1..14eaa5a1 100644 --- a/Source/Common/Networking/State/ServerBootstrapState.cs +++ b/Source/Common/Networking/State/ServerBootstrapState.cs @@ -13,8 +13,8 @@ namespace Multiplayer.Common; /// public class ServerBootstrapState(ConnectionBase conn) : MpConnectionState(conn) { - // Only one configurator at a time. - private static int? configuratorPlayerId; + // Only one configurator at a time (always playerId=0 in bootstrap) + private static bool configuratorActive; private const int MaxSettingsTomlBytes = 64 * 1024; @@ -38,15 +38,17 @@ public override void StartState() } // If someone already is configuring, keep this connection idle. - if (configuratorPlayerId != null && configuratorPlayerId != Player.id) + if (configuratorActive) { // Still tell them we're in bootstrap, so clients can show a helpful UI. - connection.Send(new ServerBootstrapPacket(true)); + var settingsMissing = !File.Exists(Path.Combine(AppContext.BaseDirectory, "settings.toml")); + connection.Send(new ServerBootstrapPacket(true, settingsMissing)); return; } - configuratorPlayerId = Player.id; - connection.Send(new ServerBootstrapPacket(true)); + configuratorActive = true; + var settingsMissing2 = !File.Exists(Path.Combine(AppContext.BaseDirectory, "settings.toml")); + connection.Send(new ServerBootstrapPacket(true, settingsMissing2)); var settingsPath = Path.Combine(AppContext.BaseDirectory, "settings.toml"); var savePath = Path.Combine(AppContext.BaseDirectory, "save.zip"); @@ -61,11 +63,11 @@ public override void StartState() public override void OnDisconnect() { - if (configuratorPlayerId == Player.id) + if (configuratorActive && Player.id == 0) { ServerLog.Log("Bootstrap: configurator disconnected; returning to waiting state."); ResetUploadState(); - configuratorPlayerId = null; + configuratorActive = false; } } @@ -102,8 +104,23 @@ public void HandleSettingsUploadData(ClientBootstrapSettingsUploadDataPacket pac if (File.Exists(settingsPath)) return; - pendingSettingsBytes = packet.data; - ServerLog.Log($"Bootstrap: settings upload data received ({pendingSettingsBytes?.Length ?? 0} bytes)"); + // Accumulate fragmented upload data + if (pendingSettingsBytes == null) + { + pendingSettingsBytes = packet.data; + } + else + { + // Append new chunk to existing data + var oldLen = pendingSettingsBytes.Length; + var newChunk = packet.data; + var combined = new byte[oldLen + newChunk.Length]; + Buffer.BlockCopy(pendingSettingsBytes, 0, combined, 0, oldLen); + Buffer.BlockCopy(newChunk, 0, combined, oldLen, newChunk.Length); + pendingSettingsBytes = combined; + } + + ServerLog.Log($"Bootstrap: settings upload data received ({packet.data?.Length ?? 0} bytes, total: {pendingSettingsBytes?.Length ?? 0}/{pendingSettingsLength})"); } [TypedPacketHandler] @@ -173,9 +190,23 @@ public void HandleUploadData(ClientBootstrapUploadDataPacket packet) if (!IsConfigurator()) return; - // Expect the full zip bytes in this packet (delivered fragmented). - pendingZipBytes = packet.data; - ServerLog.Log($"Bootstrap: upload data received ({pendingZipBytes?.Length ?? 0} bytes)"); + // Accumulate fragmented upload data + if (pendingZipBytes == null) + { + pendingZipBytes = packet.data; + } + else + { + // Append new chunk to existing data + var oldLen = pendingZipBytes.Length; + var newChunk = packet.data; + var combined = new byte[oldLen + newChunk.Length]; + Buffer.BlockCopy(pendingZipBytes, 0, combined, 0, oldLen); + Buffer.BlockCopy(newChunk, 0, combined, oldLen, newChunk.Length); + pendingZipBytes = combined; + } + + ServerLog.Log($"Bootstrap: upload data received ({packet.data?.Length ?? 0} bytes, total: {pendingZipBytes?.Length ?? 0}/{pendingLength})"); } [TypedPacketHandler] @@ -221,7 +252,7 @@ public void HandleUploadFinish(ClientBootstrapUploadFinishPacket packet) Server.running = false; } - private bool IsConfigurator() => configuratorPlayerId == Player.id; + private bool IsConfigurator() => configuratorActive && Player.id == 0; private static void ResetUploadState() { diff --git a/Source/Common/Networking/State/ServerJoiningState.cs b/Source/Common/Networking/State/ServerJoiningState.cs index 8c60de2c..1c19cdfb 100644 --- a/Source/Common/Networking/State/ServerJoiningState.cs +++ b/Source/Common/Networking/State/ServerJoiningState.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using System.IO; using Multiplayer.Common.Networking.Packet; namespace Multiplayer.Common; @@ -24,6 +25,14 @@ protected override async Task RunState() if (!HandleClientJoinData(await TypedPacket())) return; + // In bootstrap mode we only need the handshake (protocol/username/join data) so the client can stay connected + // and upload settings/save. We must NOT proceed into world loading / playing states. + if (Server.BootstrapMode) + { + connection.ChangeState(ConnectionStateEnum.ServerBootstrap); + return; + } + if (Server.settings.pauseOnJoin) Server.commands.PauseAll(); @@ -48,7 +57,11 @@ private void HandleProtocol(ClientProtocolPacket packet) // Let the client know early when the server is in bootstrap mode so it can switch // to server-configuration flow while keeping the connection open. - Player.conn.Send(new ServerBootstrapPacket(Server.BootstrapMode)); + var settingsMissing = false; + if (Server.BootstrapMode) + settingsMissing = !File.Exists(Path.Combine(AppContext.BaseDirectory, "settings.toml")); + + Player.conn.Send(new ServerBootstrapPacket(Server.BootstrapMode, settingsMissing)); } } @@ -142,7 +155,8 @@ private bool HandleClientJoinData(ClientJoinDataPacket packet) connection.SendFragmented(new ServerJoinDataPacket { - gameName = Server.settings.gameName, + // During bootstrap there may be no settings.toml, so ensure we never serialize a null string + gameName = Server.settings.gameName ?? string.Empty, playerId = Player.id, rwVersion = serverInitData.RwVersion, mpVersion = MpVersion.Version, diff --git a/Source/Common/Networking/State/ServerLoadingState.cs b/Source/Common/Networking/State/ServerLoadingState.cs index ab42084f..cad8190c 100644 --- a/Source/Common/Networking/State/ServerLoadingState.cs +++ b/Source/Common/Networking/State/ServerLoadingState.cs @@ -33,6 +33,9 @@ public void SendWorldData() writer.WritePrefixedBytes(Server.worldData.savedGame); writer.WritePrefixedBytes(Server.worldData.sessionData); + ServerLog.Detail($"SendWorldData: worldData.savedGame = {Server.worldData.savedGame.Length} bytes, sessionData = {Server.worldData.sessionData.Length} bytes"); + ServerLog.Detail($"SendWorldData: mapCmds entries = {Server.worldData.mapCmds.Count}, mapData entries = {Server.worldData.mapData.Count}"); + writer.WriteInt32(Server.worldData.mapCmds.Count); foreach (var kv in Server.worldData.mapCmds) @@ -48,6 +51,8 @@ public void SendWorldData() writer.WriteInt32(mapCmds.Count); foreach (var arr in mapCmds) writer.WritePrefixedBytes(arr); + + ServerLog.Detail($"SendWorldData: sent mapCmds[{mapId}] = {mapCmds.Count} commands"); } writer.WriteInt32(Server.worldData.mapData.Count); @@ -59,6 +64,8 @@ public void SendWorldData() writer.WriteInt32(mapId); writer.WritePrefixedBytes(mapData); + + ServerLog.Detail($"SendWorldData: sent mapData[{mapId}] = {mapData.Length} bytes"); } writer.WriteInt32(Server.worldData.syncInfos.Count); @@ -68,6 +75,6 @@ public void SendWorldData() byte[] packetData = writer.ToArray(); connection.SendFragmented(Packets.Server_WorldData, packetData); - ServerLog.Log("World response sent: " + packetData.Length); + ServerLog.Log("World response sent: " + packetData.Length + " bytes"); } } diff --git a/Source/Common/PlayerManager.cs b/Source/Common/PlayerManager.cs index acc55e7e..6ca292d9 100644 --- a/Source/Common/PlayerManager.cs +++ b/Source/Common/PlayerManager.cs @@ -55,7 +55,10 @@ public ServerPlayer OnConnected(ConnectionBase conn) if (conn.serverPlayer != null) ServerLog.Error($"Connection {conn} already has a server player"); - conn.serverPlayer = new ServerPlayer(nextPlayerId++, conn); + // In bootstrap mode, always use playerId=0 for simplicity (single configurator) + int assignedId = server.BootstrapMode ? 0 : nextPlayerId++; + + conn.serverPlayer = new ServerPlayer(assignedId, conn); Players.Add(conn.serverPlayer); ServerLog.Log($"New connection: {conn}"); diff --git a/Source/Common/ServerPlayer.cs b/Source/Common/ServerPlayer.cs index 6584661b..4892b83f 100644 --- a/Source/Common/ServerPlayer.cs +++ b/Source/Common/ServerPlayer.cs @@ -57,7 +57,8 @@ public void HandleReceive(ByteReader data, bool reliable) } catch (Exception e) { - ServerLog.Error($"Error handling packet by {conn}: {e}"); + // Include state to make packet/state mismatches easier to diagnose. + ServerLog.Error($"Error handling packet by {conn} (state={conn.State}): {e}"); Disconnect(MpDisconnectReason.ServerPacketRead); } } diff --git a/Source/Server/Server.cs b/Source/Server/Server.cs index 38fb7c7b..3099cf8d 100644 --- a/Source/Server/Server.cs +++ b/Source/Server/Server.cs @@ -107,6 +107,10 @@ static void LoadSave(MultiplayerServer server, string path) { using var zip = ZipFile.OpenRead(path); + ServerLog.Detail($"Bootstrap: loading save from {path}. Zip contains {zip.Entries.Count} entries:"); + foreach (var entry in zip.Entries) + ServerLog.Detail($" - {entry.FullName} ({entry.Length} bytes)"); + var replayInfo = ReplayInfo.Read(zip.GetBytes("info")); ServerLog.Detail($"Loading {path} saved in RW {replayInfo.rwVersion} with {replayInfo.modNames.Count} mods"); @@ -122,9 +126,12 @@ static void LoadSave(MultiplayerServer server, string path) server.startingTimer = replayInfo.sections[0].start; - server.worldData.savedGame = Compress(zip.GetBytes("world/000_save")); + var worldSaveData = zip.GetBytes("world/000_save"); + server.worldData.savedGame = Compress(worldSaveData); + ServerLog.Detail($"Bootstrap: loaded world/000_save ({worldSaveData.Length} bytes), compressed to {server.worldData.savedGame.Length} bytes"); // Parse cmds entry for each map + int mapCmdsCount = 0; foreach (var entry in zip.GetEntries("maps/*_cmds")) { var parts = entry.FullName.Split('_'); @@ -132,11 +139,15 @@ static void LoadSave(MultiplayerServer server, string path) if (parts.Length == 3) { int mapNumber = int.Parse(parts[1]); - server.worldData.mapCmds[mapNumber] = ScheduledCommand.DeserializeCmds(zip.GetBytes(entry.FullName)).Select(ScheduledCommand.Serialize).ToList(); + var cmds = ScheduledCommand.DeserializeCmds(zip.GetBytes(entry.FullName)).Select(ScheduledCommand.Serialize).ToList(); + server.worldData.mapCmds[mapNumber] = cmds; + ServerLog.Detail($"Bootstrap: loaded {entry.FullName} ({entry.Length} bytes) -> {cmds.Count} commands for map {mapNumber}"); + mapCmdsCount++; } } // Parse save entry for each map + int mapDataCount = 0; foreach (var entry in zip.GetEntries("maps/*_save")) { var parts = entry.FullName.Split('_'); @@ -144,13 +155,19 @@ static void LoadSave(MultiplayerServer server, string path) if (parts.Length == 3) { int mapNumber = int.Parse(parts[1]); - server.worldData.mapData[mapNumber] = Compress(zip.GetBytes(entry.FullName)); + var mapSaveData = zip.GetBytes(entry.FullName); + server.worldData.mapData[mapNumber] = Compress(mapSaveData); + ServerLog.Detail($"Bootstrap: loaded {entry.FullName} ({mapSaveData.Length} bytes), compressed to {server.worldData.mapData[mapNumber].Length} bytes"); + mapDataCount++; } } + var worldCmds = zip.GetBytes("world/000_cmds"); + server.worldData.mapCmds[-1] = ScheduledCommand.DeserializeCmds(worldCmds).Select(ScheduledCommand.Serialize).ToList(); + ServerLog.Detail($"Bootstrap: loaded world/000_cmds ({worldCmds.Length} bytes) -> {server.worldData.mapCmds[-1].Count} world commands"); - server.worldData.mapCmds[-1] = ScheduledCommand.DeserializeCmds(zip.GetBytes("world/000_cmds")).Select(ScheduledCommand.Serialize).ToList(); server.worldData.sessionData = Array.Empty(); + ServerLog.Detail($"Bootstrap: loaded {mapDataCount} maps with {mapCmdsCount} map command entries. SessionData is empty (vanilla save)"); } static byte[] Compress(byte[] input) From 277dc51918fed4cdf8d14590bec16ebe745770f4 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Tue, 30 Dec 2025 01:32:04 +0300 Subject: [PATCH 20/28] Registra le implementazioni mancanti per ClientBootstrap e Disconnected nella state machine del client multiplayer. Migliora la robustezza della gestione degli stati di connessione. --- Source/Client/MultiplayerStatic.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Source/Client/MultiplayerStatic.cs b/Source/Client/MultiplayerStatic.cs index 9d4d9c9c..8f36850a 100644 --- a/Source/Client/MultiplayerStatic.cs +++ b/Source/Client/MultiplayerStatic.cs @@ -87,6 +87,8 @@ 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)); + MpConnectionState.SetImplementation(ConnectionStateEnum.Disconnected, typeof(ClientDisconnectedState)); MultiplayerData.CollectCursorIcons(); From 4b15af8c12e597378040315f840cb9d10cfd0924 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Tue, 30 Dec 2025 01:32:42 +0300 Subject: [PATCH 21/28] Remove all automatic vanilla page advance logic (TryAutoAdvanceVanillaPages and related methods). The bootstrap flow no longer forces automatic tile selection or Next clicks, leaving full control to the user/manual flow. --- .../Windows/BootstrapConfiguratorWindow.cs | 423 +----------------- 1 file changed, 20 insertions(+), 403 deletions(-) diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs index 91eefeb9..2b57de8a 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -629,187 +629,6 @@ private void StartVanillaNewColonyFlow() } } - /// - /// Drives the vanilla "New colony" page flow by pressing "Random" on tile selection - /// pages and auto-advancing with "Next" until we enter Playing with a map. - /// Uses reflection to avoid hard dependencies on specific RimWorld versions / page classes. - /// - private void TryAutoAdvanceVanillaPages() - { - if (!autoAdvanceArmed) - return; - - // If we've already reached Playing with a map, stop driving pages immediately. - if (Current.ProgramState == ProgramState.Playing && Find.Maps != null && Find.Maps.Count > 0) - { - autoAdvanceArmed = false; - return; - } - - TraceSnapshotTick(); - - if (autoAdvanceDiagCooldown > 0f) - autoAdvanceDiagCooldown -= Time.deltaTime; - - // Don't start auto-advancing until the world is generated. The user can still interact - // with the scenario + world generation pages manually; we only take over after the world exists. - if (Find.World == null || Find.World.grid == null) - { - if (string.IsNullOrEmpty(saveUploadStatus) || saveUploadStatus.StartsWith("Advancing:") || saveUploadStatus.StartsWith("Entered map")) - saveUploadStatus = "Waiting for world generation..."; - - if (autoAdvanceDiagCooldown <= 0f) - { - autoAdvanceDiagCooldown = AutoAdvanceDiagCooldownSeconds; - Log.Message($"[Bootstrap] Auto-advance armed; waiting world. ProgramState={Current.ProgramState}"); - } - - Trace("WaitWorld"); - return; - } - - // World is generated: wait a small grace period before starting to press Next. - if (!worldGenDetected) - { - worldGenDetected = true; - worldGenDelayRemaining = WorldGenDelaySeconds; - Trace("WorldDetected"); - } - - if (worldGenDelayRemaining > 0f) - { - worldGenDelayRemaining -= Time.deltaTime; - saveUploadStatus = "World generated. Waiting..."; - - if (autoAdvanceDiagCooldown <= 0f) - { - autoAdvanceDiagCooldown = AutoAdvanceDiagCooldownSeconds; - Log.Message($"[Bootstrap] World detected; delaying {worldGenDelayRemaining:0.00}s before auto-next"); - } - - Trace("WorldDelay"); - return; - } - - // Stop after some time to avoid infinite looping if the UI is blocked by an error dialog. - autoAdvanceElapsed += Time.deltaTime; - if (autoAdvanceElapsed > AutoAdvanceTimeoutSeconds) - { - autoAdvanceArmed = false; - saveUploadStatus = "Auto-advance timed out. Please complete world setup manually."; - Trace("AutoAdvanceTimeout"); - return; - } - - // Once we're playing and have a map, arm the save hook and stop auto-advance. - if (Current.ProgramState == ProgramState.Playing && Find.Maps != null && Find.Maps.Count > 0) - { - if (!AwaitingBootstrapMapInit) - { - AwaitingBootstrapMapInit = true; - saveUploadStatus = "Entered map. Waiting for initialization to complete..."; - Log.Message($"[Bootstrap] Reached Playing. maps={Find.Maps.Count}, currentMap={(Find.CurrentMap != null ? Find.CurrentMap.ToString() : "")}"); - Trace("EnteredPlaying"); - } - - autoAdvanceArmed = false; - return; - } - - // Cooldowns to avoid spamming actions every frame - if (nextPressCooldown > 0f) - nextPressCooldown -= Time.deltaTime; - if (randomTileCooldown > 0f) - randomTileCooldown -= Time.deltaTime; - - // Find the top-most Page in the window stack - Page page = null; - var windows = Find.WindowStack?.Windows; - if (windows != null) - { - for (int i = windows.Count - 1; i >= 0; i--) - { - if (windows[i] is Page p) - { - page = p; - break; - } - } - } - - if (page == null) - { - if (autoAdvanceDiagCooldown <= 0f) - { - autoAdvanceDiagCooldown = AutoAdvanceDiagCooldownSeconds; - Log.Message($"[Bootstrap] Auto-advance: no Page found. ProgramState={Current.ProgramState}"); - } - - Trace("NoPage"); - return; - } - - // Some tiles prompt a confirmation dialog (e.g., harsh conditions / nearby faction). Accept it automatically. - TryAutoAcceptTileWarnings(page); - - if (autoAdvanceDiagCooldown <= 0f) - { - autoAdvanceDiagCooldown = AutoAdvanceDiagCooldownSeconds; - Log.Message($"[Bootstrap] Auto-advance on page {page.GetType().Name}; CanDoNext={CanDoNextQuick(page)}; nextCooldown={nextPressCooldown:0.00}"); - } - - // Avoid spamming page trace if we sit on the same page for multiple ticks - var curPageName = page.GetType().Name; - if (curPageName != lastPageName) - { - lastPageName = curPageName; - Trace($"Page:{curPageName}:CanNext={CanDoNextQuick(page)}"); - } - - // If we're on a starting-site selection page, try to choose a random tile by setting GameInitData.startingTile. - // This mimics "Choose random" behavior without needing to locate UI widgets. - if (randomTileCooldown <= 0f && Find.GameInitData != null && Find.World?.grid != null) - { - var typeName = page.GetType().Name; - if (typeName.IndexOf("Starting", StringComparison.OrdinalIgnoreCase) >= 0 && - (typeName.IndexOf("Site", StringComparison.OrdinalIgnoreCase) >= 0 || typeName.IndexOf("Landing", StringComparison.OrdinalIgnoreCase) >= 0)) - { - int tile = FindSuitableTile(); - Find.GameInitData.startingTile = tile; - randomTileCooldown = RandomTileCooldownSeconds; - if (Prefs.DevMode) - Log.Message($"[Bootstrap] Picked random starting tile {tile} on page {typeName}"); - } - } - - if (nextPressCooldown > 0f) - return; - - // Press Next via reflection. - if (TryInvokePageNext(page)) - { - nextPressCooldown = NextPressCooldownSeconds; - saveUploadStatus = $"Advancing: {page.GetType().Name}..."; - Trace($"DoNext:{page.GetType().Name}"); - - // If this Next starts the actual new game initialization (InitNewGame long event), - // WindowUpdate can stall for a while. Schedule a post-long-event check so we can - // reliably arm the FinalizeInit trigger once the game switches to Playing. - var pageName = page.GetType().Name; - if (pageName.IndexOf("ConfigureStartingPawns", StringComparison.OrdinalIgnoreCase) >= 0) - { - LongEventHandler.ExecuteWhenFinished(() => - { - OnMainThread.Enqueue(() => - { - Trace("PostInitNewGameCheck"); - TryArmAwaitingBootstrapMapInit("ExecuteWhenFinished"); - }); - }); - } - } - } - private void TryArmAwaitingBootstrapMapInit(string source) { // This is safe to call repeatedly. @@ -879,223 +698,26 @@ internal void TryArmAwaitingBootstrapMapInit_FromRootPlayUpdate() if (AwaitingBootstrapMapInit || postMapEnterSaveDelayRemaining > 0f || saveReady || isUploadingSave || isReconnecting) bootstrapTraceSnapshotCooldown = BootstrapTraceSnapshotSeconds; // delay next snapshot } - - private static bool? CanDoNextQuick(Page page) - { - try - { - var t = page.GetType(); - var canDoNextMethod = t.GetMethod("CanDoNext", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); - if (canDoNextMethod != null && canDoNextMethod.ReturnType == typeof(bool) && canDoNextMethod.GetParameters().Length == 0) - return (bool)canDoNextMethod.Invoke(page, null); - } - catch - { - // ignore - } - - return null; - } - - private static bool TryInvokePageNext(Page page) - { - try - { - var t = page.GetType(); - - // Common patterns across RW versions: - // - CanDoNext() + DoNext() - // - CanDoNext (property) + DoNext() - // - DoNext() only - bool canNext = true; - var canDoNextMethod = t.GetMethod("CanDoNext", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); - if (canDoNextMethod != null && canDoNextMethod.ReturnType == typeof(bool) && canDoNextMethod.GetParameters().Length == 0) - canNext = (bool)canDoNextMethod.Invoke(page, null); - - if (!canNext) - return false; - - var doNextMethod = t.GetMethod("DoNext", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); - if (doNextMethod != null && doNextMethod.GetParameters().Length == 0) - { - doNextMethod.Invoke(page, null); - return true; - } - - // Fallback: try Next() method name - var nextMethod = t.GetMethod("Next", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); - if (nextMethod != null && nextMethod.GetParameters().Length == 0) - { - nextMethod.Invoke(page, null); - return true; - } - } - catch (Exception e) - { - if (Prefs.DevMode) - Log.Warning($"[Bootstrap] Failed to invoke Next on page {page?.GetType().Name}: {e.GetType().Name}: {e.Message}"); - } - - return false; - } - - private void TryAutoAcceptTileWarnings(Page page) - { - try - { - // Only relevant on starting-site selection pages. - var typeName = page.GetType().Name; - if (typeName.IndexOf("Starting", StringComparison.OrdinalIgnoreCase) < 0) - return; - if (typeName.IndexOf("Site", StringComparison.OrdinalIgnoreCase) < 0 && typeName.IndexOf("Landing", StringComparison.OrdinalIgnoreCase) < 0) - return; - - var windows = Find.WindowStack?.Windows; - if (windows == null || windows.Count == 0) - return; - - for (int i = windows.Count - 1; i >= 0; i--) - { - if (windows[i] is Dialog_MessageBox msg) - { - // Prefer button A if present; otherwise try button B. - if (!string.IsNullOrEmpty(msg.buttonAText)) - { - msg.buttonAAction?.Invoke(); - msg.Close(true); - if (Prefs.DevMode) - Log.Message("[Bootstrap] Auto-accepted tile warning dialog (button A)"); - return; - } - - if (!string.IsNullOrEmpty(msg.buttonBText)) - { - msg.buttonBAction?.Invoke(); - msg.Close(true); - if (Prefs.DevMode) - Log.Message("[Bootstrap] Auto-accepted tile warning dialog (button B)"); - return; - } - } - } - } - catch - { - // Fail open; we don't want to block automation because of a dialog. - } - } - - internal void TryClearStartingLettersOnce() - { - if (startingLettersCleared) - return; - - var letterStack = Find.LetterStack; - var letters = letterStack?.LettersListForReading; - if (letters == null || letters.Count == 0) - return; - - // Remove from the end to avoid mutation issues. - for (int i = letters.Count - 1; i >= 0; i--) - { - var letter = letters[i]; - letterStack.RemoveLetter(letter); - } - - startingLettersCleared = true; - if (Prefs.DevMode) - Log.Message("[Bootstrap] Cleared starting letters/messages"); - } - - internal void TryCloseLandingDialogsOnce() - { - if (landingDialogsCleared) - return; - - var windows = Find.WindowStack?.Windows; - if (windows == null || windows.Count == 0) - return; - - // Close common blocking dialogs shown at landing (message boxes, node trees) - for (int i = windows.Count - 1; i >= 0; i--) - { - var w = windows[i]; - if (w is Dialog_MessageBox || w is Dialog_NodeTree) - { - try - { - w.Close(true); - } - catch { } - } - } - - landingDialogsCleared = true; - if (Prefs.DevMode) - Log.Message("[Bootstrap] Closed landing dialogs (message boxes / node trees)"); - } - private int FindSuitableTile() + public void OnBootstrapMapInitialized() { - var world = Find.World; - var grid = world.grid; - - // Try to find a temperate, flat tile without extreme conditions - for (int i = 0; i < grid.TilesCount; i++) - { - var tile = grid[i]; - - // Skip water tiles - if (tile.biome.canBuildBase == false) - continue; - - // Skip tiles with settlements or world objects - if (Find.WorldObjects.AnyWorldObjectAt(i)) - continue; - - // Prefer temperate, flat tiles - if (tile.hilliness == Hilliness.Flat || tile.hilliness == Hilliness.SmallHills) - { - // Check temperature (tile.temperature is the annual average) - if (tile.temperature > -10f && tile.temperature < 40f) - return i; - } - } - - // Fallback: find any buildable tile - for (int i = 0; i < grid.TilesCount; i++) - { - var tile = grid[i]; - if (tile.biome.canBuildBase && !Find.WorldObjects.AnyWorldObjectAt(i)) - return i; - } + if (!AwaitingBootstrapMapInit) + return; - // Last resort: use tile 0 (should never happen) - return 0; - } + AwaitingBootstrapMapInit = false; + // Wait a bit after entering the map before saving, to let final UI/world settle. + postMapEnterSaveDelayRemaining = PostMapEnterSaveDelaySeconds; + awaitingControllablePawns = true; + awaitingControllablePawnsElapsed = 0f; + bootstrapSaveQueued = false; + saveUploadStatus = "Map initialized. Waiting before saving..."; + Trace("FinalizeInit"); - public void OnBootstrapMapInitialized() - { - if (!AwaitingBootstrapMapInit) - return; - - AwaitingBootstrapMapInit = false; - - // Wait a bit after entering the map before saving, to let final UI/world settle. - postMapEnterSaveDelayRemaining = PostMapEnterSaveDelaySeconds; - awaitingControllablePawns = true; - awaitingControllablePawnsElapsed = 0f; - bootstrapSaveQueued = false; - saveUploadStatus = "Map initialized. Waiting before saving..."; - - Trace("FinalizeInit"); - - if (Prefs.DevMode) - Log.Message("[Bootstrap] Map initialized, waiting for controllable pawns before saving"); - - // Saving is driven by a tick loop (WindowUpdate + BootstrapCoordinator + Root_Play.Update). - // Do not assume WindowUpdate keeps ticking during/after long events. - } + if (Prefs.DevMode) + Log.Message("[Bootstrap] Map initialized, waiting for controllable pawns before saving"); + // Saving is driven by a tick loop (WindowUpdate + BootstrapCoordinator + Root_Play.Update). + // Do not assume WindowUpdate keeps ticking during/after long events. + } private void TickPostMapEnterSaveDelayAndMaybeSave() { @@ -1107,10 +729,6 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() if (postMapEnterSaveDelayRemaining <= 0f) return; - // Clear initial letters/messages that can appear right after landing. - TryClearStartingLettersOnce(); - TryCloseLandingDialogsOnce(); - TraceSnapshotTick(); // Drive the post-map delay. Use real time, not game ticks; during map init we still want @@ -1188,17 +806,19 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() try { // 1. Host multiplayer game on random free port (avoid collisions with user's server) + int freePort = HostWindow.GetFreeUdpPort(); var hostSettings = new ServerSettings { gameName = "BootstrapHost", maxPlayers = 2, direct = true, + directPort = freePort, + directAddress = $"0.0.0.0:{freePort}", lan = false, steam = false, - // directAddress will be set by HostProgrammatically to a free port }; - bool hosted = HostWindow.HostProgrammatically(hostSettings, file: null, randomDirectPort: true); + bool hosted = HostWindow.HostProgrammatically(hostSettings, file: null, randomDirectPort: false); if (!hosted) { OnMainThread.Enqueue(() => @@ -1295,9 +915,6 @@ public override void WindowUpdate() if (isReconnecting) CheckReconnectionState(); - - // Drive the vanilla page flow automatically (random tile + next) - TryAutoAdvanceVanillaPages(); } /// From 6c5a946f4e01e55ae11b966bf7198e5c0ddbc6f1 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Tue, 30 Dec 2025 01:32:52 +0300 Subject: [PATCH 22/28] Remove automatic closing of landing popups and letters after map generation in bootstrap. The save pipeline now starts without forcing vanilla dialog closure. --- Source/Client/Windows/BootstrapStartedNewGamePatch.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/Source/Client/Windows/BootstrapStartedNewGamePatch.cs b/Source/Client/Windows/BootstrapStartedNewGamePatch.cs index 10cfeb7c..05572321 100644 --- a/Source/Client/Windows/BootstrapStartedNewGamePatch.cs +++ b/Source/Client/Windows/BootstrapStartedNewGamePatch.cs @@ -26,8 +26,6 @@ static void Postfix() // Run on main thread: close landing popups and proceed to save pipeline. OnMainThread.Enqueue(() => { - window.TryClearStartingLettersOnce(); - window.TryCloseLandingDialogsOnce(); window.OnBootstrapMapInitialized(); }); From 316144fcfaff1193c5367df82fc96f252c46d791 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Tue, 30 Dec 2025 01:33:03 +0300 Subject: [PATCH 23/28] Add GetFreeUdpPort and update HostProgrammatically to allow hosting on a random free port for bootstrap flows. Improves port management and prevents conflicts during automatic hosting. --- Source/Client/Windows/HostWindow.cs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Source/Client/Windows/HostWindow.cs b/Source/Client/Windows/HostWindow.cs index 44926bca..8a846361 100644 --- a/Source/Client/Windows/HostWindow.cs +++ b/Source/Client/Windows/HostWindow.cs @@ -19,6 +19,14 @@ namespace Multiplayer.Client [StaticConstructorOnStartup] public class HostWindow : Window { + // Restituisce una porta UDP libera + public static int GetFreeUdpPort() + { + var udp = new System.Net.Sockets.UdpClient(0); + int port = ((IPEndPoint)udp.Client.LocalEndPoint).Port; + udp.Close(); + return port; + } enum Tab { Connecting, Gameplay @@ -603,5 +611,26 @@ private void HostFromReplay(ServerSettings settings) ReplayLoaded(); } } + /// + /// Avvia l'hosting programmaticamente per il flusso bootstrap. + /// + public static bool HostProgrammatically(ServerSettings overrides, SaveFile file = null, bool randomDirectPort = true) + { + var settings = MpUtil.ShallowCopy(overrides, new ServerSettings()); + if (randomDirectPort) + settings.directPort = GetFreeUdpPort(); + + if (!TryStartLocalServer(settings)) + return false; + + if (file?.replay ?? Multiplayer.IsReplay) + new HostWindow(file).HostFromReplay(settings); + else if (file == null) + new HostWindow().HostFromSpIngame(settings); + else + new HostWindow(file).HostFromSpSaveFile(settings); + + return true; + } } } From c9116b93272e0ecd3aa6998792ebc582e2757f75 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Tue, 30 Dec 2025 01:33:11 +0300 Subject: [PATCH 24/28] Fix state implementation and handler array sizing to include Disconnected state. Prevents out-of-bounds errors and ensures all connection states are properly registered. --- Source/Common/Networking/MpConnectionState.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/Common/Networking/MpConnectionState.cs b/Source/Common/Networking/MpConnectionState.cs index 5aa53b31..24d61199 100644 --- a/Source/Common/Networking/MpConnectionState.cs +++ b/Source/Common/Networking/MpConnectionState.cs @@ -25,10 +25,10 @@ public virtual void OnDisconnect() public virtual PacketHandlerInfo? GetPacketHandler(Packets id) => packetHandlers[(int)connection.State, (int)id]; - public static Type[] stateImpls = new Type[(int)ConnectionStateEnum.Count]; + public static Type[] stateImpls = new Type[(int)ConnectionStateEnum.Disconnected + 1]; private static PacketHandlerInfo?[,] packetHandlers = - new PacketHandlerInfo?[(int)ConnectionStateEnum.Count, (int)Packets.Count]; + new PacketHandlerInfo?[(int)ConnectionStateEnum.Disconnected + 1, (int)Packets.Count]; public static void SetImplementation(ConnectionStateEnum state, Type type) { From d40c22422a91b530aad73024a7621002702e8904 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Tue, 30 Dec 2025 01:33:20 +0300 Subject: [PATCH 25/28] Add directPort field to ServerSettings and initialize to default. Allows explicit configuration of direct port for multiplayer hosting. --- Source/Common/ServerSettings.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Source/Common/ServerSettings.cs b/Source/Common/ServerSettings.cs index 987378e7..d4fa633c 100644 --- a/Source/Common/ServerSettings.cs +++ b/Source/Common/ServerSettings.cs @@ -9,7 +9,8 @@ public class ServerSettings public string gameName; public string lanAddress; - public string directAddress = $"0.0.0.0:{MultiplayerServer.DefaultPort}"; + public string directAddress = $"0.0.0.0:{MultiplayerServer.DefaultPort}"; + public int directPort = MultiplayerServer.DefaultPort; public int maxPlayers = 8; public float autosaveInterval = 1f; public AutosaveUnit autosaveUnit; From fa3eabd19547d49d269055b956114aa4963ae002 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Tue, 30 Dec 2025 01:33:35 +0300 Subject: [PATCH 26/28] Add ClientDisconnectedState as a placeholder for the disconnected client connection state. Required for robust state machine handling. --- .../Client/Networking/State/ClientDisconnectedState.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 Source/Client/Networking/State/ClientDisconnectedState.cs diff --git a/Source/Client/Networking/State/ClientDisconnectedState.cs b/Source/Client/Networking/State/ClientDisconnectedState.cs new file mode 100644 index 00000000..8f3c2f2e --- /dev/null +++ b/Source/Client/Networking/State/ClientDisconnectedState.cs @@ -0,0 +1,10 @@ +using Multiplayer.Common; + +namespace Multiplayer.Client; + +/// +/// Stato client per connessione disconnessa. Non fa nulla, serve solo come placeholder. +/// +public class ClientDisconnectedState(ConnectionBase connection) : ClientBaseState(connection) +{ +} \ No newline at end of file From 43845c7ffb6e27fd07e6bfe9bc1222a282382bf1 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Sun, 11 Jan 2026 21:00:28 +0300 Subject: [PATCH 27/28] Fix bootstrap UI workflow state management - Arm AwaitingBootstrapMapInit flag in StartVanillaNewColonyFlow to ensure MapComponentUtility.FinalizeInit patch triggers in both TOML creation and pre-existing scenarios - Fix save.zip upload to use reconnectingConn instead of stale connection after bootstrap reconnection - Harden colonist detection with proper wait status message and debug logging - Ensure bootstrap map initialization hook fires reliably for both workflow paths This fixes Scenario 2 (TOML creation) where OnBootstrapMapInitialized was never called, blocking colonist detection and save upload. --- .../Windows/BootstrapConfiguratorWindow.cs | 98 ++++++++++++------- 1 file changed, 64 insertions(+), 34 deletions(-) diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs index 2b57de8a..c5fa204e 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Text; using System.Security.Cryptography; using Multiplayer.Client.Comp; @@ -197,10 +198,9 @@ public override void DoWindowContents(Rect inRect) Text.Font = GameFont.Small; var infoRect = headerRect.BottomPartPixels(80f); - var info = - "The server is running in bootstrap mode (no settings.toml and/or save.zip).\n" + - "Fill out the settings below to generate a complete settings.toml.\n" + - "After applying settings, you'll upload save.zip in the next step."; + var info = "The server is running in bootstrap mode (no settings.toml and/or save.zip).\n" + + "Fill out the settings below to generate a complete settings.toml.\n" + + "After applying settings, you'll upload save.zip in the next step."; Widgets.Label(infoRect, info); Rect leftRect; @@ -229,6 +229,7 @@ private void DrawGenerateMap(Rect leftRect, Rect rightRect) Widgets.DrawMenuSection(leftRect); var left = leftRect.ContractedBy(10f); + Text.Font = GameFont.Medium; Widgets.Label(left.TopPartPixels(32f), "Server settings configured"); Text.Font = GameFont.Small; @@ -238,7 +239,7 @@ private void DrawGenerateMap(Rect leftRect, Rect rightRect) GUI.color = new Color(1f, 0.85f, 0.5f); // Warning yellow 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); @@ -250,12 +251,7 @@ private void DrawGenerateMap(Rect leftRect, Rect rightRect) Text.Font = GameFont.Small; Widgets.Label(new Rect(left.x, noticeRect.yMax + 10f, left.width, 110f), - "Click 'Generate map' to automatically create a world and settlement.\n" + - "The process will:\n" + - "1) Start vanilla world generation (you'll see the scenario/world pages)\n" + - "2) After you complete world setup, automatically select a suitable tile\n" + - "3) Generate a colony map and host a temporary multiplayer session\n" + - "4) Save the game as a replay and upload save.zip to the server"); + "After the save is uploaded, the server will automatically shut down. You will need to restart the server manually to complete the setup."); // Hide the 'Generate map' button once the vanilla generation flow has started var btn = new Rect(left.x, noticeRect.yMax + 130f, 200f, 40f); @@ -414,7 +410,7 @@ void Header(string label) { var r = Row(); TooltipHandler.TipRegion(r, "Enable multi-faction play."); - CheckboxLabeled(r, "Multifaction", ref settings.multifaction); + CheckboxLabeled(r, "Multi-faction", ref settings.multifaction); y += RowHeight; Gap(); } @@ -432,7 +428,7 @@ void Header(string label) { var r = Row(); TooltipHandler.TipRegion(r, "When clients automatically join (flags). Stored as a string in TOML."); - TextFieldLabeled(r, "Auto join point (flags)", ref settings.autoJoinPoint); + TextFieldLabeled(r, "When clients automatically join (flags). Stored as a string in TOML.", ref settings.autoJoinPoint); y += RowHeight; Gap(); } @@ -441,17 +437,17 @@ void Header(string label) { var r = Row(); TooltipHandler.TipRegion(r, "When to automatically pause on letters."); - EnumDropdownLabeled(r, "Pause on letter", settings.pauseOnLetter, v => settings.pauseOnLetter = v); + EnumDropdownLabeled(r, "When to automatically pause on letters.", settings.pauseOnLetter, v => settings.pauseOnLetter = v); y += RowHeight; r = Row(); TooltipHandler.TipRegion(r, "Pause when a player joins."); - CheckboxLabeled(r, "Pause on join", ref settings.pauseOnJoin); + CheckboxLabeled(r, "Pause when a player joins.", ref settings.pauseOnJoin); y += RowHeight; r = Row(); TooltipHandler.TipRegion(r, "Pause on desync."); - CheckboxLabeled(r, "Pause on desync", ref settings.pauseOnDesync); + CheckboxLabeled(r, "Pause on desync.", ref settings.pauseOnDesync); y += RowHeight; Gap(); } @@ -462,22 +458,22 @@ void Header(string label) { var r = Row(); TooltipHandler.TipRegion(r, "Enable debug mode."); - CheckboxLabeled(r, "Debug mode", ref settings.debugMode); + CheckboxLabeled(r, "Enable debug mode.", ref settings.debugMode); y += RowHeight; r = Row(); TooltipHandler.TipRegion(r, "Include desync traces to help debugging."); - CheckboxLabeled(r, "Desync traces", ref settings.desyncTraces); + CheckboxLabeled(r, "Include desync traces to help debugging.", ref settings.desyncTraces); y += RowHeight; r = Row(); TooltipHandler.TipRegion(r, "Sync mod configs to clients."); - CheckboxLabeled(r, "Sync configs", ref settings.syncConfigs); + CheckboxLabeled(r, "Sync mod configs to clients.", ref settings.syncConfigs); y += RowHeight; r = Row(); TooltipHandler.TipRegion(r, "Dev mode scope."); - EnumDropdownLabeled(r, "Dev mode scope", settings.devModeScope, v => settings.devModeScope = v); + EnumDropdownLabeled(r, "Dev mode scope.", settings.devModeScope, v => settings.devModeScope = v); y += RowHeight; Gap(); } @@ -487,7 +483,7 @@ void Header(string label) { var r = Row(); TooltipHandler.TipRegion(r, "Arbiter is not supported in standalone server."); - CheckboxLabeled(r, "Arbiter (unsupported)", ref settings.arbiter); + CheckboxLabeled(r, "Arbiter is not supported in standalone server.", ref settings.arbiter); y += RowHeight; } @@ -597,6 +593,13 @@ private void StartVanillaNewColonyFlow() // Ensure InitData exists for the page flow; RimWorld uses this heavily during new game setup. Current.Game ??= new Game(); Current.Game.InitData ??= new GameInitData { startedFromEntry = true }; + + // Ensure BootstrapCoordinator is added to the game components for tick reliability + if (Current.Game.components.All(c => c is not BootstrapCoordinator)) + { + Current.Game.components.Add(new BootstrapCoordinator(Current.Game)); + UnityEngine.Debug.Log("[Bootstrap] BootstrapCoordinator GameComponent added to Current.Game"); + } // Do NOT change programState; let vanilla handle it during the page flow var scenarioPage = new Page_SelectScenario(); @@ -607,7 +610,7 @@ private void StartVanillaNewColonyFlow() // Start watching for page flow + map entry. saveReady = false; savedReplayPath = null; - saveUploadStatus = "Waiting for world generation..."; + saveUploadStatus = "After the save is uploaded, the server will automatically shut down. You will need to restart the server manually to complete the setup."; // Arm the vanilla page auto-advance driver autoAdvanceArmed = true; @@ -619,6 +622,9 @@ private void StartVanillaNewColonyFlow() autoAdvanceDiagCooldown = 0f; startingLettersCleared = false; landingDialogsCleared = false; + AwaitingBootstrapMapInit = true; + saveUploadStatus = "Generating map..."; + Trace("StartVanillaNewColonyFlow"); } @@ -702,7 +708,10 @@ internal void TryArmAwaitingBootstrapMapInit_FromRootPlayUpdate() public void OnBootstrapMapInitialized() { if (!AwaitingBootstrapMapInit) + { + UnityEngine.Debug.Log("[Bootstrap] OnBootstrapMapInitialized called but AwaitingBootstrapMapInit is false - ignoring"); return; + } AwaitingBootstrapMapInit = false; // Wait a bit after entering the map before saving, to let final UI/world settle. @@ -713,8 +722,7 @@ public void OnBootstrapMapInitialized() saveUploadStatus = "Map initialized. Waiting before saving..."; Trace("FinalizeInit"); - if (Prefs.DevMode) - Log.Message("[Bootstrap] Map initialized, waiting for controllable pawns before saving"); + UnityEngine.Debug.Log($"[Bootstrap] Map initialized - postMapEnterSaveDelayRemaining={postMapEnterSaveDelayRemaining:F2}s, awaiting colonists"); // Saving is driven by a tick loop (WindowUpdate + BootstrapCoordinator + Root_Play.Update). // Do not assume WindowUpdate keeps ticking during/after long events. } @@ -733,7 +741,15 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() // Drive the post-map delay. Use real time, not game ticks; during map init we still want // the save to happen shortly after the map becomes controllable. + var prevRemaining = postMapEnterSaveDelayRemaining; postMapEnterSaveDelayRemaining -= Time.deltaTime; + + // Debug logging for delay countdown + if (Mathf.FloorToInt(prevRemaining * 2) != Mathf.FloorToInt(postMapEnterSaveDelayRemaining * 2)) + { + UnityEngine.Debug.Log($"[Bootstrap] Save delay countdown: {postMapEnterSaveDelayRemaining:F2}s remaining"); + } + if (postMapEnterSaveDelayRemaining > 0f) return; @@ -752,6 +768,15 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() // (Some versions/modlists may temporarily have an empty list during generation.) anyColonist = Find.CurrentMap.mapPawns?.FreeColonistsSpawned != null && Find.CurrentMap.mapPawns.FreeColonistsSpawned.Count > 0; + + if (!anyColonist && awaitingControllablePawnsElapsed < AwaitControllablePawnsTimeoutSeconds) + { + // Log periodically while waiting + if (Mathf.FloorToInt(awaitingControllablePawnsElapsed) != Mathf.FloorToInt(awaitingControllablePawnsElapsed - Time.deltaTime)) + { + UnityEngine.Debug.Log($"[Bootstrap] Waiting for colonists... elapsed={awaitingControllablePawnsElapsed:F1}s"); + } + } } catch { @@ -765,8 +790,7 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() // Pause the game as soon as colonists are controllable so the snapshot is stable try { Find.TickManager.CurTimeSpeed = TimeSpeed.Paused; } catch { } - if (Prefs.DevMode) - Log.Message("[Bootstrap] Controllable colonists detected, starting save"); + UnityEngine.Debug.Log("[Bootstrap] Controllable colonists detected, starting save"); } } @@ -776,12 +800,11 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() { // Fallback: don't block forever; save anyway. awaitingControllablePawns = false; - if (Prefs.DevMode) - Log.Warning("[Bootstrap] Timed out waiting for controllable pawns; saving anyway"); + UnityEngine.Debug.LogWarning("[Bootstrap] Timed out waiting for controllable pawns; saving anyway"); } else { - saveUploadStatus = "Entered map. Waiting for colonists to spawn..."; + saveUploadStatus = "Waiting for controllable colonists to spawn..."; Trace("WaitColonists"); return; } @@ -794,6 +817,8 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() saveUploadStatus = "Map initialized. Starting hosted MP session..."; Trace("StartHost"); + + UnityEngine.Debug.Log("[Bootstrap] All conditions met, initiating save sequence"); // NEW FLOW: instead of vanilla save + manual repackaging, // 1) Host a local MP game programmatically (random port to avoid conflicts) @@ -856,7 +881,7 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() if (saveReady) { - saveUploadStatus = "Save complete. Exiting to menu..."; + saveUploadStatus = "Uploaded"; Trace("SaveComplete"); // 3. Exit to main menu (this also cleans up the local server) @@ -874,7 +899,7 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() } else { - saveUploadStatus = "Save failed - file not found."; + saveUploadStatus = "Failed to upload settings.toml: {0}: {1}"; Log.Error($"[Bootstrap] Save finished but file missing: {savedReplayPath}"); Trace("SaveMissingFile"); bootstrapSaveQueued = false; @@ -911,6 +936,8 @@ public override void WindowUpdate() { base.WindowUpdate(); + // Always try to drive the save delay, even if BootstrapCoordinator isn't ticking + // This ensures the autosave triggers even in edge cases TickPostMapEnterSaveDelayAndMaybeSave(); if (isReconnecting) @@ -1094,7 +1121,10 @@ private void StartUploadSaveZip() { try { - connection.Send(new ClientBootstrapUploadStartPacket("save.zip", bytes.Length)); + // Use reconnectingConn if we're in the reconnection flow, otherwise use the initial connection + var targetConn = isReconnecting && reconnectingConn != null ? reconnectingConn : connection; + + targetConn.Send(new ClientBootstrapUploadStartPacket("save.zip", bytes.Length)); const int chunk = 256 * 1024; var sent = 0; @@ -1103,13 +1133,13 @@ private void StartUploadSaveZip() var len = Math.Min(chunk, bytes.Length - sent); var part = new byte[len]; Buffer.BlockCopy(bytes, sent, part, 0, len); - connection.SendFragmented(new ClientBootstrapUploadDataPacket(part).Serialize()); + targetConn.SendFragmented(new ClientBootstrapUploadDataPacket(part).Serialize()); sent += len; var progress = bytes.Length == 0 ? 1f : (float)sent / bytes.Length; OnMainThread.Enqueue(() => saveUploadProgress = Mathf.Clamp01(progress)); } - connection.Send(new ClientBootstrapUploadFinishPacket(sha256)); + targetConn.Send(new ClientBootstrapUploadFinishPacket(sha256)); OnMainThread.Enqueue(() => { From 1517ab054cf1c6059650bf823149ac64af3d9ad9 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Sun, 11 Jan 2026 23:38:37 +0300 Subject: [PATCH 28/28] Update Source/Client/Windows/BootstrapConfiguratorWindow.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Source/Client/Windows/BootstrapConfiguratorWindow.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs index c5fa204e..aa437654 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -446,7 +446,7 @@ void Header(string label) y += RowHeight; r = Row(); - TooltipHandler.TipRegion(r, "Pause on desync."); + TooltipHandler.TipRegion(r, "Automatically pause the game when a desync is detected."); CheckboxLabeled(r, "Pause on desync.", ref settings.pauseOnDesync); y += RowHeight; Gap();