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 0a10fcfa..a5075807 100644 --- a/Source/Client/EarlyPatches/SettingsPatches.cs +++ b/Source/Client/EarlyPatches/SettingsPatches.cs @@ -1,6 +1,4 @@ using System.Collections.Generic; -using System.IO; -using System.Linq; using System.Reflection; using HarmonyLib; using Multiplayer.Client.Patches; @@ -104,74 +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; - - // Example: MultiplayerTempConfigs/rwmt.multiplayer-Multiplayer - var newPath = Path.Combine( - GenFilePaths.FolderUnderSaveData(JoinData.TempConfigsDir), - GenText.SanitizeFilename(mod.PackageIdPlayerFacing.ToLowerInvariant() + "-" + modHandleName) - ); - - __result = newPath; - } - } - - [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/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(); diff --git a/Source/Client/Networking/JoinData.cs b/Source/Client/Networking/JoinData.cs index 71fca8f2..f8a08446 100644 --- a/Source/Client/Networking/JoinData.cs +++ b/Source/Client/Networking/JoinData.cs @@ -1,12 +1,10 @@ -using System; using System.Collections; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using HarmonyLib; using Ionic.Zlib; -using Multiplayer.Client.EarlyPatches; +using Multiplayer.Client.Util; using Multiplayer.Common; using RimWorld; using Steamworks; @@ -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/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); + }); + } +} 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 diff --git a/Source/Client/Networking/State/ClientJoiningState.cs b/Source/Client/Networking/State/ClientJoiningState.cs index 5bf363ec..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) @@ -18,7 +20,14 @@ public ClientJoiningState(ConnectionBase connection) : base(connection) } [TypedPacketHandler] - public new void HandleDisconnected(ServerDisconnectPacket packet) => base.HandleDisconnected(packet); + 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; + Multiplayer.session.serverBootstrapSettingsMissing = packet.settingsMissing; + } public override void StartState() { @@ -122,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/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(); 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); + } } diff --git a/Source/Client/Session/MultiplayerSession.cs b/Source/Client/Session/MultiplayerSession.cs index 21b1c908..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,6 +57,10 @@ public class MultiplayerSession : IConnectionStatusListener public int port; public CSteamID? steamHost; + // Set during handshake (see Server_Bootstrap packet) to indicate the server is waiting for configuration/upload. + public bool serverIsInBootstrap; + public bool serverBootstrapSettingsMissing; + public void Stop() { if (client != null) diff --git a/Source/Client/Util/SyncConfigs.cs b/Source/Client/Util/SyncConfigs.cs new file mode 100644 index 00000000..f03ef9cf --- /dev/null +++ b/Source/Client/Util/SyncConfigs.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Reflection; +using HarmonyLib; +using Multiplayer.Client.Patches; +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("MultiplayerTempConfigs"); + 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) + { + 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); + } + + [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.Except(ignoredConfigsModIds)) + { + var mod = LoadedModManager.RunningMods + .FirstOrDefault(m => m.PackageIdPlayerFacing.EqualsIgnoreCase(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")); +} + +// 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 (SyncConfigs.ignoredConfigsModIds.Contains(mod.ModMetaData.PackageIdNonUnique)) return; + + __result = SyncConfigs.GetConfigPath(mod.PackageIdPlayerFacing.ToLowerInvariant(), modHandleName); + } +} + +[EarlyPatch] +[HarmonyPatch] +static class HugsLib_OverrideConfigsPatch +{ + 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"); + + 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 (!HugsLibConfigIsOverriden) return; + __instance.SetPropertyOrField("OverrideFilePath", HugsLibConfigOverridePath); + } +} diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs new file mode 100644 index 00000000..aa437654 --- /dev/null +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -0,0 +1,1329 @@ +using System; +using System.IO; +using System.Linq; +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), + "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")) + { + 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, "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(); + } + + 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 }; + + // 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(); + + // 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 = "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; + nextPressCooldown = 0f; + randomTileCooldown = 0f; + autoAdvanceElapsed = 0f; + worldGenDetected = false; + worldGenDelayRemaining = WorldGenDelaySeconds; + autoAdvanceDiagCooldown = 0f; + startingLettersCleared = false; + landingDialogsCleared = false; + AwaitingBootstrapMapInit = true; + saveUploadStatus = "Generating map..."; + + + 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}"); + } + } + + 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 + } + + 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. + postMapEnterSaveDelayRemaining = PostMapEnterSaveDelaySeconds; + awaitingControllablePawns = true; + awaitingControllablePawnsElapsed = 0f; + bootstrapSaveQueued = false; + saveUploadStatus = "Map initialized. Waiting before saving..."; + Trace("FinalizeInit"); + + 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. + } + + 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; + + 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. + 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; + + // 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; + + 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 + { + // 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 { } + + UnityEngine.Debug.Log("[Bootstrap] Controllable colonists detected, starting save"); + } + } + + if (awaitingControllablePawns) + { + if (awaitingControllablePawnsElapsed > AwaitControllablePawnsTimeoutSeconds) + { + // Fallback: don't block forever; save anyway. + awaitingControllablePawns = false; + UnityEngine.Debug.LogWarning("[Bootstrap] Timed out waiting for controllable pawns; saving anyway"); + } + else + { + saveUploadStatus = "Waiting for controllable 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"); + + 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) + // 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) + 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, + }; + + bool hosted = HostWindow.HostProgrammatically(hostSettings, file: null, randomDirectPort: false); + 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 = "Uploaded"; + 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 = "Failed to upload settings.toml: {0}: {1}"; + 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(); + + // 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) + CheckReconnectionState(); + } + + /// + /// 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 + { + // 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; + while (sent < bytes.Length) + { + var len = Math.Min(chunk, bytes.Length - sent); + var part = new byte[len]; + Buffer.BlockCopy(bytes, sent, part, 0, len); + targetConn.SendFragmented(new ClientBootstrapUploadDataPacket(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)); + + 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)); + } + } +} 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); + } + } +} 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(); + }); + } + } + } +} 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(); + } + } +} 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(); + } + } +} diff --git a/Source/Client/Windows/BootstrapStartedNewGamePatch.cs b/Source/Client/Windows/BootstrapStartedNewGamePatch.cs new file mode 100644 index 00000000..05572321 --- /dev/null +++ b/Source/Client/Windows/BootstrapStartedNewGamePatch.cs @@ -0,0 +1,35 @@ +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.OnBootstrapMapInitialized(); + }); + + UnityEngine.Debug.Log("[Bootstrap] StartedNewGame: armed + cleanup queued"); + } + } +} 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; + } } } diff --git a/Source/Client/Windows/JoinDataWindow.cs b/Source/Client/Windows/JoinDataWindow.cs index ffd2aff2..31a73cc8 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; @@ -168,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) { @@ -750,13 +749,8 @@ 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, $"{config.ModId}-{config.FileName}"), config.Contents); + SyncConfigs.SaveConfigs(data.remoteModConfigs); + SyncConfigs.MarkApplicableForChildProcess(); } var connectTo = data.remoteSteamHost != null @@ -765,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(); } 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..cf9df2cb 100644 --- a/Source/Common/Networking/ConnectionStateEnum.cs +++ b/Source/Common/Networking/ConnectionStateEnum.cs @@ -4,10 +4,12 @@ public enum ConnectionStateEnum : byte { ClientJoining, ClientLoading, + ClientBootstrap, ClientPlaying, ClientSteam, ServerJoining, + ServerBootstrap, ServerLoading, ServerPlaying, ServerSteam, // unused 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) { diff --git a/Source/Common/Networking/NetworkingLiteNet.cs b/Source/Common/Networking/NetworkingLiteNet.cs index faed7396..c561b0bc 100644 --- a/Source/Common/Networking/NetworkingLiteNet.cs +++ b/Source/Common/Networking/NetworkingLiteNet.cs @@ -21,10 +21,16 @@ public void OnConnectionRequest(ConnectionRequest req) public void OnPeerConnected(NetPeer peer) { var conn = new LiteNetConnection(peer); - conn.ChangeState(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; @@ -51,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 new file mode 100644 index 00000000..7146ef83 --- /dev/null +++ b/Source/Common/Networking/Packet/BootstrapPacket.cs @@ -0,0 +1,19 @@ +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, 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/Packet/BootstrapUploadPackets.cs b/Source/Common/Networking/Packet/BootstrapUploadPackets.cs new file mode 100644 index 00000000..418476ef --- /dev/null +++ b/Source/Common/Networking/Packet/BootstrapUploadPackets.cs @@ -0,0 +1,109 @@ +using System; + +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). +/// +[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..ba07cd2c 100644 --- a/Source/Common/Networking/Packets.cs +++ b/Source/Common/Networking/Packets.cs @@ -35,6 +35,16 @@ public enum Packets : byte Client_SetFaction, Client_FrameTime, + // Bootstrap + Client_BootstrapSettingsUploadStart, + Client_BootstrapSettingsUploadData, + Client_BootstrapSettingsUploadFinish, + 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..14eaa5a1 --- /dev/null +++ b/Source/Common/Networking/State/ServerBootstrapState.cs @@ -0,0 +1,287 @@ +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 (always playerId=0 in bootstrap) + private static bool configuratorActive; + + 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; + + 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 (configuratorActive) + { + // 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; + 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"); + + 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() + { + if (configuratorActive && Player.id == 0) + { + ServerLog.Log("Bootstrap: configurator disconnected; returning to waiting state."); + ResetUploadState(); + configuratorActive = false; + } + } + + [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; + + // 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] + 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"); + + 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; + + // 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] + 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"); + + 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. 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); + + // Stop the server loop; an external supervisor should restart. + Server.running = false; + } + + private bool IsConfigurator() => configuratorActive && Player.id == 0; + + private static void ResetUploadState() + { + pendingSettingsFileName = null; + pendingSettingsLength = 0; + pendingSettingsBytes = null; + + 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..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(); @@ -43,7 +52,17 @@ 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. + var settingsMissing = false; + if (Server.BootstrapMode) + settingsMissing = !File.Exists(Path.Combine(AppContext.BaseDirectory, "settings.toml")); + + Player.conn.Send(new ServerBootstrapPacket(Server.BootstrapMode, settingsMissing)); + } } private void HandleUsername(ClientUsernamePacket packet) @@ -136,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 75e19780..6ca292d9 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)) @@ -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/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; 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..3099cf8d 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,9 +29,24 @@ running = true, }; +var bootstrap = !File.Exists(settingsFile); + var consoleSource = new ConsoleSource(); -LoadSave(server, saveFile); +if (!bootstrap && 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."); +} +server.BootstrapMode = bootstrap; + +if (bootstrap) + ServerLog.Detail("Bootstrap flag is enabled."); if (settings.direct) { var badEndpoint = settings.TryParseEndpoints(out var endpoints); @@ -68,6 +83,16 @@ 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); + +// 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(); @@ -82,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"); @@ -97,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('_'); @@ -107,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('_'); @@ -119,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) 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();