Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f827b35
Name temporary configs more similarly to how vanilla does it
mibac138 Dec 17, 2025
2001446
Extract common logic into SyncConfigs
mibac138 Dec 17, 2025
7c9752f
SyncConfigs: handle patches and env vars on it's own
mibac138 Dec 20, 2025
2785367
SyncConfigs: include reading local configs functionality
mibac138 Dec 17, 2025
cd59333
SyncConfigs: minor style changes
mibac138 Dec 19, 2025
239b63a
Server: bootstrap mode when save.zip missing
MhaWay Dec 25, 2025
3a57c64
Server: add bootstrap mode for save.zip provisioning
MhaWay Dec 25, 2025
2b3711c
Server: shutdown after bootstrap (manual restart)
MhaWay Dec 25, 2025
4040936
Bootstrap: allow uploading settings.toml before save.zip
MhaWay Dec 25, 2025
6c8c521
Client(session): remove obsolete vanilla save conversion from Autosav…
MhaWay Dec 29, 2025
93f4155
Client(saving): remove obsolete vanilla save conversion helpers from …
MhaWay Dec 29, 2025
169b348
Client(network): add ClientBootstrapState (bootstrap join flow)
MhaWay Dec 29, 2025
363fb7d
Client(windows): add BootstrapConfiguratorWindow (bootstrap UI)
MhaWay Dec 29, 2025
e848679
Client(windows): add BootstrapCoordinator GameComponent
MhaWay Dec 29, 2025
3cb52c1
Client(patches): add BootstrapMapInitPatch (FinalizeInit hook)
MhaWay Dec 29, 2025
447d521
Client(patches): add BootstrapRootPlayPatch (Root_Play.Start hook)
MhaWay Dec 29, 2025
467c035
Client(patches): add BootstrapRootPlayUpdatePatch (Root_Play.Update h…
MhaWay Dec 29, 2025
c4c8050
Client(patches): add BootstrapStartedNewGamePatch (StartedNewGame hook)
MhaWay Dec 29, 2025
c034dd9
Bootstrap server flow: protocol, state machine, and upload logic. Min…
MhaWay Dec 29, 2025
277dc51
Registra le implementazioni mancanti per ClientBootstrap e Disconnect…
MhaWay Dec 29, 2025
4b15af8
Remove all automatic vanilla page advance logic (TryAutoAdvanceVanill…
MhaWay Dec 29, 2025
6c5a946
Remove automatic closing of landing popups and letters after map gene…
MhaWay Dec 29, 2025
316144f
Add GetFreeUdpPort and update HostProgrammatically to allow hosting o…
MhaWay Dec 29, 2025
c9116b9
Fix state implementation and handler array sizing to include Disconne…
MhaWay Dec 29, 2025
d40c224
Add directPort field to ServerSettings and initialize to default. All…
MhaWay Dec 29, 2025
fa3eabd
Add ClientDisconnectedState as a placeholder for the disconnected cli…
MhaWay Dec 29, 2025
43845c7
Fix bootstrap UI workflow state management
MhaWay Jan 11, 2026
1517ab0
Update Source/Client/Windows/BootstrapConfiguratorWindow.cs
MhaWay Jan 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 1 addition & 7 deletions Source/Client/EarlyInit.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using HarmonyLib;
using Multiplayer.Client.Patches;
Expand All @@ -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()
{
Expand All @@ -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)
Expand Down
72 changes: 0 additions & 72 deletions Source/Client/EarlyPatches/SettingsPatches.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using HarmonyLib;
using Multiplayer.Client.Patches;
Expand Down Expand Up @@ -104,74 +102,4 @@ static IEnumerable<MethodBase> 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;
}
}
}
}
1 change: 0 additions & 1 deletion Source/Client/Multiplayer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 2 additions & 0 deletions Source/Client/MultiplayerStatic.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
88 changes: 3 additions & 85 deletions Source/Client/Networking/JoinData.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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()
);

Expand Down Expand Up @@ -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<ModConfig> GetSyncableConfigContents(List<string> modIds)
{
var list = new List<ModConfig>();

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()
Expand Down
30 changes: 30 additions & 0 deletions Source/Client/Networking/State/ClientBootstrapState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using Multiplayer.Common;
using Multiplayer.Common.Networking.Packet;

namespace Multiplayer.Client;

/// <summary>
/// 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.
/// </summary>
[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<BootstrapConfiguratorWindow>();
if (window != null)
Verse.Find.WindowStack.TryRemove(window);
});
}
}
10 changes: 10 additions & 0 deletions Source/Client/Networking/State/ClientDisconnectedState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using Multiplayer.Common;

namespace Multiplayer.Client;

/// <summary>
/// Stato client per connessione disconnessa. Non fa nulla, serve solo come placeholder.
/// </summary>
public class ClientDisconnectedState(ConnectionBase connection) : ClientBaseState(connection)
{
}
Comment on lines +5 to +10

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed?

22 changes: 20 additions & 2 deletions Source/Client/Networking/State/ClientJoiningState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,24 @@
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)]
Comment on lines +13 to +15
Copy link

Copilot AI Jan 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The packet handler inheritance is changed from false to true. The comment explains this is to inherit keepalive and disconnect handlers, but this is a significant behavior change that could affect packet routing. If the parent class ClientBaseState has packet handlers that conflict with or override handlers needed during the joining phase, this could introduce bugs. Verify that all inherited handlers are appropriate for the joining state.

Suggested change
// 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)]
// Do not inherit handlers from ClientBaseState here; inheriting all base handlers can affect
// packet routing during the joining phase and potentially conflict with join-specific logic.
[PacketHandlerClass(inheritHandlers: false)]

Copilot uses AI. Check for mistakes.
public class ClientJoiningState : ClientBaseState
{
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()
{
Expand Down Expand Up @@ -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);
}
Expand Down
Loading