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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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(); From 24d377f82f2d4ddd850eee904484aff19da0292e Mon Sep 17 00:00:00 2001 From: MhaWay Date: Thu, 15 Jan 2026 02:35:03 +0100 Subject: [PATCH 29/47] Remove unused directPort and rely on directAddress for ports --- Source/Client/Windows/BootstrapConfiguratorWindow.cs | 1 - Source/Client/Windows/HostWindow.cs | 2 +- Source/Common/ServerSettings.cs | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs index aa437654..ccda86b5 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -837,7 +837,6 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() gameName = "BootstrapHost", maxPlayers = 2, direct = true, - directPort = freePort, directAddress = $"0.0.0.0:{freePort}", lan = false, steam = false, diff --git a/Source/Client/Windows/HostWindow.cs b/Source/Client/Windows/HostWindow.cs index 8a846361..85da405a 100644 --- a/Source/Client/Windows/HostWindow.cs +++ b/Source/Client/Windows/HostWindow.cs @@ -618,7 +618,7 @@ public static bool HostProgrammatically(ServerSettings overrides, SaveFile file { var settings = MpUtil.ShallowCopy(overrides, new ServerSettings()); if (randomDirectPort) - settings.directPort = GetFreeUdpPort(); + settings.directAddress = $"0.0.0.0:{GetFreeUdpPort()}"; if (!TryStartLocalServer(settings)) return false; diff --git a/Source/Common/ServerSettings.cs b/Source/Common/ServerSettings.cs index d4fa633c..2993e920 100644 --- a/Source/Common/ServerSettings.cs +++ b/Source/Common/ServerSettings.cs @@ -10,7 +10,6 @@ public class ServerSettings public string lanAddress; 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 c59c4d1a577f80766cfd0cd9d9e9f949818be295 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Thu, 15 Jan 2026 02:39:01 +0100 Subject: [PATCH 30/47] Simplify bootstrap settings upload packet (settings.toml only) --- Source/Client/Windows/BootstrapConfiguratorWindow.cs | 2 +- Source/Common/Networking/Packet/BootstrapUploadPackets.cs | 6 ++---- Source/Common/Networking/State/ServerBootstrapState.cs | 7 +------ 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs index ccda86b5..492bf4bc 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -541,7 +541,7 @@ private void StartUploadSettingsToml(string tomlText) { try { - connection.Send(new ClientBootstrapSettingsUploadStartPacket(fileName, bytes.Length)); + connection.Send(new ClientBootstrapSettingsUploadStartPacket(bytes.Length)); const int chunk = 64 * 1024; // safe: packet will be fragmented by ConnectionBase var sent = 0; diff --git a/Source/Common/Networking/Packet/BootstrapUploadPackets.cs b/Source/Common/Networking/Packet/BootstrapUploadPackets.cs index 418476ef..1dc5ad18 100644 --- a/Source/Common/Networking/Packet/BootstrapUploadPackets.cs +++ b/Source/Common/Networking/Packet/BootstrapUploadPackets.cs @@ -6,15 +6,13 @@ 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 + [PacketDefinition(Packets.Client_BootstrapSettingsUploadStart)] + public record struct ClientBootstrapSettingsUploadStartPacket(int length) : IPacket { - public string fileName = fileName; public int length = length; public void Bind(PacketBuffer buf) { - buf.Bind(ref fileName); buf.Bind(ref length); } } diff --git a/Source/Common/Networking/State/ServerBootstrapState.cs b/Source/Common/Networking/State/ServerBootstrapState.cs index 14eaa5a1..d4ac1f96 100644 --- a/Source/Common/Networking/State/ServerBootstrapState.cs +++ b/Source/Common/Networking/State/ServerBootstrapState.cs @@ -19,7 +19,6 @@ public class ServerBootstrapState(ConnectionBase conn) : MpConnectionState(conn) private const int MaxSettingsTomlBytes = 64 * 1024; // Settings upload (settings.toml) - private static string? pendingSettingsFileName; private static int pendingSettingsLength; private static byte[]? pendingSettingsBytes; @@ -87,11 +86,9 @@ public void HandleSettingsUploadStart(ClientBootstrapSettingsUploadStartPacket p 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)"); + ServerLog.Log($"Bootstrap: settings upload start 'settings.toml' ({pendingSettingsLength} bytes)"); } [TypedPacketHandler] @@ -159,7 +156,6 @@ public void HandleSettingsUploadFinish(ClientBootstrapSettingsUploadFinishPacket ServerLog.Log($"Bootstrap: wrote '{settingsPath}'. Waiting for save.zip upload..."); - pendingSettingsFileName = null; pendingSettingsLength = 0; pendingSettingsBytes = null; } @@ -256,7 +252,6 @@ public void HandleUploadFinish(ClientBootstrapUploadFinishPacket packet) private static void ResetUploadState() { - pendingSettingsFileName = null; pendingSettingsLength = 0; pendingSettingsBytes = null; From 982faa52021dd7bc5052eeda4722e21cd39c1c93 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Thu, 15 Jan 2026 02:46:13 +0100 Subject: [PATCH 31/47] Let ConnectionBase fragment settings upload (remove manual chunking) --- .../Client/Windows/BootstrapConfiguratorWindow.cs | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs index 492bf4bc..8cc38186 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -543,18 +543,9 @@ private void StartUploadSettingsToml(string tomlText) { connection.Send(new ClientBootstrapSettingsUploadStartPacket(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)); - } + // Let ConnectionBase fragment internally (MaxFragmentPacketTotalSize ~32 MiB). + connection.SendFragmented(new ClientBootstrapSettingsUploadDataPacket(bytes).Serialize()); + OnMainThread.Enqueue(() => uploadProgress = 1f); connection.Send(new ClientBootstrapSettingsUploadFinishPacket(sha256)); From a51fb310e3d79d76111d0613dfce9cda41e0250c Mon Sep 17 00:00:00 2001 From: MhaWay Date: Thu, 15 Jan 2026 11:40:34 +0100 Subject: [PATCH 32/47] Use port 0 directly instead of GetFreeUdpPort (safer, avoids race condition) --- Source/Client/Windows/BootstrapConfiguratorWindow.cs | 5 ++--- Source/Client/Windows/HostWindow.cs | 10 +--------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs index 8cc38186..f35a2df4 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -821,14 +821,13 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() { try { - // 1. Host multiplayer game on random free port (avoid collisions with user's server) - int freePort = HostWindow.GetFreeUdpPort(); + // 1. Host multiplayer game on random free port (OS assigns it) var hostSettings = new ServerSettings { gameName = "BootstrapHost", maxPlayers = 2, direct = true, - directAddress = $"0.0.0.0:{freePort}", + directAddress = "0.0.0.0:0", // OS assigns free port lan = false, steam = false, }; diff --git a/Source/Client/Windows/HostWindow.cs b/Source/Client/Windows/HostWindow.cs index 85da405a..c185a0bf 100644 --- a/Source/Client/Windows/HostWindow.cs +++ b/Source/Client/Windows/HostWindow.cs @@ -19,14 +19,6 @@ 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 @@ -618,7 +610,7 @@ public static bool HostProgrammatically(ServerSettings overrides, SaveFile file { var settings = MpUtil.ShallowCopy(overrides, new ServerSettings()); if (randomDirectPort) - settings.directAddress = $"0.0.0.0:{GetFreeUdpPort()}"; + settings.directAddress = "0.0.0.0:0"; // OS assigns free port if (!TryStartLocalServer(settings)) return false; From da65173615a3ba6a58f29cfabe83f008832b60a2 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Thu, 15 Jan 2026 12:14:46 +0100 Subject: [PATCH 33/47] Rename bootstrap save upload packets to avoid confusion with settings packets --- Source/Client/Windows/BootstrapConfiguratorWindow.cs | 6 +++--- Source/Common/Networking/Packet/BootstrapUploadPackets.cs | 6 +++--- Source/Common/Networking/State/ServerBootstrapState.cs | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs index f35a2df4..93a4a6d7 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -1113,7 +1113,7 @@ private void StartUploadSaveZip() // 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)); + targetConn.Send(new ClientBootstrapSaveUploadStartPacket("save.zip", bytes.Length)); const int chunk = 256 * 1024; var sent = 0; @@ -1122,13 +1122,13 @@ private void StartUploadSaveZip() var len = Math.Min(chunk, bytes.Length - sent); var part = new byte[len]; Buffer.BlockCopy(bytes, sent, part, 0, len); - targetConn.SendFragmented(new ClientBootstrapUploadDataPacket(part).Serialize()); + targetConn.SendFragmented(new ClientBootstrapSaveUploadDataPacket(part).Serialize()); sent += len; var progress = bytes.Length == 0 ? 1f : (float)sent / bytes.Length; OnMainThread.Enqueue(() => saveUploadProgress = Mathf.Clamp01(progress)); } - targetConn.Send(new ClientBootstrapUploadFinishPacket(sha256)); + targetConn.Send(new ClientBootstrapSaveUploadFinishPacket(sha256)); OnMainThread.Enqueue(() => { diff --git a/Source/Common/Networking/Packet/BootstrapUploadPackets.cs b/Source/Common/Networking/Packet/BootstrapUploadPackets.cs index 1dc5ad18..4666feb9 100644 --- a/Source/Common/Networking/Packet/BootstrapUploadPackets.cs +++ b/Source/Common/Networking/Packet/BootstrapUploadPackets.cs @@ -51,7 +51,7 @@ public void Bind(PacketBuffer buf) /// 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 record struct ClientBootstrapSaveUploadStartPacket(string fileName, int length) : IPacket { public string fileName = fileName; public int length = length; @@ -68,7 +68,7 @@ public void Bind(PacketBuffer buf) /// 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 record struct ClientBootstrapSaveUploadDataPacket(byte[] data) : IPacket { public byte[] data = data; @@ -82,7 +82,7 @@ public void Bind(PacketBuffer buf) /// Notify the server the upload has completed. /// [PacketDefinition(Packets.Client_BootstrapUploadFinish)] -public record struct ClientBootstrapUploadFinishPacket(string sha256Hex) : IPacket +public record struct ClientBootstrapSaveUploadFinishPacket(string sha256Hex) : IPacket { public string sha256Hex = sha256Hex; diff --git a/Source/Common/Networking/State/ServerBootstrapState.cs b/Source/Common/Networking/State/ServerBootstrapState.cs index d4ac1f96..57da79c2 100644 --- a/Source/Common/Networking/State/ServerBootstrapState.cs +++ b/Source/Common/Networking/State/ServerBootstrapState.cs @@ -161,7 +161,7 @@ public void HandleSettingsUploadFinish(ClientBootstrapSettingsUploadFinishPacket } [TypedPacketHandler] - public void HandleUploadStart(ClientBootstrapUploadStartPacket packet) + public void HandleUploadStart(ClientBootstrapSaveUploadStartPacket packet) { if (!IsConfigurator()) return; @@ -181,7 +181,7 @@ public void HandleUploadStart(ClientBootstrapUploadStartPacket packet) } [TypedPacketHandler] - public void HandleUploadData(ClientBootstrapUploadDataPacket packet) + public void HandleUploadData(ClientBootstrapSaveUploadDataPacket packet) { if (!IsConfigurator()) return; @@ -206,7 +206,7 @@ public void HandleUploadData(ClientBootstrapUploadDataPacket packet) } [TypedPacketHandler] - public void HandleUploadFinish(ClientBootstrapUploadFinishPacket packet) + public void HandleUploadFinish(ClientBootstrapSaveUploadFinishPacket packet) { if (!IsConfigurator()) return; From b1675312b5561935d35cc80f1cf589ed92cf4f9c Mon Sep 17 00:00:00 2001 From: MhaWay Date: Thu, 15 Jan 2026 12:21:24 +0100 Subject: [PATCH 34/47] Shorten bootstrap packet names: Settings/Save + Start/Data/End --- Source/Client/Windows/BootstrapConfiguratorWindow.cs | 12 ++++++------ .../Networking/Packet/BootstrapUploadPackets.cs | 12 ++++++------ .../Common/Networking/State/ServerBootstrapState.cs | 12 ++++++------ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs index 93a4a6d7..31fff309 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -541,13 +541,13 @@ private void StartUploadSettingsToml(string tomlText) { try { - connection.Send(new ClientBootstrapSettingsUploadStartPacket(bytes.Length)); + connection.Send(new ClientBootstrapSettingsStartPacket(bytes.Length)); // Let ConnectionBase fragment internally (MaxFragmentPacketTotalSize ~32 MiB). - connection.SendFragmented(new ClientBootstrapSettingsUploadDataPacket(bytes).Serialize()); + connection.SendFragmented(new ClientBootstrapSettingsDataPacket(bytes).Serialize()); OnMainThread.Enqueue(() => uploadProgress = 1f); - connection.Send(new ClientBootstrapSettingsUploadFinishPacket(sha256)); + connection.Send(new ClientBootstrapSettingsEndPacket(sha256)); OnMainThread.Enqueue(() => { @@ -1113,7 +1113,7 @@ private void StartUploadSaveZip() // Use reconnectingConn if we're in the reconnection flow, otherwise use the initial connection var targetConn = isReconnecting && reconnectingConn != null ? reconnectingConn : connection; - targetConn.Send(new ClientBootstrapSaveUploadStartPacket("save.zip", bytes.Length)); + targetConn.Send(new ClientBootstrapSaveStartPacket("save.zip", bytes.Length)); const int chunk = 256 * 1024; var sent = 0; @@ -1122,13 +1122,13 @@ private void StartUploadSaveZip() var len = Math.Min(chunk, bytes.Length - sent); var part = new byte[len]; Buffer.BlockCopy(bytes, sent, part, 0, len); - targetConn.SendFragmented(new ClientBootstrapSaveUploadDataPacket(part).Serialize()); + targetConn.SendFragmented(new ClientBootstrapSaveDataPacket(part).Serialize()); sent += len; var progress = bytes.Length == 0 ? 1f : (float)sent / bytes.Length; OnMainThread.Enqueue(() => saveUploadProgress = Mathf.Clamp01(progress)); } - targetConn.Send(new ClientBootstrapSaveUploadFinishPacket(sha256)); + targetConn.Send(new ClientBootstrapSaveEndPacket(sha256)); OnMainThread.Enqueue(() => { diff --git a/Source/Common/Networking/Packet/BootstrapUploadPackets.cs b/Source/Common/Networking/Packet/BootstrapUploadPackets.cs index 4666feb9..4289afae 100644 --- a/Source/Common/Networking/Packet/BootstrapUploadPackets.cs +++ b/Source/Common/Networking/Packet/BootstrapUploadPackets.cs @@ -7,7 +7,7 @@ namespace Multiplayer.Common.Networking.Packet; /// The client may send exactly one file: settings.toml. /// [PacketDefinition(Packets.Client_BootstrapSettingsUploadStart)] - public record struct ClientBootstrapSettingsUploadStartPacket(int length) : IPacket + public record struct ClientBootstrapSettingsStartPacket(int length) : IPacket { public int length = length; @@ -22,7 +22,7 @@ public void Bind(PacketBuffer buf) /// This packet can be fragmented. /// [PacketDefinition(Packets.Client_BootstrapSettingsUploadData, allowFragmented: true)] -public record struct ClientBootstrapSettingsUploadDataPacket(byte[] data) : IPacket +public record struct ClientBootstrapSettingsDataPacket(byte[] data) : IPacket { public byte[] data = data; @@ -36,7 +36,7 @@ public void Bind(PacketBuffer buf) /// Notify the server the settings.toml upload has completed. /// [PacketDefinition(Packets.Client_BootstrapSettingsUploadFinish)] -public record struct ClientBootstrapSettingsUploadFinishPacket(string sha256Hex) : IPacket +public record struct ClientBootstrapSettingsEndPacket(string sha256Hex) : IPacket { public string sha256Hex = sha256Hex; @@ -51,7 +51,7 @@ public void Bind(PacketBuffer buf) /// The client will send exactly one file: a pre-built save.zip (server format). /// [PacketDefinition(Packets.Client_BootstrapUploadStart)] -public record struct ClientBootstrapSaveUploadStartPacket(string fileName, int length) : IPacket +public record struct ClientBootstrapSaveStartPacket(string fileName, int length) : IPacket { public string fileName = fileName; public int length = length; @@ -68,7 +68,7 @@ public void Bind(PacketBuffer buf) /// This packet is expected to be delivered fragmented due to size. /// [PacketDefinition(Packets.Client_BootstrapUploadData, allowFragmented: true)] -public record struct ClientBootstrapSaveUploadDataPacket(byte[] data) : IPacket +public record struct ClientBootstrapSaveDataPacket(byte[] data) : IPacket { public byte[] data = data; @@ -82,7 +82,7 @@ public void Bind(PacketBuffer buf) /// Notify the server the upload has completed. /// [PacketDefinition(Packets.Client_BootstrapUploadFinish)] -public record struct ClientBootstrapSaveUploadFinishPacket(string sha256Hex) : IPacket +public record struct ClientBootstrapSaveEndPacket(string sha256Hex) : IPacket { public string sha256Hex = sha256Hex; diff --git a/Source/Common/Networking/State/ServerBootstrapState.cs b/Source/Common/Networking/State/ServerBootstrapState.cs index 57da79c2..dafefab1 100644 --- a/Source/Common/Networking/State/ServerBootstrapState.cs +++ b/Source/Common/Networking/State/ServerBootstrapState.cs @@ -71,7 +71,7 @@ public override void OnDisconnect() } [TypedPacketHandler] - public void HandleSettingsUploadStart(ClientBootstrapSettingsUploadStartPacket packet) + public void HandleSettingsStart(ClientBootstrapSettingsStartPacket packet) { if (!IsConfigurator()) return; @@ -92,7 +92,7 @@ public void HandleSettingsUploadStart(ClientBootstrapSettingsUploadStartPacket p } [TypedPacketHandler] - public void HandleSettingsUploadData(ClientBootstrapSettingsUploadDataPacket packet) + public void HandleSettingsData(ClientBootstrapSettingsDataPacket packet) { if (!IsConfigurator()) return; @@ -121,7 +121,7 @@ public void HandleSettingsUploadData(ClientBootstrapSettingsUploadDataPacket pac } [TypedPacketHandler] - public void HandleSettingsUploadFinish(ClientBootstrapSettingsUploadFinishPacket packet) + public void HandleSettingsEnd(ClientBootstrapSettingsEndPacket packet) { if (!IsConfigurator()) return; @@ -161,7 +161,7 @@ public void HandleSettingsUploadFinish(ClientBootstrapSettingsUploadFinishPacket } [TypedPacketHandler] - public void HandleUploadStart(ClientBootstrapSaveUploadStartPacket packet) + public void HandleSaveStart(ClientBootstrapSaveStartPacket packet) { if (!IsConfigurator()) return; @@ -181,7 +181,7 @@ public void HandleUploadStart(ClientBootstrapSaveUploadStartPacket packet) } [TypedPacketHandler] - public void HandleUploadData(ClientBootstrapSaveUploadDataPacket packet) + public void HandleSaveData(ClientBootstrapSaveDataPacket packet) { if (!IsConfigurator()) return; @@ -206,7 +206,7 @@ public void HandleUploadData(ClientBootstrapSaveUploadDataPacket packet) } [TypedPacketHandler] - public void HandleUploadFinish(ClientBootstrapSaveUploadFinishPacket packet) + public void HandleSaveEnd(ClientBootstrapSaveEndPacket packet) { if (!IsConfigurator()) return; From 178bf38b7c591f96f7f0dab948abcfde454d267a Mon Sep 17 00:00:00 2001 From: MhaWay Date: Thu, 15 Jan 2026 12:38:49 +0100 Subject: [PATCH 35/47] Replace ServerBootstrapCompletePacket with ServerDisconnectPacket+BootstrapCompleted reason --- .../Networking/State/ClientBootstrapState.cs | 16 +++++++++++----- Source/Common/Networking/MpDisconnectReason.cs | 2 ++ .../Networking/Packet/BootstrapUploadPackets.cs | 14 -------------- Source/Common/Networking/Packets.cs | 1 - .../Networking/State/ServerBootstrapState.cs | 4 ++-- 5 files changed, 15 insertions(+), 22 deletions(-) diff --git a/Source/Client/Networking/State/ClientBootstrapState.cs b/Source/Client/Networking/State/ClientBootstrapState.cs index 01a1b000..45b0521b 100644 --- a/Source/Client/Networking/State/ClientBootstrapState.cs +++ b/Source/Client/Networking/State/ClientBootstrapState.cs @@ -12,12 +12,15 @@ namespace Multiplayer.Client; public class ClientBootstrapState(ConnectionBase connection) : ClientBaseState(connection) { [TypedPacketHandler] - public void HandleBootstrapComplete(ServerBootstrapCompletePacket packet) + public void HandleDisconnected(ServerDisconnectPacket 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)); + // If bootstrap completed successfully, show success message before closing the window + if (packet.reason == MpDisconnectReason.BootstrapCompleted) + { + OnMainThread.Enqueue(() => Verse.Messages.Message( + "Bootstrap configuration completed. The server will now shut down; please restart it manually to start normally.", + RimWorld.MessageTypeDefOf.PositiveEvent, false)); + } // Close the bootstrap configurator window now that the process is complete OnMainThread.Enqueue(() => @@ -26,5 +29,8 @@ public void HandleBootstrapComplete(ServerBootstrapCompletePacket packet) if (window != null) Verse.Find.WindowStack.TryRemove(window); }); + + // Let the base class handle the disconnect + base.HandleDisconnected(packet); } } diff --git a/Source/Common/Networking/MpDisconnectReason.cs b/Source/Common/Networking/MpDisconnectReason.cs index 7a162053..89a0507a 100644 --- a/Source/Common/Networking/MpDisconnectReason.cs +++ b/Source/Common/Networking/MpDisconnectReason.cs @@ -20,6 +20,8 @@ public enum MpDisconnectReason : byte ServerStarting, BadGamePassword, StateException + , + BootstrapCompleted } } diff --git a/Source/Common/Networking/Packet/BootstrapUploadPackets.cs b/Source/Common/Networking/Packet/BootstrapUploadPackets.cs index 4289afae..743b65e9 100644 --- a/Source/Common/Networking/Packet/BootstrapUploadPackets.cs +++ b/Source/Common/Networking/Packet/BootstrapUploadPackets.cs @@ -91,17 +91,3 @@ 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 ba07cd2c..a294d0f6 100644 --- a/Source/Common/Networking/Packets.cs +++ b/Source/Common/Networking/Packets.cs @@ -43,7 +43,6 @@ public enum Packets : byte Client_BootstrapUploadData, Client_BootstrapUploadFinish, Server_Bootstrap, - Server_BootstrapComplete, // Joining Server_ProtocolOk, diff --git a/Source/Common/Networking/State/ServerBootstrapState.cs b/Source/Common/Networking/State/ServerBootstrapState.cs index dafefab1..85c788ec 100644 --- a/Source/Common/Networking/State/ServerBootstrapState.cs +++ b/Source/Common/Networking/State/ServerBootstrapState.cs @@ -239,8 +239,8 @@ public void HandleSaveEnd(ClientBootstrapSaveEndPacket packet) ServerLog.Log($"Bootstrap: wrote '{targetPath}'. Configuration complete; disconnecting clients and stopping."); - // Notify and disconnect all clients. - Server.SendToPlaying(new ServerBootstrapCompletePacket("Server configured. The server will now shut down; please restart it manually to start normally.")); + // Notify and disconnect all clients. + Server.SendToPlaying(new ServerDisconnectPacket { reason = MpDisconnectReason.BootstrapCompleted }); foreach (var p in Server.playerManager.Players.ToArray()) p.conn.Close(MpDisconnectReason.ServerClosed); From a1800d532a3ce70488311cd1dbaff82d57c5a39a Mon Sep 17 00:00:00 2001 From: MhaWay Date: Thu, 15 Jan 2026 12:52:10 +0100 Subject: [PATCH 36/47] Track bootstrap configurator by player id; keep unique player ids --- Source/Common/Networking/State/ServerBootstrapState.cs | 10 +++++++--- Source/Common/PlayerManager.cs | 4 ++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/Source/Common/Networking/State/ServerBootstrapState.cs b/Source/Common/Networking/State/ServerBootstrapState.cs index 85c788ec..47d2bb0b 100644 --- a/Source/Common/Networking/State/ServerBootstrapState.cs +++ b/Source/Common/Networking/State/ServerBootstrapState.cs @@ -13,8 +13,9 @@ namespace Multiplayer.Common; /// public class ServerBootstrapState(ConnectionBase conn) : MpConnectionState(conn) { - // Only one configurator at a time (always playerId=0 in bootstrap) + // Only one configurator at a time; track its player id explicitly private static bool configuratorActive; + private static int configuratorPlayerId = -1; private const int MaxSettingsTomlBytes = 64 * 1024; @@ -46,6 +47,7 @@ public override void StartState() } configuratorActive = true; + configuratorPlayerId = Player.id; var settingsMissing2 = !File.Exists(Path.Combine(AppContext.BaseDirectory, "settings.toml")); connection.Send(new ServerBootstrapPacket(true, settingsMissing2)); @@ -62,7 +64,7 @@ public override void StartState() public override void OnDisconnect() { - if (configuratorActive && Player.id == 0) + if (configuratorActive && Player.id == configuratorPlayerId) { ServerLog.Log("Bootstrap: configurator disconnected; returning to waiting state."); ResetUploadState(); @@ -248,7 +250,7 @@ public void HandleSaveEnd(ClientBootstrapSaveEndPacket packet) Server.running = false; } - private bool IsConfigurator() => configuratorActive && Player.id == 0; + private bool IsConfigurator() => configuratorActive && Player.id == configuratorPlayerId; private static void ResetUploadState() { @@ -258,6 +260,8 @@ private static void ResetUploadState() pendingFileName = null; pendingLength = 0; pendingZipBytes = null; + + configuratorPlayerId = -1; } private static string ComputeSha256Hex(byte[] data) diff --git a/Source/Common/PlayerManager.cs b/Source/Common/PlayerManager.cs index 6ca292d9..5d9c77d1 100644 --- a/Source/Common/PlayerManager.cs +++ b/Source/Common/PlayerManager.cs @@ -55,8 +55,8 @@ public ServerPlayer OnConnected(ConnectionBase conn) if (conn.serverPlayer != null) ServerLog.Error($"Connection {conn} already has a server player"); - // In bootstrap mode, always use playerId=0 for simplicity (single configurator) - int assignedId = server.BootstrapMode ? 0 : nextPlayerId++; + // Always assign unique player ids; bootstrap configurator is tracked explicitly elsewhere + int assignedId = nextPlayerId++; conn.serverPlayer = new ServerPlayer(assignedId, conn); Players.Add(conn.serverPlayer); From b01b57cd80fd83fe746cff2c162c2354e8c01683 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Thu, 15 Jan 2026 13:15:29 +0100 Subject: [PATCH 37/47] Track bootstrap configurator by username to survive reconnections --- .../Networking/State/ServerBootstrapState.cs | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/Source/Common/Networking/State/ServerBootstrapState.cs b/Source/Common/Networking/State/ServerBootstrapState.cs index 47d2bb0b..d262557e 100644 --- a/Source/Common/Networking/State/ServerBootstrapState.cs +++ b/Source/Common/Networking/State/ServerBootstrapState.cs @@ -13,9 +13,8 @@ namespace Multiplayer.Common; /// public class ServerBootstrapState(ConnectionBase conn) : MpConnectionState(conn) { - // Only one configurator at a time; track its player id explicitly - private static bool configuratorActive; - private static int configuratorPlayerId = -1; + // Only one configurator at a time; track by username to survive reconnections + private static string? configuratorUsername; private const int MaxSettingsTomlBytes = 64 * 1024; @@ -37,17 +36,17 @@ public override void StartState() return; } - // If someone already is configuring, keep this connection idle. - if (configuratorActive) + // If a different configurator is already active, keep this connection idle + if (configuratorUsername != null && configuratorUsername != connection.username) { - // Still tell them we're in bootstrap, so clients can show a helpful UI. + // Still tell them we're in bootstrap, so clients can show a helpful UI var settingsMissing = !File.Exists(Path.Combine(AppContext.BaseDirectory, "settings.toml")); connection.Send(new ServerBootstrapPacket(true, settingsMissing)); return; } - configuratorActive = true; - configuratorPlayerId = Player.id; + // This is the configurator (either new or reconnecting) + configuratorUsername = connection.username; var settingsMissing2 = !File.Exists(Path.Combine(AppContext.BaseDirectory, "settings.toml")); connection.Send(new ServerBootstrapPacket(true, settingsMissing2)); @@ -55,20 +54,19 @@ public override void StartState() 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..."); + ServerLog.Log($"Bootstrap: configurator '{connection.username}' connected. 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..."); + ServerLog.Log($"Bootstrap: configurator '{connection.username}' connected. 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."); + ServerLog.Log($"Bootstrap: configurator '{connection.username}' connected. All files already present; waiting for shutdown."); } public override void OnDisconnect() { - if (configuratorActive && Player.id == configuratorPlayerId) + if (configuratorUsername == connection.username) { ServerLog.Log("Bootstrap: configurator disconnected; returning to waiting state."); ResetUploadState(); - configuratorActive = false; } } @@ -241,8 +239,8 @@ public void HandleSaveEnd(ClientBootstrapSaveEndPacket packet) ServerLog.Log($"Bootstrap: wrote '{targetPath}'. Configuration complete; disconnecting clients and stopping."); - // Notify and disconnect all clients. - Server.SendToPlaying(new ServerDisconnectPacket { reason = MpDisconnectReason.BootstrapCompleted }); + // Notify and disconnect all clients. + Server.SendToPlaying(new ServerDisconnectPacket { reason = MpDisconnectReason.BootstrapCompleted }); foreach (var p in Server.playerManager.Players.ToArray()) p.conn.Close(MpDisconnectReason.ServerClosed); @@ -250,7 +248,7 @@ public void HandleSaveEnd(ClientBootstrapSaveEndPacket packet) Server.running = false; } - private bool IsConfigurator() => configuratorActive && Player.id == configuratorPlayerId; + private bool IsConfigurator() => configuratorUsername == connection.username; private static void ResetUploadState() { @@ -261,7 +259,7 @@ private static void ResetUploadState() pendingLength = 0; pendingZipBytes = null; - configuratorPlayerId = -1; + configuratorUsername = null; } private static string ComputeSha256Hex(byte[] data) From 3511ebe44a29f35dcfe8aca943aeaade342991b9 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Thu, 15 Jan 2026 13:19:16 +0100 Subject: [PATCH 38/47] Use Extensions.ToHexString instead of local implementation --- .../Networking/State/ServerBootstrapState.cs | 28 ++++--------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/Source/Common/Networking/State/ServerBootstrapState.cs b/Source/Common/Networking/State/ServerBootstrapState.cs index d262557e..770d5c2d 100644 --- a/Source/Common/Networking/State/ServerBootstrapState.cs +++ b/Source/Common/Networking/State/ServerBootstrapState.cs @@ -39,7 +39,7 @@ public override void StartState() // If a different configurator is already active, keep this connection idle if (configuratorUsername != null && configuratorUsername != connection.username) { - // Still tell them we're in bootstrap, so clients can show a helpful UI + // Still tell them we're in bootstrap, so clients can show a helpful UI. var settingsMissing = !File.Exists(Path.Combine(AppContext.BaseDirectory, "settings.toml")); connection.Send(new ServerBootstrapPacket(true, settingsMissing)); return; @@ -139,7 +139,8 @@ public void HandleSettingsEnd(ClientBootstrapSettingsEndPacket packet) if (pendingSettingsLength > 0 && pendingSettingsBytes.Length != pendingSettingsLength) ServerLog.Log($"Bootstrap: warning - expected {pendingSettingsLength} settings bytes but got {pendingSettingsBytes.Length}"); - var actualHash = ComputeSha256Hex(pendingSettingsBytes); + using var sha256 = System.Security.Cryptography.SHA256.Create(); + var actualHash = sha256.ComputeHash(pendingSettingsBytes).ToHexString(); if (!string.IsNullOrWhiteSpace(packet.sha256Hex) && !actualHash.Equals(packet.sha256Hex, StringComparison.OrdinalIgnoreCase)) { @@ -221,7 +222,8 @@ public void HandleSaveEnd(ClientBootstrapSaveEndPacket packet) if (pendingLength > 0 && pendingZipBytes.Length != pendingLength) ServerLog.Log($"Bootstrap: warning - expected {pendingLength} bytes but got {pendingZipBytes.Length}"); - var actualHash = ComputeSha256Hex(pendingZipBytes); + using var sha256 = System.Security.Cryptography.SHA256.Create(); + var actualHash = sha256.ComputeHash(pendingZipBytes).ToHexString(); if (!string.IsNullOrWhiteSpace(packet.sha256Hex) && !actualHash.Equals(packet.sha256Hex, StringComparison.OrdinalIgnoreCase)) { @@ -261,24 +263,4 @@ private static void ResetUploadState() configuratorUsername = 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); - } } From 3867accdf50d9567f0dbc43a8d96649ba3dd0c8c Mon Sep 17 00:00:00 2001 From: MhaWay Date: Thu, 15 Jan 2026 13:20:33 +0100 Subject: [PATCH 39/47] Keeping english --- Source/Client/Windows/HostWindow.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Client/Windows/HostWindow.cs b/Source/Client/Windows/HostWindow.cs index c185a0bf..a35d94fd 100644 --- a/Source/Client/Windows/HostWindow.cs +++ b/Source/Client/Windows/HostWindow.cs @@ -604,7 +604,7 @@ private void HostFromReplay(ServerSettings settings) } } /// - /// Avvia l'hosting programmaticamente per il flusso bootstrap. + /// Start hosting programmatically for the bootstrap flow. /// public static bool HostProgrammatically(ServerSettings overrides, SaveFile file = null, bool randomDirectPort = true) { From 89237c6ed2d900023981213f3586be682e26c572 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Thu, 15 Jan 2026 13:24:05 +0100 Subject: [PATCH 40/47] Change bootstrap hash packets to use byte array instead of hex string --- .../Client/Windows/BootstrapConfiguratorWindow.cs | 12 ++++++++---- .../Networking/Packet/BootstrapUploadPackets.cs | 12 ++++++------ .../Networking/State/ServerBootstrapState.cs | 15 +++++++-------- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs index 31fff309..c7402cc7 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -533,9 +533,9 @@ private void StartUploadSettingsToml(string tomlText) // 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; + byte[] sha256Hash; using (var hasher = SHA256.Create()) - sha256 = hasher.ComputeHash(bytes).ToHexString(); + sha256Hash = hasher.ComputeHash(bytes); new System.Threading.Thread(() => { @@ -547,7 +547,7 @@ private void StartUploadSettingsToml(string tomlText) connection.SendFragmented(new ClientBootstrapSettingsDataPacket(bytes).Serialize()); OnMainThread.Enqueue(() => uploadProgress = 1f); - connection.Send(new ClientBootstrapSettingsEndPacket(sha256)); + connection.Send(new ClientBootstrapSettingsEndPacket(sha256Hash)); OnMainThread.Enqueue(() => { @@ -1128,7 +1128,11 @@ private void StartUploadSaveZip() OnMainThread.Enqueue(() => saveUploadProgress = Mathf.Clamp01(progress)); } - targetConn.Send(new ClientBootstrapSaveEndPacket(sha256)); + byte[] sha256Hash; + using (var hasher = SHA256.Create()) + sha256Hash = hasher.ComputeHash(bytes); + + targetConn.Send(new ClientBootstrapSaveEndPacket(sha256Hash)); OnMainThread.Enqueue(() => { diff --git a/Source/Common/Networking/Packet/BootstrapUploadPackets.cs b/Source/Common/Networking/Packet/BootstrapUploadPackets.cs index 743b65e9..4ce8f7d1 100644 --- a/Source/Common/Networking/Packet/BootstrapUploadPackets.cs +++ b/Source/Common/Networking/Packet/BootstrapUploadPackets.cs @@ -36,13 +36,13 @@ public void Bind(PacketBuffer buf) /// Notify the server the settings.toml upload has completed. /// [PacketDefinition(Packets.Client_BootstrapSettingsUploadFinish)] -public record struct ClientBootstrapSettingsEndPacket(string sha256Hex) : IPacket +public record struct ClientBootstrapSettingsEndPacket(byte[] sha256Hash) : IPacket { - public string sha256Hex = sha256Hex; + public byte[] sha256Hash = sha256Hash; public void Bind(PacketBuffer buf) { - buf.Bind(ref sha256Hex); + buf.BindBytes(ref sha256Hash, maxLength: 32); } } @@ -82,12 +82,12 @@ public void Bind(PacketBuffer buf) /// Notify the server the upload has completed. /// [PacketDefinition(Packets.Client_BootstrapUploadFinish)] -public record struct ClientBootstrapSaveEndPacket(string sha256Hex) : IPacket +public record struct ClientBootstrapSaveEndPacket(byte[] sha256Hash) : IPacket { - public string sha256Hex = sha256Hex; + public byte[] sha256Hash = sha256Hash; public void Bind(PacketBuffer buf) { - buf.Bind(ref sha256Hex); + buf.BindBytes(ref sha256Hash, maxLength: 32); } } diff --git a/Source/Common/Networking/State/ServerBootstrapState.cs b/Source/Common/Networking/State/ServerBootstrapState.cs index 770d5c2d..bd0182e6 100644 --- a/Source/Common/Networking/State/ServerBootstrapState.cs +++ b/Source/Common/Networking/State/ServerBootstrapState.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Security.Cryptography; using Multiplayer.Common.Networking.Packet; @@ -140,11 +141,10 @@ public void HandleSettingsEnd(ClientBootstrapSettingsEndPacket packet) ServerLog.Log($"Bootstrap: warning - expected {pendingSettingsLength} settings bytes but got {pendingSettingsBytes.Length}"); using var sha256 = System.Security.Cryptography.SHA256.Create(); - var actualHash = sha256.ComputeHash(pendingSettingsBytes).ToHexString(); - if (!string.IsNullOrWhiteSpace(packet.sha256Hex) && - !actualHash.Equals(packet.sha256Hex, StringComparison.OrdinalIgnoreCase)) + var actualHash = sha256.ComputeHash(pendingSettingsBytes); + if (packet.sha256Hash != null && packet.sha256Hash.Length > 0 && !actualHash.SequenceEqual(packet.sha256Hash)) { - throw new PacketReadException($"Bootstrap settings upload hash mismatch. expected={packet.sha256Hex} actual={actualHash}"); + throw new PacketReadException($"Bootstrap settings upload hash mismatch. expected={packet.sha256Hash.ToHexString()} actual={actualHash.ToHexString()}"); } // Persist settings.toml @@ -223,11 +223,10 @@ public void HandleSaveEnd(ClientBootstrapSaveEndPacket packet) ServerLog.Log($"Bootstrap: warning - expected {pendingLength} bytes but got {pendingZipBytes.Length}"); using var sha256 = System.Security.Cryptography.SHA256.Create(); - var actualHash = sha256.ComputeHash(pendingZipBytes).ToHexString(); - if (!string.IsNullOrWhiteSpace(packet.sha256Hex) && - !actualHash.Equals(packet.sha256Hex, StringComparison.OrdinalIgnoreCase)) + var actualHash = sha256.ComputeHash(pendingZipBytes); + if (packet.sha256Hash != null && packet.sha256Hash.Length > 0 && !actualHash.SequenceEqual(packet.sha256Hash)) { - throw new PacketReadException($"Bootstrap upload hash mismatch. expected={packet.sha256Hex} actual={actualHash}"); + throw new PacketReadException($"Bootstrap upload hash mismatch. expected={packet.sha256Hash.ToHexString()} actual={actualHash.ToHexString()}"); } // Persist save.zip From 5552b6008f9acec0041ebd32b945623d29a7c715 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Thu, 15 Jan 2026 13:29:34 +0100 Subject: [PATCH 41/47] Remove unnecessary ClientDisconnectedState (StateObj is set to null when disconnected) --- Source/Client/MultiplayerStatic.cs | 1 - .../Client/Networking/State/ClientDisconnectedState.cs | 10 ---------- Source/Common/Networking/MpDisconnectReason.cs | 3 +-- 3 files changed, 1 insertion(+), 13 deletions(-) delete mode 100644 Source/Client/Networking/State/ClientDisconnectedState.cs diff --git a/Source/Client/MultiplayerStatic.cs b/Source/Client/MultiplayerStatic.cs index 8f36850a..6e8584c7 100644 --- a/Source/Client/MultiplayerStatic.cs +++ b/Source/Client/MultiplayerStatic.cs @@ -88,7 +88,6 @@ static MultiplayerStatic() 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(); diff --git a/Source/Client/Networking/State/ClientDisconnectedState.cs b/Source/Client/Networking/State/ClientDisconnectedState.cs deleted file mode 100644 index 8f3c2f2e..00000000 --- a/Source/Client/Networking/State/ClientDisconnectedState.cs +++ /dev/null @@ -1,10 +0,0 @@ -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 diff --git a/Source/Common/Networking/MpDisconnectReason.cs b/Source/Common/Networking/MpDisconnectReason.cs index 89a0507a..571ae9b7 100644 --- a/Source/Common/Networking/MpDisconnectReason.cs +++ b/Source/Common/Networking/MpDisconnectReason.cs @@ -19,8 +19,7 @@ public enum MpDisconnectReason : byte Internal, ServerStarting, BadGamePassword, - StateException - , + StateException, BootstrapCompleted } From 1b0bd04ceddae14c9320de1d37278630b006b563 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Thu, 15 Jan 2026 20:25:41 +0100 Subject: [PATCH 42/47] Refactor shared ServerSettings UI into reusable helper class - Extract common UI drawing logic from HostWindow and BootstrapConfiguratorWindow into new ServerSettingsUI helper class - Eliminate ~200 lines of duplicated UI code between host and bootstrap configuration windows - Both windows now use same ServerSettingsUI.DrawNetworkingSettings() and DrawGameplaySettings() methods - Maintain separate window logic (hosting vs bootstrap) while sharing UI components - Improves maintainability: UI changes reflect in both windows automatically - Zero functional changes, 100% backward compatible --- .../Windows/BootstrapConfiguratorWindow.cs | 321 +++--------------- Source/Client/Windows/HostWindow.cs | 274 ++------------- Source/Client/Windows/ServerSettingsUI.cs | 310 +++++++++++++++++ 3 files changed, 370 insertions(+), 535 deletions(-) create mode 100644 Source/Client/Windows/ServerSettingsUI.cs diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs index c7402cc7..8213a474 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -43,9 +43,8 @@ private enum Step private Vector2 scroll; - // numeric buffers - private string maxPlayersBuffer; - private string autosaveIntervalBuffer; + // UI buffers + private ServerSettingsUI.BufferSet settingsUiBuffers = new(); // toml preview private string tomlPreview; @@ -145,6 +144,10 @@ public BootstrapConfiguratorWindow(ConnectionBase connection) arbiter = false }; + // Initialize UI buffers + settingsUiBuffers.MaxPlayersBuffer = settings.maxPlayers.ToString(); + settingsUiBuffers.AutosaveBuffer = settings.autosaveInterval.ToString(); + // 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. @@ -208,11 +211,20 @@ public override void DoWindowContents(Rect inRect) if (step == Step.Settings) { - leftRect = bodyRect.LeftPart(0.58f).ContractedBy(4f); - rightRect = bodyRect.RightPart(0.42f).ContractedBy(4f); + // Only show TOML preview in dev mode + if (Prefs.DevMode) + { + leftRect = bodyRect.LeftPart(0.58f).ContractedBy(4f); + rightRect = bodyRect.RightPart(0.42f).ContractedBy(4f); + DrawTomlPreview(rightRect); + } + else + { + leftRect = bodyRect.ContractedBy(4f); + rightRect = Rect.zero; + } DrawSettings(leftRect); - DrawTomlPreview(rightRect); DrawSettingsButtons(buttonsRect); } else @@ -304,189 +316,15 @@ private void DrawSettings(Rect inRect) 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); + // Use ServerSettingsUI to draw both networking and gameplay settings + Widgets.BeginScrollView(contentRect, ref scroll, contentRect); + + var settingsRect = contentRect.TopPartPixels(contentRect.height); + ServerSettingsUI.DrawNetworkingSettings(settingsRect, settings, settingsUiBuffers); + + settingsRect = settingsRect.Down(300f); + ServerSettingsUI.DrawGameplaySettingsOnly(settingsRect, settings, settingsUiBuffers); - 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, "Multi-faction", 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, "When clients automatically join (flags). Stored as a string in TOML.", ref settings.autoJoinPoint); - y += RowHeight; - Gap(); - } - - // pause behavior - { - var r = Row(); - TooltipHandler.TipRegion(r, "When to automatically pause on letters."); - 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 when a player joins.", ref settings.pauseOnJoin); - y += RowHeight; - - r = Row(); - TooltipHandler.TipRegion(r, "Automatically pause the game when a desync is detected."); - 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, "Enable debug mode.", ref settings.debugMode); - y += RowHeight; - - r = Row(); - TooltipHandler.TipRegion(r, "Include desync traces to help debugging."); - 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 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); - 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 is not supported in standalone server.", ref settings.arbiter); - y += RowHeight; - } - Widgets.EndScrollView(); } @@ -494,15 +332,23 @@ private void DrawSettingsButtons(Rect inRect) { var buttons = inRect.ContractedBy(4f); - var copyRect = buttons.LeftPart(0.5f).ContractedBy(2f); - if (Widgets.ButtonText(copyRect, "Copy TOML")) + // Copy TOML button only in dev mode + Rect nextRect; + if (Prefs.DevMode) { - RebuildTomlPreview(); - GUIUtility.systemCopyBuffer = tomlPreview; - Messages.Message("Copied settings.toml to clipboard", MessageTypeDefOf.SilentInput, false); + 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); + } + nextRect = buttons.RightPart(0.5f).ContractedBy(2f); + } + else + { + nextRect = buttons.ContractedBy(2f); } - - var nextRect = buttons.RightPart(0.5f).ContractedBy(2f); var nextLabel = settingsUploaded ? "Uploaded" : "Next"; var nextEnabled = !isUploadingToml && !settingsUploaded; @@ -1233,90 +1079,5 @@ private static void AppendKv(StringBuilder sb, string key, float value) 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)); - } } } diff --git a/Source/Client/Windows/HostWindow.cs b/Source/Client/Windows/HostWindow.cs index a35d94fd..c04a744d 100644 --- a/Source/Client/Windows/HostWindow.cs +++ b/Source/Client/Windows/HostWindow.cs @@ -147,269 +147,33 @@ private void DoTabButton(Rect r, Tab tab) private void DoConnecting(Rect entry) { - // Max players - MpUI.TextFieldNumericLabeled(entry.Width(LabelWidth + 35f), $"{"MpMaxPlayers".Translate()}: ", ref serverSettings.maxPlayers, ref maxPlayersBuffer, LabelWidth, 0, 999); - entry = entry.Down(30); - - // Password - MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), $"{"MpHostGamePassword".Translate()}: ", ref serverSettings.hasPassword, order: ElementOrder.Right); - if (serverSettings.hasPassword) - MpUI.DoPasswordField(entry.Right(CheckboxWidth + 10).MaxX(entry.xMax), "PasswordField", ref serverSettings.password); - entry = entry.Down(30); - - // Direct hosting - var directLabel = $"{"MpHostDirect".Translate()}: "; - MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), directLabel, ref serverSettings.direct, order: ElementOrder.Right); - TooltipHandler.TipRegion(entry.Width(LabelWidth), MpUtil.TranslateWithDoubleNewLines("MpHostDirectDesc", 4)); - if (serverSettings.direct) - serverSettings.directAddress = Widgets.TextField(entry.Right(CheckboxWidth + 10).MaxX(entry.xMax), serverSettings.directAddress); - - entry = entry.Down(30); - - // LAN hosting - var lanRect = entry.Width(CheckboxWidth); - MpUI.CheckboxLabeled(lanRect, $"{"MpLan".Translate()}: ", ref serverSettings.lan, order: ElementOrder.Right); - TooltipHandler.TipRegion(lanRect, $"{"MpLanDesc1".Translate()}\n\n{"MpLanDesc2".Translate(serverSettings.lanAddress)}"); - - entry = entry.Down(30); - - // Steam hosting - var steamRect = entry.Width(CheckboxWidth); - if (!SteamManager.Initialized) serverSettings.steam = false; - MpUI.CheckboxLabeled(steamRect, $"{"MpSteam".Translate()}: ", ref serverSettings.steam, order: ElementOrder.Right, disabled: !SteamManager.Initialized); - if (!SteamManager.Initialized) TooltipHandler.TipRegion(steamRect, "MpSteamNotAvailable".Translate()); - entry = entry.Down(30); - - // Sync configs - TooltipHandler.TipRegion(entry.Width(CheckboxWidth), MpUtil.TranslateWithDoubleNewLines("MpSyncConfigsDescNew", 3)); - MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), $"{"MpSyncConfigs".Translate()}: ", ref serverSettings.syncConfigs, order: ElementOrder.Right); - entry = entry.Down(30); - } - - private void DoGameplay(Rect entry) - { - // Autosave interval - var autosaveUnitKey = serverSettings.autosaveUnit == AutosaveUnit.Days - ? "MpAutosavesDays" - : "MpAutosavesMinutes"; - - bool changeAutosaveUnit = false; - - LeftLabel(entry, $"{"MpAutosaves".Translate()}: "); - TooltipHandler.TipRegion(entry.Width(LabelWidth), MpUtil.TranslateWithDoubleNewLines("MpAutosavesDesc", 3)); - - using (MpStyle.Set(TextAnchor.MiddleRight)) - DoRow( - entry.Right(LabelWidth + 10), - rect => MpUI.LabelFlexibleWidth(rect, "MpAutosavesEvery".Translate()) + 6, - rect => - { - Widgets.TextFieldNumeric( - rect.Width(50f), - ref serverSettings.autosaveInterval, - ref autosaveBuffer, - 0, - 999 - ); - return 50f + 6; - }, - rect => - { - changeAutosaveUnit = CustomButton(rect, autosaveUnitKey.Translate(), out var width); - return width; - } - ); - - if (changeAutosaveUnit) - { - serverSettings.autosaveUnit = serverSettings.autosaveUnit.Cycle(); - serverSettings.autosaveInterval *= - serverSettings.autosaveUnit == AutosaveUnit.Minutes ? - 8f : // Days to minutes - 0.125f; // Minutes to days - autosaveBuffer = serverSettings.autosaveInterval.ToString(); - } - - entry = entry.Down(30); - - // Multifaction - MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), $"Multifaction: ", ref serverSettings.multifaction, order: ElementOrder.Right, disabled: multifactionLocked); - entry = entry.Down(30); - - // Async time - TooltipHandler.TipRegion(entry.Width(CheckboxWidth), $"{"MpAsyncTimeDesc".Translate()}\n\n{"MpExperimentalFeature".Translate()}"); - MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), $"{"MpAsyncTime".Translate()}: ", ref serverSettings.asyncTime, order: ElementOrder.Right, disabled: asyncTimeLocked); - entry = entry.Down(30); - - // Time control - LeftLabel(entry, $"{"MpTimeControl".Translate()}: "); - DoTimeControl(entry.Right(LabelWidth + 10)); - - entry = entry.Down(30); - - // Log desync traces - MpUI.CheckboxLabeledWithTipNoHighlight( - entry.Width(CheckboxWidth), - $"{"MpLogDesyncTraces".Translate()}: ", - MpUtil.TranslateWithDoubleNewLines("MpLogDesyncTracesDesc", 2), - ref serverSettings.desyncTraces, - placeTextNearCheckbox: true - ); - entry = entry.Down(30); - - // Arbiter - if (MpVersion.IsDebug) { - TooltipHandler.TipRegion(entry.Width(CheckboxWidth), "MpArbiterDesc".Translate()); - MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), $"{"MpRunArbiter".Translate()}: ", ref serverSettings.arbiter, order: ElementOrder.Right); - entry = entry.Down(30); - } - - // Dev mode - MpUI.CheckboxLabeledWithTipNoHighlight( - entry.Width(CheckboxWidth), - $"{"MpHostingDevMode".Translate()}: ", - MpUtil.TranslateWithDoubleNewLines("MpHostingDevModeDesc", 2), - ref serverSettings.debugMode, - placeTextNearCheckbox: true - ); - - // Dev mode scope - if (serverSettings.debugMode) - if (CustomButton(entry.Right(CheckboxWidth + 10f), $"MpHostingDevMode{serverSettings.devModeScope}".Translate())) - { - serverSettings.devModeScope = serverSettings.devModeScope.Cycle(); - SoundDefOf.Checkbox_TurnedOn.PlayOneShotOnCamera(); - } - - entry = entry.Down(30); - - // Auto join-points - DrawJoinPointOptions(entry); - entry = entry.Down(30); - - // Pause on letter - LeftLabel(entry, $"{"MpPauseOnLetter".Translate()}: "); - DoPauseOnLetter(entry.Right(LabelWidth + 10)); - entry = entry.Down(30); - - // Pause on (join, desync) - LeftLabel(entry, $"{"MpPauseOn".Translate()}: "); - DoRow( - entry.Right(LabelWidth + 10), - rect => MpUI.CheckboxLabeled( - rect.Width(CheckboxWidth), - "MpPauseOnJoin".Translate(), - ref serverSettings.pauseOnJoin, - size: 20f, - order: ElementOrder.Left).width + 15, - rect => MpUI.CheckboxLabeled( - rect.Width(CheckboxWidth), - "MpPauseOnDesync".Translate(), - ref serverSettings.pauseOnDesync, - size: 20f, - order: ElementOrder.Left).width - ); - - entry = entry.Down(30); - } - - private void DoTimeControl(Rect entry) - { - if (CustomButton(entry, $"MpTimeControl{serverSettings.timeControl}".Translate())) - Find.WindowStack.Add(new FloatMenu(Options().ToList())); - - IEnumerable Options() - { - foreach (var opt in Enum.GetValues(typeof(TimeControl)).OfType()) - yield return new FloatMenuOption($"MpTimeControl{opt}".Translate(), () => - { - serverSettings.timeControl = opt; - }); - } - } - - private void DoPauseOnLetter(Rect entry) - { - if (CustomButton(entry, $"MpPauseOnLetter{serverSettings.pauseOnLetter}".Translate())) - Find.WindowStack.Add(new FloatMenu(Options().ToList())); - - IEnumerable Options() - { - foreach (var opt in Enum.GetValues(typeof(PauseOnLetter)).OfType()) - yield return new FloatMenuOption($"MpPauseOnLetter{opt}".Translate(), () => - { - serverSettings.pauseOnLetter = opt; - }); - } - } - - static float LeftLabel(Rect entry, string text, string desc = null) - { - using (MpStyle.Set(TextAnchor.MiddleRight)) - MpUI.LabelWithTip( - entry.Width(LabelWidth + 1), - text, - desc - ); - return Text.CalcSize(text).x; - } - - static void DoRow(Rect inRect, params Func[] drawers) - { - foreach (var drawer in drawers) + var buffers = new ServerSettingsUI.BufferSet { - inRect.xMin += drawer(inRect); - } + MaxPlayersBuffer = maxPlayersBuffer, + AutosaveBuffer = autosaveBuffer + }; + + ServerSettingsUI.DrawNetworkingSettings(entry, serverSettings, buffers); + + maxPlayersBuffer = buffers.MaxPlayersBuffer; + autosaveBuffer = buffers.AutosaveBuffer; } - private static Color CustomButtonColor = new(0.15f, 0.15f, 0.15f); - - private void DrawJoinPointOptions(Rect entry) + private void DoGameplay(Rect entry) { - LeftLabel(entry, $"{"MpAutoJoinPoints".Translate()}: ", MpUtil.TranslateWithDoubleNewLines("MpAutoJoinPointsDesc", 3)); - - var flags = Enum.GetValues(typeof(AutoJoinPointFlags)) - .OfType() - .Where(f => serverSettings.autoJoinPoint.HasFlag(f)) - .Select(f => $"MpAutoJoinPoints{f}".Translate()) - .Join(", "); - if (flags.Length == 0) flags = "Off"; - - if (CustomButton(entry.Right(LabelWidth + 10), flags)) - Find.WindowStack.Add(new FloatMenu(Flags().ToList())); - - IEnumerable Flags() + var buffers = new ServerSettingsUI.BufferSet { - foreach (var flag in Enum.GetValues(typeof(AutoJoinPointFlags)).OfType()) - yield return new FloatMenuOption($"MpAutoJoinPoints{flag}".Translate(), () => - { - if (serverSettings.autoJoinPoint.HasFlag(flag)) - serverSettings.autoJoinPoint &= ~flag; - else - serverSettings.autoJoinPoint |= flag; - }); - } + MaxPlayersBuffer = maxPlayersBuffer, + AutosaveBuffer = autosaveBuffer + }; + + ServerSettingsUI.DrawGameplaySettings(entry, serverSettings, buffers, asyncTimeLocked, multifactionLocked); + + maxPlayersBuffer = buffers.MaxPlayersBuffer; + autosaveBuffer = buffers.AutosaveBuffer; } - private static bool CustomButton(Rect rect, string label) - => CustomButton(rect, label, out _); - - private static bool CustomButton(Rect rect, string label, out float width) - { - using var _ = MpStyle.Set(TextAnchor.MiddleLeft); - var flagsWidth = Text.CalcSize(label).x; - - const float btnMargin = 5f; - - var flagsBtn = rect.Width(flagsWidth + btnMargin * 2); - Widgets.DrawRectFast(flagsBtn.Height(24).Down(3), CustomButtonColor); - Widgets.DrawHighlightIfMouseover(flagsBtn.Height(24).Down(3)); - MpUI.Label(rect.Right(btnMargin).Width(flagsWidth), label); - width = flagsBtn.width; - - return Widgets.ButtonInvisible(flagsBtn); - } private void TryHost() { diff --git a/Source/Client/Windows/ServerSettingsUI.cs b/Source/Client/Windows/ServerSettingsUI.cs new file mode 100644 index 00000000..f81dd097 --- /dev/null +++ b/Source/Client/Windows/ServerSettingsUI.cs @@ -0,0 +1,310 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Multiplayer.Client.Util; +using Multiplayer.Common; +using Multiplayer.Common.Util; +using RimWorld; +using UnityEngine; +using Verse; +using Verse.Sound; +using Verse.Steam; + +namespace Multiplayer.Client +{ + /// + /// Shared UI components for drawing ServerSettings fields. + /// Used by both HostWindow and BootstrapConfiguratorWindow. + /// + public static class ServerSettingsUI + { + private const float LabelWidth = 110f; + private const float CheckboxWidth = LabelWidth + 30f; + private static Color CustomButtonColor = new(0.15f, 0.15f, 0.15f); + + // Buffer references - caller must manage these + public class BufferSet + { + public string MaxPlayersBuffer; + public string AutosaveBuffer; + } + + /// + /// Draw networking-related settings (max players, password, direct/LAN/steam, sync configs). + /// + public static void DrawNetworkingSettings(Rect entry, ServerSettings settings, BufferSet buffers) + { + // Max players + MpUI.TextFieldNumericLabeled(entry.Width(LabelWidth + 35f), $"{"MpMaxPlayers".Translate()}: ", ref settings.maxPlayers, ref buffers.MaxPlayersBuffer, LabelWidth, 0, 999); + entry = entry.Down(30); + + // Password + MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), $"{"MpHostGamePassword".Translate()}: ", ref settings.hasPassword, order: ElementOrder.Right); + if (settings.hasPassword) + MpUI.DoPasswordField(entry.Right(CheckboxWidth + 10).MaxX(entry.xMax), "PasswordField", ref settings.password); + entry = entry.Down(30); + + // Direct hosting + var directLabel = $"{"MpHostDirect".Translate()}: "; + MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), directLabel, ref settings.direct, order: ElementOrder.Right); + TooltipHandler.TipRegion(entry.Width(LabelWidth), MpUtil.TranslateWithDoubleNewLines("MpHostDirectDesc", 4)); + if (settings.direct) + settings.directAddress = Widgets.TextField(entry.Right(CheckboxWidth + 10).MaxX(entry.xMax), settings.directAddress); + + entry = entry.Down(30); + + // LAN hosting + var lanRect = entry.Width(CheckboxWidth); + MpUI.CheckboxLabeled(lanRect, $"{"MpLan".Translate()}: ", ref settings.lan, order: ElementOrder.Right); + TooltipHandler.TipRegion(lanRect, $"{"MpLanDesc1".Translate()}\n\n{"MpLanDesc2".Translate(settings.lanAddress)}"); + + entry = entry.Down(30); + + // Steam hosting + var steamRect = entry.Width(CheckboxWidth); + if (!SteamManager.Initialized) settings.steam = false; + MpUI.CheckboxLabeled(steamRect, $"{"MpSteam".Translate()}: ", ref settings.steam, order: ElementOrder.Right, disabled: !SteamManager.Initialized); + if (!SteamManager.Initialized) TooltipHandler.TipRegion(steamRect, "MpSteamNotAvailable".Translate()); + entry = entry.Down(30); + + // Sync configs + TooltipHandler.TipRegion(entry.Width(CheckboxWidth), MpUtil.TranslateWithDoubleNewLines("MpSyncConfigsDescNew", 3)); + MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), $"{"MpSyncConfigs".Translate()}: ", ref settings.syncConfigs, order: ElementOrder.Right); + } + + /// + /// Draw gameplay-related settings (autosave, multifaction, async time, time control, etc.). + /// + public static void DrawGameplaySettings(Rect entry, ServerSettings settings, BufferSet buffers, bool asyncTimeLocked = false, bool multifactionLocked = false) + { + // Autosave interval + var autosaveUnitKey = settings.autosaveUnit == AutosaveUnit.Days + ? "MpAutosavesDays" + : "MpAutosavesMinutes"; + + bool changeAutosaveUnit = false; + + LeftLabel(entry, $"{"MpAutosaves".Translate()}: "); + TooltipHandler.TipRegion(entry.Width(LabelWidth), MpUtil.TranslateWithDoubleNewLines("MpAutosavesDesc", 3)); + + using (MpStyle.Set(TextAnchor.MiddleRight)) + DoRow( + entry.Right(LabelWidth + 10), + rect => MpUI.LabelFlexibleWidth(rect, "MpAutosavesEvery".Translate()) + 6, + rect => + { + Widgets.TextFieldNumeric( + rect.Width(50f), + ref settings.autosaveInterval, + ref buffers.AutosaveBuffer, + 0, + 999 + ); + return 50f + 6; + }, + rect => + { + changeAutosaveUnit = CustomButton(rect, autosaveUnitKey.Translate(), out var width); + return width; + } + ); + + if (changeAutosaveUnit) + { + settings.autosaveUnit = settings.autosaveUnit.Cycle(); + settings.autosaveInterval *= + settings.autosaveUnit == AutosaveUnit.Minutes ? + 8f : // Days to minutes + 0.125f; // Minutes to days + buffers.AutosaveBuffer = settings.autosaveInterval.ToString(); + } + + entry = entry.Down(30); + + // Multifaction + MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), $"Multifaction: ", ref settings.multifaction, order: ElementOrder.Right, disabled: multifactionLocked); + entry = entry.Down(30); + + // Async time + TooltipHandler.TipRegion(entry.Width(CheckboxWidth), $"{"MpAsyncTimeDesc".Translate()}\n\n{"MpExperimentalFeature".Translate()}"); + MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), $"{"MpAsyncTime".Translate()}: ", ref settings.asyncTime, order: ElementOrder.Right, disabled: asyncTimeLocked); + entry = entry.Down(30); + + // Time control + LeftLabel(entry, $"{"MpTimeControl".Translate()}: "); + DoTimeControl(entry.Right(LabelWidth + 10), settings); + + entry = entry.Down(30); + + // Log desync traces + MpUI.CheckboxLabeledWithTipNoHighlight( + entry.Width(CheckboxWidth), + $"{"MpLogDesyncTraces".Translate()}: ", + MpUtil.TranslateWithDoubleNewLines("MpLogDesyncTracesDesc", 2), + ref settings.desyncTraces, + placeTextNearCheckbox: true + ); + entry = entry.Down(30); + + // Arbiter (debug only) + if (MpVersion.IsDebug) + { + TooltipHandler.TipRegion(entry.Width(CheckboxWidth), "MpArbiterDesc".Translate()); + MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), $"{"MpRunArbiter".Translate()}: ", ref settings.arbiter, order: ElementOrder.Right); + entry = entry.Down(30); + } + + // Dev mode + MpUI.CheckboxLabeledWithTipNoHighlight( + entry.Width(CheckboxWidth), + $"{"MpHostingDevMode".Translate()}: ", + MpUtil.TranslateWithDoubleNewLines("MpHostingDevModeDesc", 2), + ref settings.debugMode, + placeTextNearCheckbox: true + ); + + // Dev mode scope + if (settings.debugMode) + if (CustomButton(entry.Right(CheckboxWidth + 10f), $"MpHostingDevMode{settings.devModeScope}".Translate())) + { + settings.devModeScope = settings.devModeScope.Cycle(); + SoundDefOf.Checkbox_TurnedOn.PlayOneShotOnCamera(); + } + + entry = entry.Down(30); + + // Auto join-points + DrawJoinPointOptions(entry, settings); + entry = entry.Down(30); + + // Pause on letter + LeftLabel(entry, $"{"MpPauseOnLetter".Translate()}: "); + DoPauseOnLetter(entry.Right(LabelWidth + 10), settings); + entry = entry.Down(30); + + // Pause on (join, desync) + LeftLabel(entry, $"{"MpPauseOn".Translate()}: "); + DoRow( + entry.Right(LabelWidth + 10), + rect => MpUI.CheckboxLabeled( + rect.Width(CheckboxWidth), + "MpPauseOnJoin".Translate(), + ref settings.pauseOnJoin, + size: 20f, + order: ElementOrder.Left).width + 15, + rect => MpUI.CheckboxLabeled( + rect.Width(CheckboxWidth), + "MpPauseOnDesync".Translate(), + ref settings.pauseOnDesync, + size: 20f, + order: ElementOrder.Left).width + ); + } + + /// + /// Draw just the gameplay settings (no networking) - useful when networking is in a separate context. + /// + public static void DrawGameplaySettingsOnly(Rect entry, ServerSettings settings, BufferSet buffers, bool asyncTimeLocked = false, bool multifactionLocked = false) + { + DrawGameplaySettings(entry, settings, buffers, asyncTimeLocked, multifactionLocked); + } + + // Helper methods + + private static void DoTimeControl(Rect entry, ServerSettings settings) + { + if (CustomButton(entry, $"MpTimeControl{settings.timeControl}".Translate())) + Find.WindowStack.Add(new FloatMenu(Options(settings).ToList())); + + IEnumerable Options(ServerSettings s) + { + foreach (var opt in Enum.GetValues(typeof(TimeControl)).OfType()) + yield return new FloatMenuOption($"MpTimeControl{opt}".Translate(), () => + { + s.timeControl = opt; + }); + } + } + + private static void DoPauseOnLetter(Rect entry, ServerSettings settings) + { + if (CustomButton(entry, $"MpPauseOnLetter{settings.pauseOnLetter}".Translate())) + Find.WindowStack.Add(new FloatMenu(Options(settings).ToList())); + + IEnumerable Options(ServerSettings s) + { + foreach (var opt in Enum.GetValues(typeof(PauseOnLetter)).OfType()) + yield return new FloatMenuOption($"MpPauseOnLetter{opt}".Translate(), () => + { + s.pauseOnLetter = opt; + }); + } + } + + private static void DrawJoinPointOptions(Rect entry, ServerSettings settings) + { + LeftLabel(entry, $"{"MpAutoJoinPoints".Translate()}: ", MpUtil.TranslateWithDoubleNewLines("MpAutoJoinPointsDesc", 3)); + + var flags = Enum.GetValues(typeof(AutoJoinPointFlags)) + .OfType() + .Where(f => settings.autoJoinPoint.HasFlag(f)) + .Select(f => $"MpAutoJoinPoints{f}".Translate()) + .Join(", "); + if (flags.Length == 0) flags = "Off"; + + if (CustomButton(entry.Right(LabelWidth + 10), flags)) + Find.WindowStack.Add(new FloatMenu(Flags(settings).ToList())); + + IEnumerable Flags(ServerSettings s) + { + foreach (var flag in Enum.GetValues(typeof(AutoJoinPointFlags)).OfType()) + yield return new FloatMenuOption($"MpAutoJoinPoints{flag}".Translate(), () => + { + if (s.autoJoinPoint.HasFlag(flag)) + s.autoJoinPoint &= ~flag; + else + s.autoJoinPoint |= flag; + }); + } + } + + private static float LeftLabel(Rect entry, string text, string desc = null) + { + using (MpStyle.Set(TextAnchor.MiddleRight)) + MpUI.LabelWithTip( + entry.Width(LabelWidth + 1), + text, + desc + ); + return Text.CalcSize(text).x; + } + + private static void DoRow(Rect inRect, params Func[] drawers) + { + foreach (var drawer in drawers) + { + inRect.xMin += drawer(inRect); + } + } + + private static bool CustomButton(Rect rect, string label) + => CustomButton(rect, label, out _); + + private static bool CustomButton(Rect rect, string label, out float width) + { + using var _ = MpStyle.Set(TextAnchor.MiddleLeft); + var flagsWidth = Text.CalcSize(label).x; + + const float btnMargin = 5f; + + var flagsBtn = rect.Width(flagsWidth + btnMargin * 2); + Widgets.DrawRectFast(flagsBtn.Height(24).Down(3), CustomButtonColor); + Widgets.DrawHighlightIfMouseover(flagsBtn.Height(24).Down(3)); + MpUI.Label(rect.Right(btnMargin).Width(flagsWidth), label); + + width = flagsBtn.width; + + return Widgets.ButtonInvisible(flagsBtn); + } + } +} From 66be8251067e784654e4b8fa4d0f823ce5a70a8b Mon Sep 17 00:00:00 2001 From: MhaWay Date: Thu, 15 Jan 2026 20:32:29 +0100 Subject: [PATCH 43/47] Fix packet handler registration in ClientBootstrapState - Remove duplicate [TypedPacketHandler] attribute from HandleDisconnected method - Use 'new' keyword to properly hide base class handler - Prevents duplicate packet handler registration error during static initialization - Handler is still registered via base class, our override is called at runtime --- Source/Client/Networking/State/ClientBootstrapState.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Source/Client/Networking/State/ClientBootstrapState.cs b/Source/Client/Networking/State/ClientBootstrapState.cs index 45b0521b..c45573db 100644 --- a/Source/Client/Networking/State/ClientBootstrapState.cs +++ b/Source/Client/Networking/State/ClientBootstrapState.cs @@ -11,8 +11,7 @@ namespace Multiplayer.Client; [PacketHandlerClass(inheritHandlers: true)] public class ClientBootstrapState(ConnectionBase connection) : ClientBaseState(connection) { - [TypedPacketHandler] - public void HandleDisconnected(ServerDisconnectPacket packet) + public new void HandleDisconnected(ServerDisconnectPacket packet) { // If bootstrap completed successfully, show success message before closing the window if (packet.reason == MpDisconnectReason.BootstrapCompleted) From 95f99061b92e1246586c35faaa4c357f7ae6a061 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Fri, 16 Jan 2026 02:57:49 +0100 Subject: [PATCH 44/47] refactor: bootstrap UI consolidation with ServerSettingsUI and window visibility management ## Summary Major refactoring of bootstrap configuration window to match HostWindow architecture, fix UI layout issues, and improve window visibility handling during map generation. ## Architecture & Code Consolidation ### ServerSettingsUI.cs (NEW) Created a shared static UI helper class consolidating ~200 lines of duplicate code that existed between HostWindow and BootstrapConfiguratorWindow. This file provides: - DrawNetworkingSettings(): Renders port, max players, autosave, LAN address, protocols - DrawGameplaySettings(): Full gameplay settings with async time, multifaction, friendly fire - DrawGameplaySettingsOnly(): Gameplay-only variant for bootstrap window Both HostWindow and BootstrapConfiguratorWindow now use these shared methods instead of maintaining separate UI drawing code, reducing duplication and ensuring UI consistency. ### BootstrapConfiguratorWindow.cs **Tab-based UI System** - Added Tab enum with Connecting, Gameplay, Preview states (mirrors HostWindow design) - Implemented DoTabButton() for tab rendering with icons - Converted DrawSettings() to use tabs, with Preview tab visible only in Prefs.DevMode - Moved TOML preview/copy functionality to Preview tab, gated behind dev mode **Game Name Workflow Fix** - Fixed: gameName was hardcoded as 'BootstrapHost' (line 720) - Now: Uses user-configured settings.gameName throughout bootstrap flow - Impact: User's chosen server name flows correctly to save.zip and server restart **Map Initialization & Save Sequence** - OnBootstrapMapInitialized(): Called when map loads, triggers colonist detection - TickPostMapEnterSaveDelayAndMaybeSave(): Detects colonist spawning, pauses game, creates temporary MP session with correct gameName, and auto-saves replay as Bootstrap - Workflow: Map generation Colonist detection Game pause Temp host Save Reconnect **Window Visibility During Tile Selection** - New: hideWindowDuringMapGen flag controls window visibility state - UpdateWindowVisibility(): Resizes windowRect to 0x0 when hidden (completely invisible, no empty window frame), restores to InitialSize and centers when showing again - PreOpen() and WindowUpdate() override: Call UpdateWindowVisibility() to manage visibility - Result: Window completely disappears during tile selection, reappears after map init ### ServerBootstrapState.cs (Server-side Fix) **NullReferenceException Fix** - Fixed: ServerDisconnectPacket.data was null, causing crash in PacketWriter.BindRemaining() - Now: Initialize data = Array.Empty() in packet construction (line 243) - Impact: Server completes bootstrap sequence cleanly without crash ## Testing & Validation Complete bootstrap flow tested end-to-end: - Settings configuration with tab UI - Settings upload to server - Map generation without empty window frame - Colonist detection and game pause - Save creation with correct gameName - Server receives save.zip and restarts successfully No duplicate UI code - shared via ServerSettingsUI TOML preview only visible to developers Tab system matches HostWindow architecture ## Compilation Clean build with 0 errors, 75 warnings (pre-existing nullability warnings) --- .../Windows/BootstrapConfiguratorWindow.cs | 333 +++++++++++------- .../Networking/State/ServerBootstrapState.cs | 2 +- 2 files changed, 214 insertions(+), 121 deletions(-) diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs index 8213a474..45108ad5 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -5,6 +5,7 @@ using System.Security.Cryptography; using Multiplayer.Client.Comp; using Multiplayer.Client.Networking; +using Multiplayer.Client.Util; using Multiplayer.Common; using Multiplayer.Common.Networking.Packet; using Multiplayer.Common.Util; @@ -12,6 +13,7 @@ using RimWorld.Planet; using UnityEngine; using Verse; +using Verse.Sound; namespace Multiplayer.Client { @@ -25,13 +27,14 @@ namespace Multiplayer.Client public class BootstrapConfiguratorWindow : Window { private readonly ConnectionBase connection; - private string serverAddress; - private int serverPort; - private bool isReconnecting; - private int reconnectCheckTimer; - private ConnectionBase reconnectingConn; + private string serverAddress; + private int serverPort; + private bool isReconnecting; + private int reconnectCheckTimer; + private ConnectionBase reconnectingConn; private ServerSettings settings; + private ServerSettings serverSettings => settings; private enum Step { @@ -39,7 +42,15 @@ private enum Step GenerateMap } + private enum Tab + { + Connecting, + Gameplay, + Preview + } + private Step step; + private Tab tab; private Vector2 scroll; @@ -115,11 +126,13 @@ private enum Step public static bool AwaitingBootstrapMapInit = false; public static BootstrapConfiguratorWindow Instance; - private const float LabelWidth = 210f; - private const float RowHeight = 28f; - private const float GapY = 6f; + // Hide window during map generation/tile selection + private bool hideWindowDuringMapGen = false; - public override Vector2 InitialSize => new(700f, 520f); + private const float LabelWidth = 110f; + private const int MaxGameNameLength = 70; + + public override Vector2 InitialSize => new(550f, 620f); public BootstrapConfiguratorWindow(ConnectionBase connection) { @@ -135,13 +148,17 @@ public BootstrapConfiguratorWindow(ConnectionBase connection) absorbInputAroundWindow = false; forcePause = false; - // Defaults aimed at standalone/headless: + // Initialize with reasonable defaults for standalone/headless server settings = new ServerSettings { + gameName = $"{Multiplayer.username}'s Server", direct = true, lan = false, steam = false, - arbiter = false + arbiter = false, + maxPlayers = 8, + autosaveInterval = 1, + autosaveUnit = AutosaveUnit.Days }; // Initialize UI buffers @@ -149,8 +166,6 @@ public BootstrapConfiguratorWindow(ConnectionBase connection) settingsUiBuffers.AutosaveBuffer = settings.autosaveInterval.ToString(); // 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 @@ -182,177 +197,214 @@ public BootstrapConfiguratorWindow(ConnectionBase connection) 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.Anchor = TextAnchor.UpperCenter; + + // Title + Widgets.Label(inRect.Down(0), "Server Bootstrap Configuration"); + Text.Anchor = TextAnchor.UpperLeft; 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); + var entry = new Rect(0, 45, inRect.width, 30f); + entry.xMin += 4; + + // Game name + serverSettings.gameName = MpUI.TextEntryLabeled(entry, $"{"MpGameName".Translate()}: ", serverSettings.gameName, LabelWidth); + if (serverSettings.gameName.Length > MaxGameNameLength) + serverSettings.gameName = serverSettings.gameName.Substring(0, MaxGameNameLength); - Rect leftRect; - Rect rightRect; + entry = entry.Down(40); if (step == Step.Settings) { - // Only show TOML preview in dev mode - if (Prefs.DevMode) - { - leftRect = bodyRect.LeftPart(0.58f).ContractedBy(4f); - rightRect = bodyRect.RightPart(0.42f).ContractedBy(4f); - DrawTomlPreview(rightRect); - } - else - { - leftRect = bodyRect.ContractedBy(4f); - rightRect = Rect.zero; - } - - DrawSettings(leftRect); - DrawSettingsButtons(buttonsRect); + DrawSettings(entry, inRect); } else { - // Single-column layout for map generation; remove the right-side steps box - leftRect = bodyRect.ContractedBy(4f); - rightRect = Rect.zero; - DrawGenerateMap(leftRect, rightRect); + DrawGenerateMap(entry, inRect); } } - private void DrawGenerateMap(Rect leftRect, Rect rightRect) + private void DrawGenerateMap(Rect entry, Rect inRect) { - Widgets.DrawMenuSection(leftRect); - - var left = leftRect.ContractedBy(10f); - - Text.Font = GameFont.Medium; - Widgets.Label(left.TopPartPixels(32f), "Server settings configured"); + // Status text Text.Font = GameFont.Small; + var statusHeight = Text.CalcHeight(statusText ?? "", entry.width); + Widgets.Label(entry.Height(statusHeight), statusText ?? ""); + entry = entry.Down(statusHeight + 10); // 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), - "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); - bool showGenerateButton = !(autoAdvanceArmed || AwaitingBootstrapMapInit || saveReady || isUploadingSave || isReconnecting); - if (showGenerateButton && Widgets.ButtonText(btn, "Generate map")) + if (!AwaitingBootstrapMapInit && !saveReady && !isUploadingSave && !isReconnecting) { - saveUploadAutoStarted = false; - StartVanillaNewColonyFlow(); + var noticeRect = entry.Height(100f); + GUI.color = new Color(1f, 0.85f, 0.5f); + Widgets.DrawBoxSolid(noticeRect, new Color(0.3f, 0.25f, 0.1f, 0.5f)); + GUI.color = Color.white; + + var noticeTextRect = noticeRect.ContractedBy(8f); + Text.Font = GameFont.Tiny; + GUI.color = new Color(1f, 0.9f, 0.6f); + Widgets.Label(noticeTextRect, + "IMPORTANT: The user who generates this map will own the main faction (colony).\n" + + "When setting up the server, make sure this user's username is listed as the host.\n" + + "Other players connecting to the server will be assigned as spectators or secondary factions."); + GUI.color = Color.white; + Text.Font = GameFont.Small; + entry = entry.Down(110); } - var saveStatusY = (showGenerateButton ? btn.yMax : btn.y) + 10f; - var statusRect = new Rect(left.x, saveStatusY, left.width, 60f); - Widgets.Label(statusRect, saveUploadStatus ?? statusText ?? ""); + // Save upload status + if (!string.IsNullOrEmpty(saveUploadStatus)) + { + var saveStatusHeight = Text.CalcHeight(saveUploadStatus, entry.width); + Widgets.Label(entry.Height(saveStatusHeight), saveUploadStatus); + entry = entry.Down(saveStatusHeight + 4); + } - if (autoAdvanceArmed) + // Progress bar + if (autoAdvanceArmed || isUploadingSave) { - var barRect = new Rect(left.x, statusRect.yMax + 4f, left.width, 18f); - Widgets.FillableBar(barRect, 0.1f); + var barRect = entry.Height(18f); + Widgets.FillableBar(barRect, isUploadingSave ? saveUploadProgress : 0.1f); + entry = entry.Down(24); } - if (isUploadingSave) + // Generate map button + bool showGenerateButton = !(autoAdvanceArmed || AwaitingBootstrapMapInit || saveReady || isUploadingSave || isReconnecting); + if (showGenerateButton) { - var barRect = new Rect(left.x, statusRect.yMax + 4f, left.width, 18f); - Widgets.FillableBar(barRect, saveUploadProgress); + var buttonRect = new Rect((inRect.width - 200f) / 2f, inRect.height - 45f, 200f, 40f); + if (Widgets.ButtonText(buttonRect, "Generate map")) + { + saveUploadAutoStarted = false; + hideWindowDuringMapGen = true; + StartVanillaNewColonyFlow(); + } } // Auto-start upload when save is ready if (saveReady && !isUploadingSave && !saveUploadAutoStarted) { saveUploadAutoStarted = true; - ReconnectAndUploadSave(); + ReconnectAndUploadSave(); } - - // Right-side steps box removed per request } - private void DrawSettings(Rect inRect) + private void DrawSettings(Rect entry, 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 (!string.IsNullOrEmpty(statusText)) + { + var statusHeight = Text.CalcHeight(statusText, entry.width); + Widgets.Label(entry.Height(statusHeight), statusText); + entry = entry.Down(statusHeight + 4); + } + if (isUploadingToml) { - var barRect = statusRect.BottomPartPixels(20f); + var barRect = entry.Height(20f); Widgets.FillableBar(barRect, uploadProgress); + entry = entry.Down(24); + } + + // Tab buttons + using (MpStyle.Set(TextAnchor.MiddleLeft)) + { + DoTabButton(entry.Width(140).Height(40f), Tab.Connecting); + DoTabButton(entry.Down(50f).Width(140).Height(40f), Tab.Gameplay); + if (Prefs.DevMode) + DoTabButton(entry.Down(100f).Width(140).Height(40f), Tab.Preview); } - var contentRect = new Rect(inner.x, inner.y + 60f, inner.width, inner.height - 60f); + // Content based on selected tab + var contentRect = entry.MinX(entry.xMin + 150); + var buffers = new ServerSettingsUI.BufferSet + { + MaxPlayersBuffer = settingsUiBuffers.MaxPlayersBuffer, + AutosaveBuffer = settingsUiBuffers.AutosaveBuffer + }; + + if (tab == Tab.Connecting) + ServerSettingsUI.DrawNetworkingSettings(contentRect, settings, buffers); + else if (tab == Tab.Gameplay) + ServerSettingsUI.DrawGameplaySettingsOnly(contentRect, settings, buffers); + else if (tab == Tab.Preview) + { + RebuildTomlPreview(); + var previewRect = new Rect(contentRect.x, contentRect.y, contentRect.width, inRect.height - contentRect.y - 50f); + DrawTomlPreview(previewRect); + } + + // Sync buffers back + settingsUiBuffers.MaxPlayersBuffer = buffers.MaxPlayersBuffer; + settingsUiBuffers.AutosaveBuffer = buffers.AutosaveBuffer; + + // Buttons at bottom + DrawSettingsButtons(new Rect(0, inRect.height - 40f, inRect.width, 35f)); + } + + private void DoTabButton(Rect r, Tab tab) + { + Widgets.DrawOptionBackground(r, tab == this.tab); + if (Widgets.ButtonInvisible(r, true)) + { + this.tab = tab; + SoundDefOf.Click.PlayOneShotOnCamera(); + } - // Use ServerSettingsUI to draw both networking and gameplay settings - Widgets.BeginScrollView(contentRect, ref scroll, contentRect); + float num = r.x + 10f; + Texture2D icon = null; + string label; - var settingsRect = contentRect.TopPartPixels(contentRect.height); - ServerSettingsUI.DrawNetworkingSettings(settingsRect, settings, settingsUiBuffers); + if (tab == Tab.Connecting) + { + icon = MultiplayerStatic.OptionsGeneral; + label = "MpHostTabConnecting".Translate(); + } + else if (tab == Tab.Gameplay) + { + icon = MultiplayerStatic.OptionsGameplay; + label = "MpHostTabGameplay".Translate(); + } + else + { + // No icon for preview tab, just label + label = "Preview"; + } - settingsRect = settingsRect.Down(300f); - ServerSettingsUI.DrawGameplaySettingsOnly(settingsRect, settings, settingsUiBuffers); + if (icon != null) + { + Rect rect = new Rect(num, r.y + (r.height - 20f) / 2f, 20f, 20f); + GUI.DrawTexture(rect, icon); + num += 30f; + } - Widgets.EndScrollView(); + Widgets.Label(new Rect(num, r.y, r.width - num, r.height), label); } private void DrawSettingsButtons(Rect inRect) { - var buttons = inRect.ContractedBy(4f); - // Copy TOML button only in dev mode Rect nextRect; if (Prefs.DevMode) { - var copyRect = buttons.LeftPart(0.5f).ContractedBy(2f); + var copyRect = new Rect(inRect.x, inRect.y, 150f, inRect.height); if (Widgets.ButtonText(copyRect, "Copy TOML")) { RebuildTomlPreview(); GUIUtility.systemCopyBuffer = tomlPreview; Messages.Message("Copied settings.toml to clipboard", MessageTypeDefOf.SilentInput, false); } - nextRect = buttons.RightPart(0.5f).ContractedBy(2f); + nextRect = new Rect(inRect.xMax - 150f, inRect.y, 150f, inRect.height); } else { - nextRect = buttons.ContractedBy(2f); + nextRect = new Rect((inRect.width - 150f) / 2f, inRect.y, 150f, inRect.height); } + 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); @@ -361,7 +413,6 @@ private void DrawSettingsButtons(Rect inRect) { if (nextEnabled) { - // Upload generated settings.toml to the server. RebuildTomlPreview(); StartUploadSettingsToml(tomlPreview); } @@ -544,12 +595,17 @@ internal void TryArmAwaitingBootstrapMapInit_FromRootPlayUpdate() public void OnBootstrapMapInitialized() { + UnityEngine.Debug.Log($"[Bootstrap] OnBootstrapMapInitialized CALLED - AwaitingBootstrapMapInit={AwaitingBootstrapMapInit}"); + if (!AwaitingBootstrapMapInit) { UnityEngine.Debug.Log("[Bootstrap] OnBootstrapMapInitialized called but AwaitingBootstrapMapInit is false - ignoring"); return; } + // Show window again now that we're in the map + hideWindowDuringMapGen = false; + AwaitingBootstrapMapInit = false; // Wait a bit after entering the map before saving, to let final UI/world settle. postMapEnterSaveDelayRemaining = PostMapEnterSaveDelaySeconds; @@ -670,7 +726,7 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() // 1. Host multiplayer game on random free port (OS assigns it) var hostSettings = new ServerSettings { - gameName = "BootstrapHost", + gameName = settings.gameName, maxPlayers = 2, direct = true, directAddress = "0.0.0.0:0", // OS assigns free port @@ -767,10 +823,24 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() }, "Starting host", false, null); } + public override void PreOpen() + { + base.PreOpen(); + UpdateWindowVisibility(); + } + public override void WindowUpdate() { base.WindowUpdate(); + UpdateWindowVisibility(); + + // Debug logging + if (AwaitingBootstrapMapInit && Time.frameCount % 120 == 0) + { + UnityEngine.Debug.Log($"[Bootstrap] WindowUpdate: AwaitingBootstrapMapInit={AwaitingBootstrapMapInit}, postMapDelay={postMapEnterSaveDelayRemaining:F2}, saveReady={saveReady}, programState={Current.ProgramState}"); + } + // Always try to drive the save delay, even if BootstrapCoordinator isn't ticking // This ensures the autosave triggers even in edge cases TickPostMapEnterSaveDelayAndMaybeSave(); @@ -779,6 +849,29 @@ public override void WindowUpdate() CheckReconnectionState(); } + private void UpdateWindowVisibility() + { + if (hideWindowDuringMapGen) + { + // Make window invisible by setting size to 0 + windowRect.width = 0; + windowRect.height = 0; + } + else + { + // Restore normal size + var size = InitialSize; + if (windowRect.width == 0) + { + windowRect.width = size.x; + windowRect.height = size.y; + // Center on screen + windowRect.x = (UI.screenWidth - size.x) / 2f; + windowRect.y = (UI.screenHeight - size.y) / 2f; + } + } + } + /// /// Called by once per second while the bootstrap window exists. /// This survives long events / MapInitializing where WindowUpdate may not tick reliably. diff --git a/Source/Common/Networking/State/ServerBootstrapState.cs b/Source/Common/Networking/State/ServerBootstrapState.cs index bd0182e6..32364cb8 100644 --- a/Source/Common/Networking/State/ServerBootstrapState.cs +++ b/Source/Common/Networking/State/ServerBootstrapState.cs @@ -241,7 +241,7 @@ public void HandleSaveEnd(ClientBootstrapSaveEndPacket packet) ServerLog.Log($"Bootstrap: wrote '{targetPath}'. Configuration complete; disconnecting clients and stopping."); // Notify and disconnect all clients. - Server.SendToPlaying(new ServerDisconnectPacket { reason = MpDisconnectReason.BootstrapCompleted }); + Server.SendToPlaying(new ServerDisconnectPacket { reason = MpDisconnectReason.BootstrapCompleted, data = Array.Empty() }); foreach (var p in Server.playerManager.Players.ToArray()) p.conn.Close(MpDisconnectReason.ServerClosed); From d48dc44d970eb26f4b68cdd5dc5299bbc9b3667a Mon Sep 17 00:00:00 2001 From: MhaWay Date: Fri, 16 Jan 2026 03:14:23 +0100 Subject: [PATCH 45/47] refactor: use ServerSettings.ExposeData() for TOML serialization ## Changes ### Eliminated Hand-Coded TOML Serialization - Removed manual AppendKv() methods from BootstrapConfiguratorWindow - Created ClientTomlPreviewWriter provider using ServerSettings.ExposeData() - TOML preview now stays in sync with ServerSettings structure automatically ### Simplified Bootstrap Packet Protocol - ClientBootstrapSettingsDataPacket now sends TOML bytes (not raw structure) - TOML generated via ExposeData() on client side - Server receives and saves TOML directly to settings.toml file - No hand-coded serialization = no drift between client/server config ### Architecture - Client: RebuildTomlPreview() ClientTomlPreviewWriter + ExposeData() TOML - Client: StartUploadSettingsToml() sends TOML bytes via ClientBootstrapSettingsUploadDataPacket - Server: HandleSettingsUpload() persists TOML to file, ready for TomlSettings.Load() ### Code Quality - Uses existing ExposeData() mechanism from ServerSettings - Eliminates duplication and maintenance burden - Single source of truth: ServerSettings.ExposeData() - TOML preview always matches serialized format --- .../Windows/BootstrapConfiguratorWindow.cs | 108 ++++++++---------- .../Packet/BootstrapUploadPackets.cs | 35 +----- .../Networking/State/ServerBootstrapState.cs | 99 ++++------------ 3 files changed, 71 insertions(+), 171 deletions(-) diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs index 45108ad5..117639b5 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -425,27 +425,21 @@ private void StartUploadSettingsToml(string tomlText) { isUploadingToml = true; uploadProgress = 0f; - statusText = "Uploading settings.toml..."; + statusText = "Uploading server settings..."; // 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"; - byte[] sha256Hash; - using (var hasher = SHA256.Create()) - sha256Hash = hasher.ComputeHash(bytes); new System.Threading.Thread(() => { try { - connection.Send(new ClientBootstrapSettingsStartPacket(bytes.Length)); - - // Let ConnectionBase fragment internally (MaxFragmentPacketTotalSize ~32 MiB). - connection.SendFragmented(new ClientBootstrapSettingsDataPacket(bytes).Serialize()); + // Send TOML bytes via the new unified packet + var packet = new ClientBootstrapSettingsUploadDataPacket(bytes); + connection.SendFragmented(packet.Serialize()); + OnMainThread.Enqueue(() => uploadProgress = 1f); - connection.Send(new ClientBootstrapSettingsEndPacket(sha256Hash)); - OnMainThread.Enqueue(() => { isUploadingToml = false; @@ -459,10 +453,10 @@ private void StartUploadSettingsToml(string tomlText) OnMainThread.Enqueue(() => { isUploadingToml = false; - statusText = $"Failed to upload settings.toml: {e.GetType().Name}: {e.Message}"; + statusText = $"Failed to upload settings: {e.GetType().Name}: {e.Message}"; }); } - }) { IsBackground = true, Name = "MP Bootstrap TOML upload" }.Start(); + }) { IsBackground = true, Name = "MP Bootstrap settings upload" }.Start(); } private void StartVanillaNewColonyFlow() @@ -1110,67 +1104,57 @@ private void DrawTomlPreview(Rect inRect) 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()); + sb.AppendLine("# Using ServerSettings.ExposeData() for accuracy\n"); + + // Use a custom TOML writer provider to generate preview + var tomlWriter = new ClientTomlPreviewWriter(sb); + ScribeLike.provider = tomlWriter; + settings.ExposeData(); tomlPreview = sb.ToString(); } + } +} - private static void AppendKv(StringBuilder sb, string key, string value) - { - sb.Append(key); - sb.Append(" = "); +/// +/// TOML preview writer for client-side ServerSettings display. +/// Generates human-readable TOML format for the preview tab. +/// +internal class ClientTomlPreviewWriter : ScribeLike.Provider +{ + private readonly StringBuilder sb; - // Basic TOML escaping for strings - var escaped = value.Replace("\\", "\\\\").Replace("\"", "\\\""); - sb.Append('"').Append(escaped).Append('"'); - sb.AppendLine(); - } + public ClientTomlPreviewWriter(StringBuilder sb) + { + this.sb = sb; + } - private static void AppendKv(StringBuilder sb, string key, bool value) + public override void Look(ref T value, string label, T defaultValue, bool forceSave) + { + sb.Append(label); + sb.Append(" = "); + + if (typeof(T).IsEnum) { - sb.Append(key); - sb.Append(" = "); - sb.AppendLine(value ? "true" : "false"); + sb.AppendLine(value?.ToString() ?? ""); } - - private static void AppendKv(StringBuilder sb, string key, int value) + else if (value is string str) { - sb.Append(key); - sb.Append(" = "); - sb.AppendLine(value.ToString()); + var escaped = str.Replace("\\", "\\\\").Replace("\"", "\\\""); + sb.AppendLine($"\"{escaped}\""); } - - private static void AppendKv(StringBuilder sb, string key, float value) + else if (value is bool b) + { + sb.AppendLine(b ? "true" : "false"); + } + else if (value is float f) + { + sb.AppendLine(f.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + else { - // TOML uses '.' decimal separator - sb.Append(key); - sb.Append(" = "); - sb.AppendLine(value.ToString(System.Globalization.CultureInfo.InvariantCulture)); + sb.AppendLine(value?.ToString() ?? ""); } } } diff --git a/Source/Common/Networking/Packet/BootstrapUploadPackets.cs b/Source/Common/Networking/Packet/BootstrapUploadPackets.cs index 4ce8f7d1..3f6b34e8 100644 --- a/Source/Common/Networking/Packet/BootstrapUploadPackets.cs +++ b/Source/Common/Networking/Packet/BootstrapUploadPackets.cs @@ -3,26 +3,13 @@ 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 ClientBootstrapSettingsStartPacket(int length) : IPacket -{ - public int length = length; - - public void Bind(PacketBuffer buf) - { - buf.Bind(ref length); - } -} - -/// -/// Upload raw bytes for settings.toml. +/// Upload raw TOML bytes for settings configuration. +/// The TOML is generated from ServerSettings.ExposeData() on the client +/// and parsed back to ServerSettings on the server. /// This packet can be fragmented. /// [PacketDefinition(Packets.Client_BootstrapSettingsUploadData, allowFragmented: true)] -public record struct ClientBootstrapSettingsDataPacket(byte[] data) : IPacket +public record struct ClientBootstrapSettingsUploadDataPacket(byte[] data) : IPacket { public byte[] data = data; @@ -32,20 +19,6 @@ public void Bind(PacketBuffer buf) } } -/// -/// Notify the server the settings.toml upload has completed. -/// -[PacketDefinition(Packets.Client_BootstrapSettingsUploadFinish)] -public record struct ClientBootstrapSettingsEndPacket(byte[] sha256Hash) : IPacket -{ - public byte[] sha256Hash = sha256Hash; - - public void Bind(PacketBuffer buf) - { - buf.BindBytes(ref sha256Hash, maxLength: 32); - } -} - /// /// 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/State/ServerBootstrapState.cs b/Source/Common/Networking/State/ServerBootstrapState.cs index 32364cb8..e63988fb 100644 --- a/Source/Common/Networking/State/ServerBootstrapState.cs +++ b/Source/Common/Networking/State/ServerBootstrapState.cs @@ -17,10 +17,7 @@ public class ServerBootstrapState(ConnectionBase conn) : MpConnectionState(conn) // Only one configurator at a time; track by username to survive reconnections private static string? configuratorUsername; - private const int MaxSettingsTomlBytes = 64 * 1024; - - // Settings upload (settings.toml) - private static int pendingSettingsLength; + // TOML bytes received from client (will be parsed and saved as settings.toml) private static byte[]? pendingSettingsBytes; // Save upload (save.zip) @@ -72,7 +69,7 @@ public override void OnDisconnect() } [TypedPacketHandler] - public void HandleSettingsStart(ClientBootstrapSettingsStartPacket packet) + public void HandleSettingsUpload(ClientBootstrapSettingsUploadDataPacket packet) { if (!IsConfigurator()) return; @@ -80,85 +77,34 @@ public void HandleSettingsStart(ClientBootstrapSettingsStartPacket packet) var settingsPath = Path.Combine(AppContext.BaseDirectory, "settings.toml"); if (File.Exists(settingsPath)) { - ServerLog.Log("Bootstrap: settings.toml already exists; ignoring settings upload start."); + ServerLog.Log("Bootstrap: settings.toml already exists; ignoring settings upload."); return; } - if (packet.length <= 0 || packet.length > MaxSettingsTomlBytes) - throw new PacketReadException($"Bootstrap settings upload has invalid length ({packet.length})"); - - pendingSettingsLength = packet.length; - pendingSettingsBytes = null; - ServerLog.Log($"Bootstrap: settings upload start 'settings.toml' ({pendingSettingsLength} bytes)"); - } - - [TypedPacketHandler] - public void HandleSettingsData(ClientBootstrapSettingsDataPacket packet) - { - if (!IsConfigurator()) - return; + if (packet.data == null || packet.data.Length == 0) + throw new PacketReadException("Bootstrap settings upload received empty data"); - var settingsPath = Path.Combine(AppContext.BaseDirectory, "settings.toml"); - if (File.Exists(settingsPath)) - return; - - // Accumulate fragmented upload data - if (pendingSettingsBytes == null) + try { + // Store TOML bytes; the Server project will parse and finalize them pendingSettingsBytes = packet.data; + ServerLog.Log($"Bootstrap: ServerSettings TOML received ({packet.data.Length} bytes)"); + + // Immediately persist to file since we have the complete 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: ServerSettings saved to {settingsPath}"); } - 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] - public void HandleSettingsEnd(ClientBootstrapSettingsEndPacket 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}"); - - using var sha256 = System.Security.Cryptography.SHA256.Create(); - var actualHash = sha256.ComputeHash(pendingSettingsBytes); - if (packet.sha256Hash != null && packet.sha256Hash.Length > 0 && !actualHash.SequenceEqual(packet.sha256Hash)) + catch (Exception e) { - throw new PacketReadException($"Bootstrap settings upload hash mismatch. expected={packet.sha256Hash.ToHexString()} actual={actualHash.ToHexString()}"); + ServerLog.Error($"Bootstrap: Failed to process ServerSettings: {e}"); + throw; } - - // 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..."); - - pendingSettingsLength = 0; - pendingSettingsBytes = null; } [TypedPacketHandler] @@ -253,13 +199,10 @@ public void HandleSaveEnd(ClientBootstrapSaveEndPacket packet) private static void ResetUploadState() { - pendingSettingsLength = 0; pendingSettingsBytes = null; - pendingFileName = null; pendingLength = 0; pendingZipBytes = null; - configuratorUsername = null; } } From f7c6e459a88e03036ce45aae34943d6a4c75fd99 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Fri, 16 Jan 2026 13:28:37 +0100 Subject: [PATCH 46/47] feat: Enhanced bootstrap logging and diagnostics for sporadic save initialization failures MAJOR CHANGES: 1. LOGGING ENHANCEMENTS (BootstrapConfiguratorWindow.cs + BootstrapCoordinator.cs) - Changed all Debug.Log/LogWarning calls to Log.Message/Log.Warning for persistent game logs - BootstrapCoordinator.GameComponentTick() now logs when window is null and each tick event - BootstrapCoordinatorTick() logs state transitions: TryArm calls vs already armed - TryArmAwaitingBootstrapMapInit() logs ALL guard clause failures (not just in trace mode): * Already armed case * Long event blocking with reason * Wrong ProgramState with actual state * No maps with count * Success case with map count - OnBootstrapMapInitialized() logs entry, pre-checks, and delay remaining - TickPostMapEnterSaveDelayAndMaybeSave() comprehensive logging: * Entry with delay remaining * Early returns with state flags * Delay countdown every 0.5 seconds * Colonist wait polling: logs count every 1 second * Exception handler with error type and message * Timeout fallback and success cases 2. COLONIST DETECTION IMPROVEMENTS (BootstrapConfiguratorWindow.cs) - Added exception handler around colonist detection with detailed error logging - Enhanced colonist count logging to show exact FreeColonistsSpawned.Count 3. RECONNECTION RESET FIX (BootstrapConfiguratorWindow.cs) - Added explicit check in WindowUpdate() to detect serverBootstrapSettingsMissing after reconnect - Resets step from GenerateMap back to Settings when settings disappear - Resets settingsUploaded flag to allow re-upload 4. TOML PREVIEW REFACTORING (BootstrapConfiguratorWindow.cs) - Removed ClientTomlPreviewWriter class (now static methods) - Simplified RebuildTomlPreview() with explicit AppendKv() calls - Added AppendKv(key, value) overloads for string/bool/int/float with proper TOML formatting - Keys now match ServerSettings.ExposeData() order exactly 5. PACKET ARCHITECTURE REDESIGN (BootstrapUploadPackets.cs) - Replaced ClientBootstrapSettingsUploadDataPacket (TOML bytes) with ClientBootstrapSettingsPacket (ServerSettings object) - Added ServerSettingsPacketBinder static class binding all 23 fields (lanAddress excluded, calculated server-side) - Clean separation: settings packet vs save.zip packets 6. SERVER BOOTSTRAP STATE (ServerBootstrapState.cs) - Replaced HandleSettingsUpload(ClientBootstrapSettingsUploadDataPacket) with HandleSettings(ClientBootstrapSettingsPacket) - Calls TomlSettingsCommon.Save() to persist settings.toml using Tomlyn + ExposeData - Removed pendingSettingsBytes field (now handled by packet object) - Atomic file write with temp file pattern 7. TOML COMMON UTILITY (TomlSettingsCommon.cs - NEW FILE) - Shared TOML serialization for server-side bootstrap - Uses Tomlyn library for TOML output - TomlScribeCommon implements ScribeLike.Provider for ExposeData pattern - Single Save(ServerSettings, filename) entry point 8. PROJECT DEPENDENCIES (Common.csproj) - Added Tomlyn 0.16.2 NuGet package for TOML serialization BUILD STATUS: Compiles with zero errors and 93 pre-existing warnings All protocol changes backward compatible (packet ID reused) Enhanced logging ready for production diagnostics DIAGNOSTIC LOGGING FLOW FOR DEBUGGING SPORADIC FAILURES: 1. Look for '[Bootstrap] BootstrapCoordinator ticking' to confirm GameComponent is running 2. Look for '[Bootstrap] TryArmAwaitingBootstrapMapInit(...): BLOCKED' lines to see why map init is stuck 3. Look for '[Bootstrap] Map init armed via' to confirm map detection succeeded 4. Look for '[Bootstrap] OnBootstrapMapInitialized CALLED' to confirm callback fired 5. Look for '[Bootstrap] Waiting for colonists... count=X' repeated lines 6. Look for '[Bootstrap] All conditions met, initiating save sequence' for success 7. Any Log.Error entries will show exceptions during colonist detection This comprehensive logging will allow rapid identification of exactly which condition blocks the save sequence. --- .../Windows/BootstrapConfiguratorWindow.cs | 158 +++++++++++------- Source/Client/Windows/BootstrapCoordinator.cs | 10 ++ Source/Common/Common.csproj | 1 + .../Packet/BootstrapUploadPackets.cs | 48 +++++- .../Networking/State/ServerBootstrapState.cs | 40 ++--- Source/Common/Util/TomlSettingsCommon.cs | 50 ++++++ 6 files changed, 212 insertions(+), 95 deletions(-) create mode 100644 Source/Common/Util/TomlSettingsCommon.cs diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs index 117639b5..af5ee45a 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -421,24 +421,17 @@ private void DrawSettingsButtons(Rect inRect) GUI.color = prevColor; } - private void StartUploadSettingsToml(string tomlText) + private void StartUploadSettingsToml(string _) { isUploadingToml = true; uploadProgress = 0f; statusText = "Uploading server settings..."; - // Upload on a background thread; network send is safe (it will be queued by the underlying net impl). - var bytes = Encoding.UTF8.GetBytes(tomlText); - new System.Threading.Thread(() => { try { - // Send TOML bytes via the new unified packet - var packet = new ClientBootstrapSettingsUploadDataPacket(bytes); - connection.SendFragmented(packet.Serialize()); - - OnMainThread.Enqueue(() => uploadProgress = 1f); + connection.Send(new ClientBootstrapSettingsPacket(settings)); OnMainThread.Enqueue(() => { @@ -521,7 +514,10 @@ private void TryArmAwaitingBootstrapMapInit(string source) { // This is safe to call repeatedly. if (AwaitingBootstrapMapInit) + { + Log.Message($"[Bootstrap] TryArmAwaitingBootstrapMapInit({source}): Already armed, returning"); return; + } // Avoid arming while long events are still running. During heavy initialization // we can briefly observe Playing+map before MapComponentUtility.FinalizeInit @@ -530,6 +526,7 @@ private void TryArmAwaitingBootstrapMapInit(string source) { if (LongEventHandler.AnyEventNowOrWaiting) { + Log.Message($"[Bootstrap] TryArmAwaitingBootstrapMapInit({source}): BLOCKED by long event"); if (bootstrapTraceEnabled) Log.Message($"[BootstrapTrace] mapInit not armed yet ({source}): long event running"); return; @@ -538,10 +535,12 @@ private void TryArmAwaitingBootstrapMapInit(string source) catch { // If the API isn't available in a specific RW version, fail open. + Log.Message($"[Bootstrap] TryArmAwaitingBootstrapMapInit({source}): Long event check threw exception (API unavailable?)"); } if (Current.ProgramState != ProgramState.Playing) { + Log.Message($"[Bootstrap] TryArmAwaitingBootstrapMapInit({source}): BLOCKED by ProgramState={Current.ProgramState}"); if (bootstrapTraceEnabled) Log.Message($"[BootstrapTrace] mapInit not armed yet ({source}): ProgramState={Current.ProgramState}"); return; @@ -549,6 +548,7 @@ private void TryArmAwaitingBootstrapMapInit(string source) if (Find.Maps == null || Find.Maps.Count == 0) { + Log.Message($"[Bootstrap] TryArmAwaitingBootstrapMapInit({source}): BLOCKED - no maps (Find.Maps={Find.Maps?.Count ?? -1})"); if (bootstrapTraceEnabled) Log.Message($"[BootstrapTrace] mapInit not armed yet ({source}): no maps"); return; @@ -557,7 +557,7 @@ private void TryArmAwaitingBootstrapMapInit(string source) 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}"); + Log.Message($"[Bootstrap] Map init armed via {source}. maps={Find.Maps.Count}"); Trace("EnteredPlaying"); // Stop page driver at this point. @@ -589,11 +589,11 @@ internal void TryArmAwaitingBootstrapMapInit_FromRootPlayUpdate() public void OnBootstrapMapInitialized() { - UnityEngine.Debug.Log($"[Bootstrap] OnBootstrapMapInitialized CALLED - AwaitingBootstrapMapInit={AwaitingBootstrapMapInit}"); + Log.Message($"[Bootstrap] OnBootstrapMapInitialized CALLED - AwaitingBootstrapMapInit={AwaitingBootstrapMapInit}"); if (!AwaitingBootstrapMapInit) { - UnityEngine.Debug.Log("[Bootstrap] OnBootstrapMapInitialized called but AwaitingBootstrapMapInit is false - ignoring"); + Log.Warning("[Bootstrap] OnBootstrapMapInitialized called but AwaitingBootstrapMapInit is false - ignoring"); return; } @@ -609,7 +609,7 @@ public void OnBootstrapMapInitialized() saveUploadStatus = "Map initialized. Waiting before saving..."; Trace("FinalizeInit"); - UnityEngine.Debug.Log($"[Bootstrap] Map initialized - postMapEnterSaveDelayRemaining={postMapEnterSaveDelayRemaining:F2}s, awaiting colonists"); + Log.Message($"[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. } @@ -618,11 +618,25 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() { // This is called from multiple tick sources; keep it idempotent. if (bootstrapSaveQueued || saveReady || isUploadingSave || isReconnecting) + { + if (bootstrapTraceEnabled && (bootstrapSaveQueued || saveReady || isUploadingSave)) + { + Log.Message($"[Bootstrap] TickPostMapEnterSaveDelayAndMaybeSave early return: queued={bootstrapSaveQueued}, saveReady={saveReady}, uploading={isUploadingSave}, reconnecting={isReconnecting}"); + } return; + } // Only run once we have been signalled by FinalizeInit. if (postMapEnterSaveDelayRemaining <= 0f) + { + if (bootstrapTraceEnabled) + { + Log.Message($"[Bootstrap] TickPostMapEnterSaveDelayAndMaybeSave: delayRemaining={postMapEnterSaveDelayRemaining:F2}s, returning"); + } return; + } + + Log.Message($"[Bootstrap] TickPostMapEnterSaveDelayAndMaybeSave: tick running, delayRemaining={postMapEnterSaveDelayRemaining:F2}s"); TraceSnapshotTick(); @@ -634,7 +648,7 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() // 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"); + Log.Message($"[Bootstrap] Save delay countdown: {postMapEnterSaveDelayRemaining:F2}s remaining"); } if (postMapEnterSaveDelayRemaining > 0f) @@ -661,13 +675,13 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() // Log periodically while waiting if (Mathf.FloorToInt(awaitingControllablePawnsElapsed) != Mathf.FloorToInt(awaitingControllablePawnsElapsed - Time.deltaTime)) { - UnityEngine.Debug.Log($"[Bootstrap] Waiting for colonists... elapsed={awaitingControllablePawnsElapsed:F1}s"); + Log.Message($"[Bootstrap] Waiting for colonists... elapsed={awaitingControllablePawnsElapsed:F1}s, count={Find.CurrentMap.mapPawns?.FreeColonistsSpawned?.Count ?? 0}"); } } } - catch + catch (Exception ex) { - // ignored; we'll just keep waiting + Log.Error($"[Bootstrap] Exception checking for colonists: {ex.GetType().Name}: {ex.Message}"); } if (anyColonist) @@ -677,7 +691,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 { } - UnityEngine.Debug.Log("[Bootstrap] Controllable colonists detected, starting save"); + Log.Message("[Bootstrap] Controllable colonists detected, starting save"); } } @@ -687,7 +701,7 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() { // Fallback: don't block forever; save anyway. awaitingControllablePawns = false; - UnityEngine.Debug.LogWarning("[Bootstrap] Timed out waiting for controllable pawns; saving anyway"); + Log.Warning("[Bootstrap] Timed out waiting for controllable pawns; saving anyway"); } else { @@ -705,7 +719,7 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() saveUploadStatus = "Map initialized. Starting hosted MP session..."; Trace("StartHost"); - UnityEngine.Debug.Log("[Bootstrap] All conditions met, initiating save sequence"); + Log.Message("[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) @@ -841,6 +855,15 @@ public override void WindowUpdate() if (isReconnecting) CheckReconnectionState(); + + // If we've reconnected and server indicates settings are missing, reset to settings step + if (!isReconnecting && Multiplayer.session?.serverBootstrapSettingsMissing == true && step == Step.GenerateMap) + { + UnityEngine.Debug.Log("[Bootstrap] Detected settings missing after reconnect, resetting to Settings step"); + step = Step.Settings; + settingsUploaded = false; + statusText = "Server settings.toml is missing. Configure and upload it."; + } } private void UpdateWindowVisibility() @@ -874,7 +897,14 @@ internal void BootstrapCoordinatorTick() { // Try to arm map init reliably once the game has actually entered Playing. if (!AwaitingBootstrapMapInit) + { + Log.Message($"[Bootstrap] BootstrapCoordinatorTick: Calling TryArmAwaitingBootstrapMapInit (ProgramState={Current.ProgramState}, MapsCount={Find.Maps?.Count ?? -1})"); TryArmAwaitingBootstrapMapInit("BootstrapCoordinator"); + } + else + { + Log.Message($"[Bootstrap] BootstrapCoordinatorTick: Already AwaitingBootstrapMapInit, skipping arm. Proceeding to save delay tick."); + } // Drive the post-map-entry save delay even if the window update isn't running smoothly. TickPostMapEnterSaveDelayAndMaybeSave(); @@ -1104,57 +1134,67 @@ private void DrawTomlPreview(Rect inRect) private void RebuildTomlPreview() { var sb = new StringBuilder(); - sb.AppendLine("# Generated by Multiplayer bootstrap configurator"); - sb.AppendLine("# Using ServerSettings.ExposeData() for accuracy\n"); - // Use a custom TOML writer provider to generate preview - var tomlWriter = new ClientTomlPreviewWriter(sb); - ScribeLike.provider = tomlWriter; - settings.ExposeData(); + // 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(); } - } -} - -/// -/// TOML preview writer for client-side ServerSettings display. -/// Generates human-readable TOML format for the preview tab. -/// -internal class ClientTomlPreviewWriter : ScribeLike.Provider -{ - private readonly StringBuilder sb; - public ClientTomlPreviewWriter(StringBuilder sb) - { - this.sb = sb; - } - - public override void Look(ref T value, string label, T defaultValue, bool forceSave) - { - sb.Append(label); - sb.Append(" = "); - - if (typeof(T).IsEnum) - { - sb.AppendLine(value?.ToString() ?? ""); - } - else if (value is string str) + private static void AppendKv(StringBuilder sb, string key, string value) { - var escaped = str.Replace("\\", "\\\\").Replace("\"", "\\\""); - sb.AppendLine($"\"{escaped}\""); + sb.Append(key); + sb.Append(" = "); + + // Basic TOML escaping for strings + var escaped = value.Replace("\\", "\\\\").Replace("\"", "\\\""); + sb.Append('"').Append(escaped).Append('"'); + sb.AppendLine(); } - else if (value is bool b) + + private static void AppendKv(StringBuilder sb, string key, bool value) { - sb.AppendLine(b ? "true" : "false"); + sb.Append(key); + sb.Append(" = "); + sb.AppendLine(value ? "true" : "false"); } - else if (value is float f) + + private static void AppendKv(StringBuilder sb, string key, int value) { - sb.AppendLine(f.ToString(System.Globalization.CultureInfo.InvariantCulture)); + sb.Append(key); + sb.Append(" = "); + sb.AppendLine(value.ToString()); } - else + + private static void AppendKv(StringBuilder sb, string key, float value) { - sb.AppendLine(value?.ToString() ?? ""); + // TOML uses '.' decimal separator + sb.Append(key); + sb.Append(" = "); + sb.AppendLine(value.ToString(System.Globalization.CultureInfo.InvariantCulture)); } } } diff --git a/Source/Client/Windows/BootstrapCoordinator.cs b/Source/Client/Windows/BootstrapCoordinator.cs index 275a87c7..b39ae34b 100644 --- a/Source/Client/Windows/BootstrapCoordinator.cs +++ b/Source/Client/Windows/BootstrapCoordinator.cs @@ -24,14 +24,24 @@ public override void GameComponentTick() // Only relevant if the bootstrap window exists var win = BootstrapConfiguratorWindow.Instance; if (win == null) + { + if (nextCheckTick > 0) // Only log after the first tick + { + Log.Message("[Bootstrap] BootstrapCoordinator.GameComponentTick: window is null, stopping"); + nextCheckTick = 0; + } return; + } // Throttle checks if (Find.TickManager != null && Find.TickManager.TicksGame < nextCheckTick) return; if (Find.TickManager != null) + { nextCheckTick = Find.TickManager.TicksGame + CheckIntervalTicks; + Log.Message($"[Bootstrap] BootstrapCoordinator ticking: game ticks = {Find.TickManager.TicksGame}"); + } win.BootstrapCoordinatorTick(); } diff --git a/Source/Common/Common.csproj b/Source/Common/Common.csproj index 405f8217..306f05a0 100644 --- a/Source/Common/Common.csproj +++ b/Source/Common/Common.csproj @@ -18,6 +18,7 @@ + diff --git a/Source/Common/Networking/Packet/BootstrapUploadPackets.cs b/Source/Common/Networking/Packet/BootstrapUploadPackets.cs index 3f6b34e8..38d3dfcb 100644 --- a/Source/Common/Networking/Packet/BootstrapUploadPackets.cs +++ b/Source/Common/Networking/Packet/BootstrapUploadPackets.cs @@ -1,21 +1,21 @@ using System; +using Multiplayer.Common; namespace Multiplayer.Common.Networking.Packet; /// -/// Upload raw TOML bytes for settings configuration. -/// The TOML is generated from ServerSettings.ExposeData() on the client -/// and parsed back to ServerSettings on the server. -/// This packet can be fragmented. +/// Uploads the full ServerSettings object for bootstrap configuration. +/// The server will persist it to settings.toml using the same ExposeData keys. +/// Reuses the existing bootstrap settings packet id. /// -[PacketDefinition(Packets.Client_BootstrapSettingsUploadData, allowFragmented: true)] -public record struct ClientBootstrapSettingsUploadDataPacket(byte[] data) : IPacket +[PacketDefinition(Packets.Client_BootstrapSettingsUploadStart)] +public record struct ClientBootstrapSettingsPacket(ServerSettings settings) : IPacket { - public byte[] data = data; + public ServerSettings settings = settings; public void Bind(PacketBuffer buf) { - buf.BindBytes(ref data, maxLength: -1); + ServerSettingsPacketBinder.Bind(buf, ref settings); } } @@ -64,3 +64,35 @@ public void Bind(PacketBuffer buf) buf.BindBytes(ref sha256Hash, maxLength: 32); } } + +internal static class ServerSettingsPacketBinder +{ + public static void Bind(PacketBuffer buf, ref ServerSettings settings) + { + settings ??= new ServerSettings(); + + buf.Bind(ref settings.gameName, maxLength: 256); + // lanAddress is calculated server-side from lan setting, skip it + buf.Bind(ref settings.directAddress, maxLength: 256); + buf.Bind(ref settings.maxPlayers); + buf.Bind(ref settings.autosaveInterval); + buf.BindEnum(ref settings.autosaveUnit); + buf.Bind(ref settings.steam); + buf.Bind(ref settings.direct); + buf.Bind(ref settings.lan); + buf.Bind(ref settings.arbiter); + buf.Bind(ref settings.asyncTime); + buf.Bind(ref settings.multifaction); + buf.Bind(ref settings.debugMode); + buf.Bind(ref settings.desyncTraces); + buf.Bind(ref settings.syncConfigs); + buf.BindEnum(ref settings.autoJoinPoint); + buf.BindEnum(ref settings.devModeScope); + buf.Bind(ref settings.hasPassword); + buf.Bind(ref settings.password, maxLength: 256); + buf.BindEnum(ref settings.pauseOnLetter); + buf.Bind(ref settings.pauseOnJoin); + buf.Bind(ref settings.pauseOnDesync); + buf.BindEnum(ref settings.timeControl); + } +} diff --git a/Source/Common/Networking/State/ServerBootstrapState.cs b/Source/Common/Networking/State/ServerBootstrapState.cs index e63988fb..0ed94471 100644 --- a/Source/Common/Networking/State/ServerBootstrapState.cs +++ b/Source/Common/Networking/State/ServerBootstrapState.cs @@ -17,9 +17,6 @@ public class ServerBootstrapState(ConnectionBase conn) : MpConnectionState(conn) // Only one configurator at a time; track by username to survive reconnections private static string? configuratorUsername; - // TOML bytes received from client (will be parsed and saved as settings.toml) - private static byte[]? pendingSettingsBytes; - // Save upload (save.zip) private static string? pendingFileName; private static int pendingLength; @@ -69,7 +66,7 @@ public override void OnDisconnect() } [TypedPacketHandler] - public void HandleSettingsUpload(ClientBootstrapSettingsUploadDataPacket packet) + public void HandleSettings(ClientBootstrapSettingsPacket packet) { if (!IsConfigurator()) return; @@ -81,30 +78,17 @@ public void HandleSettingsUpload(ClientBootstrapSettingsUploadDataPacket packet) return; } - if (packet.data == null || packet.data.Length == 0) - throw new PacketReadException("Bootstrap settings upload received empty data"); + Directory.CreateDirectory(Path.GetDirectoryName(settingsPath)!); - try - { - // Store TOML bytes; the Server project will parse and finalize them - pendingSettingsBytes = packet.data; - ServerLog.Log($"Bootstrap: ServerSettings TOML received ({packet.data.Length} bytes)"); - - // Immediately persist to file since we have the complete 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: ServerSettings saved to {settingsPath}"); - } - catch (Exception e) - { - ServerLog.Error($"Bootstrap: Failed to process ServerSettings: {e}"); - throw; - } + var tempPath = settingsPath + ".tmp"; + Multiplayer.Common.Util.TomlSettingsCommon.Save(packet.settings, tempPath); + + if (File.Exists(settingsPath)) + File.Delete(settingsPath); + + File.Move(tempPath, settingsPath); + + ServerLog.Log($"Bootstrap: wrote '{settingsPath}'. Waiting for save.zip upload..."); } [TypedPacketHandler] @@ -199,10 +183,10 @@ public void HandleSaveEnd(ClientBootstrapSaveEndPacket packet) private static void ResetUploadState() { - pendingSettingsBytes = null; pendingFileName = null; pendingLength = 0; pendingZipBytes = null; + configuratorUsername = null; } } diff --git a/Source/Common/Util/TomlSettingsCommon.cs b/Source/Common/Util/TomlSettingsCommon.cs new file mode 100644 index 00000000..f74b74be --- /dev/null +++ b/Source/Common/Util/TomlSettingsCommon.cs @@ -0,0 +1,50 @@ +using System; +using Multiplayer.Common.Networking.Packet; +using Tomlyn; +using Tomlyn.Model; + +namespace Multiplayer.Common.Util +{ + /// + /// Minimal TOML saver for ServerSettings using the existing ExposeData pipeline. + /// Placed in Common so bootstrap server logic can persist settings without duplicating keys. + /// + public static class TomlSettingsCommon + { + public static void Save(ServerSettings settings, string filename) + { + var toml = new TomlScribeCommon { Mode = TomlScribeMode.Saving }; + ScribeLike.provider = toml; + + settings.ExposeData(); + + System.IO.File.WriteAllText(filename, Toml.FromModel(toml.Root)); + } + } + + internal class TomlScribeCommon : ScribeLike.Provider + { + public TomlTable Root { get; } = new(); + public TomlScribeMode Mode { get; set; } + + public override void Look(ref T value, string label, T defaultValue, bool forceSave) + { + if (Mode != TomlScribeMode.Saving) + throw new InvalidOperationException("TomlScribeCommon only supports saving"); + + if (typeof(T).IsEnum) + { + Root[label] = value!.ToString(); + } + else + { + Root[label] = value; + } + } + } + + internal enum TomlScribeMode + { + Saving + } +} From b6c4f65d5235ec1fe641fd28df0c9c3b8172fe19 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Fri, 16 Jan 2026 13:39:45 +0100 Subject: [PATCH 47/47] refactor: replace manual player disconnect loop with Server.TryStop() Removes the manual foreach loop that disconnects players individually, since Server.TryStop() handles the proper shutdown sequence including client disconnection. This avoids code duplication and uses the official shutdown method as suggested in code review. --- Source/Common/Networking/State/ServerBootstrapState.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Source/Common/Networking/State/ServerBootstrapState.cs b/Source/Common/Networking/State/ServerBootstrapState.cs index 0ed94471..b5a9ca9f 100644 --- a/Source/Common/Networking/State/ServerBootstrapState.cs +++ b/Source/Common/Networking/State/ServerBootstrapState.cs @@ -172,11 +172,10 @@ public void HandleSaveEnd(ClientBootstrapSaveEndPacket packet) // Notify and disconnect all clients. Server.SendToPlaying(new ServerDisconnectPacket { reason = MpDisconnectReason.BootstrapCompleted, data = Array.Empty() }); - 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; + Server.TryStop(); } private bool IsConfigurator() => configuratorUsername == connection.username;