From 734dbc721d6d7bbfa028022003fed6a2f2845d0e Mon Sep 17 00:00:00 2001 From: mkiplm-svg Date: Mon, 19 Oct 2026 09:23:03 -0400 Subject: [PATCH 01/19] Add experimental RimWorld 1.7 support --- Source/Client/Desyncs/DeferredStackTracing.cs | 303 +-- Source/Client/Desyncs/JittedMethods.cs | 214 +- Source/Client/Desyncs/SaveableDesyncInfo.cs | 398 ++-- Source/Client/Desyncs/StackTraceLogItem.cs | 173 +- Source/Client/Factions/MultifactionPatches.cs | 1918 ++++++++--------- Source/Client/Multiplayer.cs | 509 ++--- Source/Client/Multiplayer.csproj | 169 +- .../Patches/GravshipTravelSessionPatches.cs | 514 ++--- Source/Client/Patches/VanillaTweaks.cs | 296 +-- Source/Client/Syncing/SyncTemplates.cs | 206 +- Source/Client/UI/MainMenuAnimation.cs | 879 ++++---- Source/Client/Util/MpUI.cs | 548 ++--- Source/Client/Util/MpUtil.cs | 403 ++-- Source/Common/Common.csproj | 85 +- Source/Common/Native.cs | 532 ++--- Source/Directory.Build.targets | 26 + .../MultiplayerLoader.csproj | 75 +- Source/MultiplayerLoader/Prepatches.cs | 160 +- Source/MultiplayerLoader/SyncRituals.cs | 276 +-- Source/Tests/CoreClrRuntimeTest.cs | 17 + Source/Tests/Tests.csproj | 65 +- .../ClientInitDataPacket.verified.txt | 2 +- .../ServerJoinDataPacket.verified.txt | 2 +- 23 files changed, 3932 insertions(+), 3838 deletions(-) create mode 100644 Source/Directory.Build.targets create mode 100644 Source/Tests/CoreClrRuntimeTest.cs diff --git a/Source/Client/Desyncs/DeferredStackTracing.cs b/Source/Client/Desyncs/DeferredStackTracing.cs index db53da2df..f36c9395f 100644 --- a/Source/Client/Desyncs/DeferredStackTracing.cs +++ b/Source/Client/Desyncs/DeferredStackTracing.cs @@ -1,146 +1,169 @@ using System.Collections.Generic; -using System.Reflection; -using System.Runtime.InteropServices; -using HarmonyLib; -using Multiplayer.Client.Patches; -using Multiplayer.Common; -using RimWorld; -using Verse; -using Verse.AI; - -namespace Multiplayer.Client.Desyncs -{ - [EarlyPatch] - [HarmonyPatch] - public static class DeferredStackTracing - { - public static int ignoreTraces; - public static long maxTraceDepth; - public static int randCalls; - - static IEnumerable TargetMethods() - { - yield return AccessTools.PropertyGetter(typeof(Rand), nameof(Rand.Value)); - yield return AccessTools.PropertyGetter(typeof(Rand), nameof(Rand.Int)); - } - - public static int acc; - - private static bool SupportsDeferredStackTracing => - RuntimeInformation.ProcessArchitecture is Architecture.X64 or Architecture.X86; - +using System.Diagnostics; +using System.Text; +using System.Reflection; +using System.Runtime.InteropServices; +using HarmonyLib; +using Multiplayer.Client.Patches; +using Multiplayer.Common; +using RimWorld; +using Verse; +using Verse.AI; + +namespace Multiplayer.Client.Desyncs +{ + [EarlyPatch] + [HarmonyPatch] + public static class DeferredStackTracing + { + public static int ignoreTraces; + public static long maxTraceDepth; + public static int randCalls; + + static IEnumerable TargetMethods() + { + yield return AccessTools.PropertyGetter(typeof(Rand), nameof(Rand.Value)); + yield return AccessTools.PropertyGetter(typeof(Rand), nameof(Rand.Int)); + } + + public static int acc; + + private static bool SupportsDeferredStackTracing => + RuntimeInformation.ProcessArchitecture is Architecture.X64 or Architecture.X86; + public static void Postfix() { - if (Native.LmfPtr == 0) return; + if (Native.IsMonoRuntime && Native.LmfPtr == 0) return; if (!ShouldAddStackTraceForDesyncLog()) return; var logItem = StackTraceLogItemRaw.GetFromPool(); - var trace = logItem.raw; - int hash = 0; - // Skip this (DeferredStackTracing.Postfix) frame. Keeping it in the trace doesn't provide any value. - int depth = DeferredStackTracingImpl.TraceImpl(trace, ref hash, skipFrames: 1); - - Multiplayer.game.sync.TryAddStackTraceForDesyncLogRaw(logItem, depth, hash); - - acc++; - } - - public static bool ShouldAddStackTraceForDesyncLog() - { - if (!SupportsDeferredStackTracing) return false; - if (Multiplayer.Client == null) return false; - if (Multiplayer.settings.desyncTracingMode == DesyncTracingMode.None) return false; - if (Multiplayer.game == null) return false; - - if (!Multiplayer.game.gameComp.logDesyncTraces) return false; - - if (Rand.stateStack.Count > 1) return false; - if (Multiplayer.IsReplay) return false; - - if (!Multiplayer.Ticking && !Multiplayer.ExecutingCmds) return false; - - return ignoreTraces == 0; - } - } - - [HarmonyPatch(typeof(UniqueIDsManager), nameof(UniqueIDsManager.GetNextID))] - public static class UniqueIdsPatch - { - static void Postfix() - { - DeferredStackTracing.Postfix(); - } - } - - [HarmonyPatch(typeof(Thing), nameof(Thing.SpawnSetup))] - public static class ThingSpawnPatch - { - static void Postfix(Thing __instance) - { - if (__instance.def.HasThingIDNumber) - DeferredStackTracing.Postfix(); - } - } - - [HarmonyPatch(typeof(Thing), nameof(Thing.DeSpawn))] - public static class ThingDeSpawnPatch - { - static void Postfix(Thing __instance) - { - if (__instance.def.HasThingIDNumber) - DeferredStackTracing.Postfix(); - } - } - - [HarmonyPatch(typeof(Pawn_JobTracker), nameof(Pawn_JobTracker.EndCurrentJob))] - public static class EndCurrentJobPatch - { - static void Prefix(Pawn_JobTracker __instance) - { - if (MpVersion.IsDebug && __instance.curJob != null && DeferredStackTracing.ShouldAddStackTraceForDesyncLog()) - Multiplayer.game.sync.TryAddInfoForDesyncLog($"EndCurrentJob for {__instance.pawn}: {__instance.curJob}", ""); - } - } - - [HarmonyPatch(typeof(WildAnimalSpawner), nameof(WildAnimalSpawner.WildAnimalSpawnerTick))] - static class WildAnimalSpawnerTickTraceIgnore - { - static void Prefix() => DeferredStackTracing.ignoreTraces++; - static void Finalizer() => DeferredStackTracing.ignoreTraces--; - } - - [HarmonyPatch(typeof(WildPlantSpawner), nameof(WildPlantSpawner.WildPlantSpawnerTick))] - static class WildPlantSpawnerTickTraceIgnore - { - static void Prefix() => DeferredStackTracing.ignoreTraces++; - static void Finalizer() => DeferredStackTracing.ignoreTraces--; - } - - [HarmonyPatch(typeof(SteadyEnvironmentEffects), nameof(SteadyEnvironmentEffects.SteadyEnvironmentEffectsTick))] - static class SteadyEnvironmentEffectsTickTraceIgnore - { - static void Prefix() => DeferredStackTracing.ignoreTraces++; - static void Finalizer() => DeferredStackTracing.ignoreTraces--; - } - - [HarmonyPatch(typeof(TempTerrainManager), nameof(TempTerrainManager.Tick))] - static class TempTerrainManagerTickTraceIgnore - { - static void Prefix() => DeferredStackTracing.ignoreTraces++; - static void Finalizer() => DeferredStackTracing.ignoreTraces--; - } - - /*[HarmonyPatch(typeof(StoreUtility), nameof(StoreUtility.TryFindBestBetterStoreCellForWorker))] - static class FindBestStorageCellTraceIgnore - { - static void Prefix() => DeferredStackTracing.ignoreTraces++; - static void Finalizer() => DeferredStackTracing.ignoreTraces--; - }*/ - - [HarmonyPatch(typeof(IntermittentSteamSprayer), nameof(IntermittentSteamSprayer.SteamSprayerTick))] - static class SteamSprayerTickTraceIgnore - { - static void Prefix() => DeferredStackTracing.ignoreTraces++; - static void Finalizer() => DeferredStackTracing.ignoreTraces--; - } -} + if (!Native.IsMonoRuntime) + { + // Managed fallback: never interpret CoreCLR addresses as Mono frames. + var frames = new StackTrace(1, false).GetFrames(); + var text = new StringBuilder(); + int managedHash = 17; + int managedDepth = System.Math.Min(frames?.Length ?? 0, StackTraceLogItemRaw.MaxDepth); + for (int i = 0; i < managedDepth; i++) + { + var method = frames[i].GetMethod(); + var name = $"{method?.DeclaringType?.FullName}.{method}"; + text.AppendLine(name); + // Explicit stable hashing; no process-randomized string hashes. + foreach (char c in name) + managedHash = unchecked(managedHash * 31 + c); + } + logItem.managedTrace = text.ToString(); + Multiplayer.game.sync.TryAddStackTraceForDesyncLogRaw(logItem, managedDepth, managedHash); + acc++; + return; + } + var trace = logItem.raw; + int hash = 0; + // Skip this (DeferredStackTracing.Postfix) frame. Keeping it in the trace doesn't provide any value. + int depth = DeferredStackTracingImpl.TraceImpl(trace, ref hash, skipFrames: 1); + + Multiplayer.game.sync.TryAddStackTraceForDesyncLogRaw(logItem, depth, hash); + + acc++; + } + + public static bool ShouldAddStackTraceForDesyncLog() + { + if (!SupportsDeferredStackTracing) return false; + if (Multiplayer.Client == null) return false; + if (Multiplayer.settings.desyncTracingMode == DesyncTracingMode.None) return false; + if (Multiplayer.game == null) return false; + + if (!Multiplayer.game.gameComp.logDesyncTraces) return false; + + if (Rand.stateStack.Count > 1) return false; + if (Multiplayer.IsReplay) return false; + + if (!Multiplayer.Ticking && !Multiplayer.ExecutingCmds) return false; + + return ignoreTraces == 0; + } + } + + [HarmonyPatch(typeof(UniqueIDsManager), nameof(UniqueIDsManager.GetNextID))] + public static class UniqueIdsPatch + { + static void Postfix() + { + DeferredStackTracing.Postfix(); + } + } + + [HarmonyPatch(typeof(Thing), nameof(Thing.SpawnSetup))] + public static class ThingSpawnPatch + { + static void Postfix(Thing __instance) + { + if (__instance.def.HasThingIDNumber) + DeferredStackTracing.Postfix(); + } + } + + [HarmonyPatch(typeof(Thing), nameof(Thing.DeSpawn))] + public static class ThingDeSpawnPatch + { + static void Postfix(Thing __instance) + { + if (__instance.def.HasThingIDNumber) + DeferredStackTracing.Postfix(); + } + } + + [HarmonyPatch(typeof(Pawn_JobTracker), nameof(Pawn_JobTracker.EndCurrentJob))] + public static class EndCurrentJobPatch + { + static void Prefix(Pawn_JobTracker __instance) + { + if (MpVersion.IsDebug && __instance.curJob != null && DeferredStackTracing.ShouldAddStackTraceForDesyncLog()) + Multiplayer.game.sync.TryAddInfoForDesyncLog($"EndCurrentJob for {__instance.pawn}: {__instance.curJob}", ""); + } + } + + [HarmonyPatch(typeof(WildAnimalSpawner), nameof(WildAnimalSpawner.WildAnimalSpawnerTick))] + static class WildAnimalSpawnerTickTraceIgnore + { + static void Prefix() => DeferredStackTracing.ignoreTraces++; + static void Finalizer() => DeferredStackTracing.ignoreTraces--; + } + + [HarmonyPatch(typeof(WildPlantSpawner), nameof(WildPlantSpawner.WildPlantSpawnerTick))] + static class WildPlantSpawnerTickTraceIgnore + { + static void Prefix() => DeferredStackTracing.ignoreTraces++; + static void Finalizer() => DeferredStackTracing.ignoreTraces--; + } + + [HarmonyPatch(typeof(SteadyEnvironmentEffects), nameof(SteadyEnvironmentEffects.SteadyEnvironmentEffectsTick))] + static class SteadyEnvironmentEffectsTickTraceIgnore + { + static void Prefix() => DeferredStackTracing.ignoreTraces++; + static void Finalizer() => DeferredStackTracing.ignoreTraces--; + } + + [HarmonyPatch(typeof(TempTerrainManager), nameof(TempTerrainManager.Tick))] + static class TempTerrainManagerTickTraceIgnore + { + static void Prefix() => DeferredStackTracing.ignoreTraces++; + static void Finalizer() => DeferredStackTracing.ignoreTraces--; + } + + /*[HarmonyPatch(typeof(StoreUtility), nameof(StoreUtility.TryFindBestBetterStoreCellForWorker))] + static class FindBestStorageCellTraceIgnore + { + static void Prefix() => DeferredStackTracing.ignoreTraces++; + static void Finalizer() => DeferredStackTracing.ignoreTraces--; + }*/ + + [HarmonyPatch(typeof(IntermittentSteamSprayer), nameof(IntermittentSteamSprayer.SteamSprayerTick))] + static class SteamSprayerTickTraceIgnore + { + static void Prefix() => DeferredStackTracing.ignoreTraces++; + static void Finalizer() => DeferredStackTracing.ignoreTraces--; + } +} diff --git a/Source/Client/Desyncs/JittedMethods.cs b/Source/Client/Desyncs/JittedMethods.cs index 7928b04b4..7b966c1a4 100644 --- a/Source/Client/Desyncs/JittedMethods.cs +++ b/Source/Client/Desyncs/JittedMethods.cs @@ -1,112 +1,116 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Reflection; -using System.Text; -using HarmonyLib; -using JetBrains.Annotations; -using Verse; - -namespace Multiplayer.Client.Desyncs; - -public class JittedMethod -{ - [CanBeNull] public MethodBase method; - [CanBeNull] public MethodBase from; - [CanBeNull] public int[] mapTicks; - public int worldTicks; - public int timer; - public bool inInterface; - - public string TimeString() - { - return $"m:{mapTicks?.Join(delimiter: ",") ?? "[]"} w:{worldTicks} t:{timer}"; - } - - public override string ToString() - { - return $"{method?.DeclaringType}.{method?.Name} from {from?.DeclaringType}.{from?.Name} {TimeString()} i:{inInterface}"; - } -} - -public static class JittedMethods -{ - private static Queue methodQueue = new(); - private static IntPtr profiler; - private static bool adding; - +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Text; +using HarmonyLib; +using JetBrains.Annotations; +using Verse; + +namespace Multiplayer.Client.Desyncs; + +public class JittedMethod +{ + [CanBeNull] public MethodBase method; + [CanBeNull] public MethodBase from; + [CanBeNull] public int[] mapTicks; + public int worldTicks; + public int timer; + public bool inInterface; + + public string TimeString() + { + return $"m:{mapTicks?.Join(delimiter: ",") ?? "[]"} w:{worldTicks} t:{timer}"; + } + + public override string ToString() + { + return $"{method?.DeclaringType}.{method?.Name} from {from?.DeclaringType}.{from?.Name} {TimeString()} i:{inInterface}"; + } +} + +public static class JittedMethods +{ + private static Queue methodQueue = new(); + private static IntPtr profiler; + private static bool adding; + public static void Init() { - profiler = Native.mono_profiler_create(IntPtr.Zero); - Native.mono_profiler_set_jit_done_callback(profiler, (_, method, _) => - { - if (adding) return; - - try - { - adding = true; - - if (UnityData.IsInMainThread && Multiplayer.settings != null && Native.mono_method_get_token(method) != 0) - { - var methodBase = Native.GetMethodBaseFromRuntimePointer(method); - methodQueue.Enqueue(new JittedMethod - { - method = methodBase, - from = new StackTrace().GetFrame(1)?.GetMethod(), - mapTicks = Multiplayer.game?.asyncTimeComps.Select(c => c.mapTicks).ToArray(), - worldTicks = Multiplayer.game?.asyncWorldTimeComp?.worldTicks ?? -1, - timer = TickPatch.Timer, - inInterface = Multiplayer.InInterface - }); - - if (methodQueue.Count > Multiplayer.settings.jittedMethodsInDesync) - methodQueue.Dequeue(); - } - } - finally - { - adding = false; - } - }); - } - + if (!Native.IsMonoRuntime) return; + profiler = Native.mono_profiler_create(IntPtr.Zero); + Native.mono_profiler_set_jit_done_callback(profiler, (_, method, _) => + { + if (adding) return; + + try + { + adding = true; + + if (UnityData.IsInMainThread && Multiplayer.settings != null && Native.mono_method_get_token(method) != 0) + { + var methodBase = Native.GetMethodBaseFromRuntimePointer(method); + methodQueue.Enqueue(new JittedMethod + { + method = methodBase, + from = new StackTrace().GetFrame(1)?.GetMethod(), + mapTicks = Multiplayer.game?.asyncTimeComps.Select(c => c.mapTicks).ToArray(), + worldTicks = Multiplayer.game?.asyncWorldTimeComp?.worldTicks ?? -1, + timer = TickPatch.Timer, + inInterface = Multiplayer.InInterface + }); + + if (methodQueue.Count > Multiplayer.settings.jittedMethodsInDesync) + methodQueue.Dequeue(); + } + } + finally + { + adding = false; + } + }); + } + public static void OnApplicationQuit() { + if (!Native.IsMonoRuntime || profiler == IntPtr.Zero) return; Native.mono_profiler_set_jit_done_callback(profiler, null); - } - + } + public static string GetJittedMethodsString() { - // Two ToArrays to prevent a compilation in between from causing a "Collection was modified" exception - var jittedMethods = methodQueue.ToArray().Select((j, i) => (j, i)).ToArray(); - - var builder = new StringBuilder(); - builder.Append("In simulation:"); // This will get a newline from the != comparison below - var timeString = ""; - - foreach (var j in jittedMethods.Where(j => !j.j.inInterface)) - { - if (timeString != j.j.TimeString()) - builder.AppendLine(); - builder.AppendLine($"{j.i} {j.j}"); - timeString = j.j.TimeString(); - } - - builder.AppendLine(); - builder.AppendLine(); - - timeString = ""; - - builder.Append("In interface:"); - foreach (var j in jittedMethods.Where(j => j.j.inInterface)) - { - if (timeString != j.j.TimeString()) - builder.AppendLine(); - builder.AppendLine($"{j.i} {j.j}"); - timeString = j.j.TimeString(); - } - - return builder.ToString(); - } -} + if (!Native.IsMonoRuntime) + return "Mono JIT history is unavailable on this runtime."; + // Two ToArrays to prevent a compilation in between from causing a "Collection was modified" exception + var jittedMethods = methodQueue.ToArray().Select((j, i) => (j, i)).ToArray(); + + var builder = new StringBuilder(); + builder.Append("In simulation:"); // This will get a newline from the != comparison below + var timeString = ""; + + foreach (var j in jittedMethods.Where(j => !j.j.inInterface)) + { + if (timeString != j.j.TimeString()) + builder.AppendLine(); + builder.AppendLine($"{j.i} {j.j}"); + timeString = j.j.TimeString(); + } + + builder.AppendLine(); + builder.AppendLine(); + + timeString = ""; + + builder.Append("In interface:"); + foreach (var j in jittedMethods.Where(j => j.j.inInterface)) + { + if (timeString != j.j.TimeString()) + builder.AppendLine(); + builder.AppendLine($"{j.i} {j.j}"); + timeString = j.j.TimeString(); + } + + return builder.ToString(); + } +} diff --git a/Source/Client/Desyncs/SaveableDesyncInfo.cs b/Source/Client/Desyncs/SaveableDesyncInfo.cs index 200c3dfee..2bc80eba8 100644 --- a/Source/Client/Desyncs/SaveableDesyncInfo.cs +++ b/Source/Client/Desyncs/SaveableDesyncInfo.cs @@ -1,199 +1,201 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using HarmonyLib; -using JetBrains.Annotations; -using Multiplayer.Common; -using Multiplayer.Common.Util; -using RimWorld; -using UnityEngine; -using Verse; -using CompressionLevel = System.IO.Compression.CompressionLevel; - -namespace Multiplayer.Client.Desyncs; - -public class SaveableDesyncInfo( - SyncCoordinator coordinator, - ClientSyncOpinion local, - ClientSyncOpinion remote, - int diffAt, - bool diffAtFound) -{ - public readonly ClientSyncOpinion local = local; - public readonly ClientSyncOpinion remote = remote; - public readonly int diffAt = diffAt; - private readonly Task metadata = Task.Run(MetadataGenerator.Generate); - private readonly Task replay = Task.Run(SaveReplayIfApplicable); - - public bool ReadyToSave => metadata.IsCompleted && replay.IsCompleted; - - public void Save([CanBeNull] HostInfo hostInfo) - { - var watch = Stopwatch.StartNew(); - - try - { - var desyncFilePath = FindFileNameForNextDesyncFile(); - using var zip = MpZipFile.Open(Path.Combine(Multiplayer.DesyncsDir, desyncFilePath + ".zip"), ZipArchiveMode.Create); - - zip.AddEntry("desync_info", GetDesyncDetails()); - - zip.AddEntry("local_traces.txt", GetLocalTraces()); - zip.AddEntry("host_traces.txt", hostInfo?.Traces ?? "No host traces"); - - zip.AddEntry("local_jitted_methods.txt", JittedMethods.GetJittedMethodsString()); - zip.AddEntry("host_jitted_methods.txt", hostInfo?.JittedMethods ?? "No host jitted methods"); - - var extraLogs = LogGenerator.PrepareLogData(); - if (extraLogs != null) zip.AddEntry("local_logs.txt", extraLogs); - - zip.AddEntry("local_metadata.txt", metadata.Result); - - try - { - replay.Wait(); - } - catch (AggregateException e) - { - if (e.InnerExceptions.SingleOrDefault(inner => inner is TaskCanceledException) == null) throw; - } - if (replay.IsCompletedSuccessfully) { - var replayFile = replay.Result; - zip.CreateEntryFromFile(replayFile.FullName, "replay.rwmts", CompressionLevel.NoCompression); - DeleteFileSilent(replayFile); - } - } - catch (Exception e) - { - Log.Error($"Exception writing desync info: {e}"); - } - - Log.Message($"Desync info writing took {watch.ElapsedMilliseconds}"); - } - - private string GetLocalTraces() - { - var traceMessage = ""; - var localCount = local.desyncStackTraceHashes.Count; - var remoteCount = remote.desyncStackTraceHashes.Count; - int count = Math.Min(localCount, remoteCount); - - if (diffAt == -1) - { - if (count == 0) - { - return $"No traces (remote: {remoteCount}, local: {localCount})"; - } - - traceMessage = "Note: trace hashes are equal between local and remote\n\n"; - } - else if (!diffAtFound) - { - traceMessage = "Note: traces differ in amount, but the existing ones are equal. This means that a tick" + - " has ended sooner on one of the connection sides\n\n"; - } - - traceMessage += local.GetFormattedStackTracesForRange(diffAt); - return traceMessage; - } - - private string GetDesyncDetails() - { - var desyncInfo = new StringBuilder(); - - desyncInfo - .AppendLine("###Tick Data###") - .AppendLine($"Arbiter Connected And Playing|||{Multiplayer.session.ArbiterPlaying}") - .AppendLine($"Last Valid Tick - Local|||{coordinator.lastValidTick}") - .AppendLine($"Last Valid Tick - Arbiter|||{coordinator.arbiterWasPlayingOnLastValidTick}") - .AppendLine("\n###Version Data###") - .AppendLine($"Multiplayer Mod Version|||{MpVersion.Version}") - .AppendLine($"Rimworld Version and Rev|||{VersionControl.CurrentVersionStringWithRev}") - .AppendLine("\n###Debug Options###") - .AppendLine($"Multiplayer Debug Build - Client|||{MpVersion.IsDebug}") - .AppendLine($"Multiplayer Debug Mode - Host|||{Multiplayer.GameComp.debugMode}") - .AppendLine($"Rimworld Developer Mode - Client|||{Prefs.DevMode}") - .AppendLine("\n###Server Info###") - .AppendLine($"Player Count|||{Multiplayer.session.players.Count}") - .AppendLine($"Async time active|||{Multiplayer.GameComp.asyncTime}") - .AppendLine($"Multifaction active|||{Multiplayer.GameComp.multifaction}") - .AppendLine($"Map Count|||{Find.Maps?.Count.ToStringSafe()}") - .AppendLine("\n###CPU Info###") - .AppendLine($"Processor Name|||{SystemInfo.processorType}") - .AppendLine($"Processor Speed (MHz)|||{SystemInfo.processorFrequency}") - .AppendLine($"Thread Count|||{SystemInfo.processorCount}") - .AppendLine("\n###GPU Info###") +using System; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using HarmonyLib; +using JetBrains.Annotations; +using Multiplayer.Common; +using Multiplayer.Common.Util; +using RimWorld; +using UnityEngine; +using Verse; +using CompressionLevel = System.IO.Compression.CompressionLevel; + +namespace Multiplayer.Client.Desyncs; + +public class SaveableDesyncInfo( + SyncCoordinator coordinator, + ClientSyncOpinion local, + ClientSyncOpinion remote, + int diffAt, + bool diffAtFound) +{ + public readonly ClientSyncOpinion local = local; + public readonly ClientSyncOpinion remote = remote; + public readonly int diffAt = diffAt; + private readonly Task metadata = Task.Run(MetadataGenerator.Generate); + private readonly Task replay = Task.Run(SaveReplayIfApplicable); + + public bool ReadyToSave => metadata.IsCompleted && replay.IsCompleted; + + public void Save([CanBeNull] HostInfo hostInfo) + { + var watch = Stopwatch.StartNew(); + + try + { + var desyncFilePath = FindFileNameForNextDesyncFile(); + using var zip = MpZipFile.Open(Path.Combine(Multiplayer.DesyncsDir, desyncFilePath + ".zip"), ZipArchiveMode.Create); + + zip.AddEntry("desync_info", GetDesyncDetails()); + + zip.AddEntry("local_traces.txt", GetLocalTraces()); + zip.AddEntry("host_traces.txt", hostInfo?.Traces ?? "No host traces"); + + zip.AddEntry("local_jitted_methods.txt", JittedMethods.GetJittedMethodsString()); + zip.AddEntry("host_jitted_methods.txt", hostInfo?.JittedMethods ?? "No host jitted methods"); + + var extraLogs = LogGenerator.PrepareLogData(); + if (extraLogs != null) zip.AddEntry("local_logs.txt", extraLogs); + + zip.AddEntry("local_metadata.txt", metadata.Result); + + try + { + replay.Wait(); + } + catch (AggregateException e) + { + if (e.InnerExceptions.SingleOrDefault(inner => inner is TaskCanceledException) == null) throw; + } + if (replay.IsCompletedSuccessfully) { + var replayFile = replay.Result; + zip.CreateEntryFromFile(replayFile.FullName, "replay.rwmts", CompressionLevel.NoCompression); + DeleteFileSilent(replayFile); + } + } + catch (Exception e) + { + Log.Error($"Exception writing desync info: {e}"); + } + + Log.Message($"Desync info writing took {watch.ElapsedMilliseconds}"); + } + + private string GetLocalTraces() + { + var traceMessage = ""; + var localCount = local.desyncStackTraceHashes.Count; + var remoteCount = remote.desyncStackTraceHashes.Count; + int count = Math.Min(localCount, remoteCount); + + if (diffAt == -1) + { + if (count == 0) + { + return $"No traces (remote: {remoteCount}, local: {localCount})"; + } + + traceMessage = "Note: trace hashes are equal between local and remote\n\n"; + } + else if (!diffAtFound) + { + traceMessage = "Note: traces differ in amount, but the existing ones are equal. This means that a tick" + + " has ended sooner on one of the connection sides\n\n"; + } + + traceMessage += local.GetFormattedStackTracesForRange(diffAt); + return traceMessage; + } + + private string GetDesyncDetails() + { + var desyncInfo = new StringBuilder(); + + desyncInfo + .AppendLine("###Tick Data###") + .AppendLine($"Arbiter Connected And Playing|||{Multiplayer.session.ArbiterPlaying}") + .AppendLine($"Last Valid Tick - Local|||{coordinator.lastValidTick}") + .AppendLine($"Last Valid Tick - Arbiter|||{coordinator.arbiterWasPlayingOnLastValidTick}") + .AppendLine("\n###Version Data###") + .AppendLine($"Multiplayer Mod Version|||{MpVersion.Version}") + .AppendLine($"Rimworld Version and Rev|||{VersionControl.CurrentVersionStringWithRev}") + .AppendLine("\n###Debug Options###") + .AppendLine($"Multiplayer Debug Build - Client|||{MpVersion.IsDebug}") + .AppendLine($"Multiplayer Debug Mode - Host|||{Multiplayer.GameComp.debugMode}") + .AppendLine($"Rimworld Developer Mode - Client|||{Prefs.DevMode}") + .AppendLine("\n###Server Info###") + .AppendLine($"Player Count|||{Multiplayer.session.players.Count}") + .AppendLine($"Async time active|||{Multiplayer.GameComp.asyncTime}") + .AppendLine($"Multifaction active|||{Multiplayer.GameComp.multifaction}") + .AppendLine($"Map Count|||{Find.Maps?.Count.ToStringSafe()}") + .AppendLine("\n###CPU Info###") + .AppendLine($"Processor Name|||{SystemInfo.processorType}") + .AppendLine($"Processor Speed (MHz)|||{SystemInfo.processorFrequency}") + .AppendLine($"Thread Count|||{SystemInfo.processorCount}") + .AppendLine("\n###GPU Info###") +#if !RIMWORLD_1_7 .AppendLine($"GPU Family|||{SystemInfo.graphicsDeviceVendor}") - .AppendLine($"GPU Type|||{SystemInfo.graphicsDeviceType}") - .AppendLine($"GPU Name|||{SystemInfo.graphicsDeviceName}") - .AppendLine($"GPU VRAM|||{SystemInfo.graphicsMemorySize}") - .AppendLine("\n###RAM Info###") - .AppendLine($"Physical Memory Present|||{SystemInfo.systemMemorySize}") - .AppendLine("\n###OS Info###") - .AppendLine($"OS Type|||{SystemInfo.operatingSystemFamily}") - .AppendLine($"OS Name and Version|||{SystemInfo.operatingSystem}"); - - return desyncInfo.ToString(); - } - - private static string FindFileNameForNextDesyncFile() - { - const string FilePrefix = "Desync-"; - const string FileExtension = ".zip"; - - //Find all current existing desync zips - var files = new DirectoryInfo(Multiplayer.DesyncsDir).GetFiles($"{FilePrefix}*{FileExtension}"); - - const int MaxFiles = 10; - - //Delete any pushing us over the limit, and reserve room for one more - if (files.Length > MaxFiles - 1) - files.OrderByDescending(f => f.LastWriteTime).Skip(MaxFiles - 1).Do(DeleteFileSilent); - - //Find the current max desync number - int max = 0; - foreach (var f in files) - if (int.TryParse( - f.Name.Substring(FilePrefix.Length, f.Name.Length - FilePrefix.Length - FileExtension.Length), - out int result) && result > max) - max = result; - - return $"{FilePrefix}{max + 1:00}"; - } - - private static Task SaveReplayIfApplicable() - { - if (!Multiplayer.settings.includeReplayInDesync) return Task.FromCanceled(new CancellationToken(true)); - - try - { - var tmp = new FileInfo(Path.Combine(Multiplayer.DesyncsDir, "desync-replay.tmp.zip")); - if (tmp.Exists) tmp.Delete(); - Replay.ForSaving(tmp).WriteData(Multiplayer.session.dataSnapshot); - return Task.FromResult(tmp); - } - catch (Exception e) - { - Log.Error($"Failed to save replay for desync: {e}"); - return Task.FromException(e); - } - } - - private static void DeleteFileSilent(FileInfo file) - { - try - { - file.Delete(); - } - catch (IOException) - { - } - } - - public record HostInfo([CanBeNull] string Traces, [CanBeNull] string JittedMethods); -} +#endif + .AppendLine($"GPU Type|||{SystemInfo.graphicsDeviceType}") + .AppendLine($"GPU Name|||{SystemInfo.graphicsDeviceName}") + .AppendLine($"GPU VRAM|||{SystemInfo.graphicsMemorySize}") + .AppendLine("\n###RAM Info###") + .AppendLine($"Physical Memory Present|||{SystemInfo.systemMemorySize}") + .AppendLine("\n###OS Info###") + .AppendLine($"OS Type|||{SystemInfo.operatingSystemFamily}") + .AppendLine($"OS Name and Version|||{SystemInfo.operatingSystem}"); + + return desyncInfo.ToString(); + } + + private static string FindFileNameForNextDesyncFile() + { + const string FilePrefix = "Desync-"; + const string FileExtension = ".zip"; + + //Find all current existing desync zips + var files = new DirectoryInfo(Multiplayer.DesyncsDir).GetFiles($"{FilePrefix}*{FileExtension}"); + + const int MaxFiles = 10; + + //Delete any pushing us over the limit, and reserve room for one more + if (files.Length > MaxFiles - 1) + files.OrderByDescending(f => f.LastWriteTime).Skip(MaxFiles - 1).Do(DeleteFileSilent); + + //Find the current max desync number + int max = 0; + foreach (var f in files) + if (int.TryParse( + f.Name.Substring(FilePrefix.Length, f.Name.Length - FilePrefix.Length - FileExtension.Length), + out int result) && result > max) + max = result; + + return $"{FilePrefix}{max + 1:00}"; + } + + private static Task SaveReplayIfApplicable() + { + if (!Multiplayer.settings.includeReplayInDesync) return Task.FromCanceled(new CancellationToken(true)); + + try + { + var tmp = new FileInfo(Path.Combine(Multiplayer.DesyncsDir, "desync-replay.tmp.zip")); + if (tmp.Exists) tmp.Delete(); + Replay.ForSaving(tmp).WriteData(Multiplayer.session.dataSnapshot); + return Task.FromResult(tmp); + } + catch (Exception e) + { + Log.Error($"Failed to save replay for desync: {e}"); + return Task.FromException(e); + } + } + + private static void DeleteFileSilent(FileInfo file) + { + try + { + file.Delete(); + } + catch (IOException) + { + } + } + + public record HostInfo([CanBeNull] string Traces, [CanBeNull] string JittedMethods); +} diff --git a/Source/Client/Desyncs/StackTraceLogItem.cs b/Source/Client/Desyncs/StackTraceLogItem.cs index b7da27da5..a57d6c69d 100644 --- a/Source/Client/Desyncs/StackTraceLogItem.cs +++ b/Source/Client/Desyncs/StackTraceLogItem.cs @@ -1,92 +1,95 @@ -using System; -using System.Collections.Generic; -using System.Text; -using Multiplayer.Client.Desyncs; -using Verse; - -namespace Multiplayer.Client -{ - public abstract class StackTraceLogItem : IDisposable - { - public int tick; - public int hash; - - public abstract string AdditionalInfo { get; } - - public abstract string StackTraceString { get; } - - public override string ToString() - { - return $"Tick:{tick} Hash:{hash} '{AdditionalInfo}'\n{StackTraceString}"; - } - - public virtual void Dispose() - { - } - } - - public class StackTraceLogItemObj : StackTraceLogItem - { - public string info1; - public string info2; - - public override string AdditionalInfo => $"{info1} {info2}"; - - public override string StackTraceString => ""; - } - - public class StackTraceLogItemRaw : StackTraceLogItem - { - public const int MaxDepth = 32; - public long[] raw = new long[MaxDepth]; +using System; +using System.Collections.Generic; +using System.Text; +using Multiplayer.Client.Desyncs; +using Verse; + +namespace Multiplayer.Client +{ + public abstract class StackTraceLogItem : IDisposable + { + public int tick; + public int hash; + + public abstract string AdditionalInfo { get; } + + public abstract string StackTraceString { get; } + + public override string ToString() + { + return $"Tick:{tick} Hash:{hash} '{AdditionalInfo}'\n{StackTraceString}"; + } + + public virtual void Dispose() + { + } + } + + public class StackTraceLogItemObj : StackTraceLogItem + { + public string info1; + public string info2; + + public override string AdditionalInfo => $"{info1} {info2}"; + + public override string StackTraceString => ""; + } + + public class StackTraceLogItemRaw : StackTraceLogItem + { + public const int MaxDepth = 32; + public long[] raw = new long[MaxDepth]; public int depth; - - public int ticksGame; - public ulong rngState; - public ThingDef thingDef; - public int thingId; - public string factionName; - public string moreInfo; - - public override string AdditionalInfo => $"{ticksGame} {thingDef}{thingId} {factionName} {depth} {rngState} {moreInfo}"; - - private static Dictionary methodNameCache = new(); - - public override string StackTraceString - { + public string managedTrace; + + public int ticksGame; + public ulong rngState; + public ThingDef thingDef; + public int thingId; + public string factionName; + public string moreInfo; + + public override string AdditionalInfo => $"{ticksGame} {thingDef}{thingId} {factionName} {depth} {rngState} {moreInfo}"; + + private static Dictionary methodNameCache = new(); + + public override string StackTraceString + { get { + if (managedTrace != null) return managedTrace; var builder = new StringBuilder(); - for (int i = 0; i < depth; i++) - { - var addr = raw[i]; - - if (!methodNameCache.TryGetValue(addr, out string method)) - methodNameCache[addr] = method = Native.MethodNameFromAddr(raw[i], false); - - builder.AppendLine(method != null ? SyncCoordinator.MethodNameWithIL(method) : "Null"); - } - - return builder.ToString(); - } - } - - public static StackTraceLogItemRaw GetFromPool() - { - return SimplePool.Get(); - } - + for (int i = 0; i < depth; i++) + { + var addr = raw[i]; + + if (!methodNameCache.TryGetValue(addr, out string method)) + methodNameCache[addr] = method = Native.MethodNameFromAddr(raw[i], false); + + builder.AppendLine(method != null ? SyncCoordinator.MethodNameWithIL(method) : "Null"); + } + + return builder.ToString(); + } + } + + public static StackTraceLogItemRaw GetFromPool() + { + return SimplePool.Get(); + } + public override void Dispose() { + managedTrace = null; depth = 0; - ticksGame = 0; - rngState = 0; - thingId = 0; - thingDef = null; - factionName = null; - moreInfo = null; - - SimplePool.Return(this); - } - } -} + ticksGame = 0; + rngState = 0; + thingId = 0; + thingDef = null; + factionName = null; + moreInfo = null; + + SimplePool.Return(this); + } + } +} diff --git a/Source/Client/Factions/MultifactionPatches.cs b/Source/Client/Factions/MultifactionPatches.cs index 9eea55518..51e3f143e 100644 --- a/Source/Client/Factions/MultifactionPatches.cs +++ b/Source/Client/Factions/MultifactionPatches.cs @@ -1,959 +1,959 @@ -using HarmonyLib; -using KTrie; -using Multiplayer.API; -using Multiplayer.Client.Factions; -using RimWorld; -using RimWorld.Planet; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Reflection.Emit; -using System.Runtime.Remoting.Messaging; -using UnityEngine; -using Verse; -using Verse.AI; -using Verse.Sound; -using static HarmonyLib.AccessTools; - -namespace Multiplayer.Client.Patches; - -[HarmonyPatch(typeof(MainTabWindow_Quests), nameof(MainTabWindow_Quests.DoRow))] -public static class MainTabWindow_QuestsDoRowPatch -{ - public static void Prefix(ref Rect rect, Quest quest) - { - if (Multiplayer.Client != null && !Multiplayer.settings.hideOtherPlayersQuests) - { - Faction playerFaction; - if (quest.TryGetPlayerFaction(out playerFaction)) - { - Rect iconRect = new Rect(rect.x + 2f, rect.y + 2f, 4f, rect.height - 4f); - Widgets.DrawBoxSolid(iconRect, playerFaction.Color); - rect.xMin += 8f; - TooltipHandler.TipRegion(rect, "MpQuestDesc".Translate(playerFaction.Name, playerFaction == Faction.OfPlayer ? ". (you)" : ".")); - } - else - { - TooltipHandler.TipRegion(rect, "MpPublicQuest".Translate()); - } - } - } -} - -[HarmonyPatch(typeof(MainTabWindow_Quests), nameof(MainTabWindow_Quests.ShouldListNow))] -public static class MainTabWindow_QuestsShouldListNowPatch -{ - static bool Prefix(Quest quest, ref bool __result) - { - if (Multiplayer.Client != null && Multiplayer.settings.hideOtherPlayersQuests) - { - Faction playerFaction; - if (quest.TryGetPlayerFaction(out playerFaction) && playerFaction != Faction.OfPlayer) - { - __result = false; - return false; - } - } - return true; - } -} - -[HarmonyPatch(typeof(WorldObject), nameof(WorldObject.ShowRelatedQuests), MethodType.Getter)] -static class WorldObjectShowRelatedQuestsPatch -{ - static void Postfix(ref bool __result) - { - if (Multiplayer.Client != null && Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction) - __result = false; - } -} - -[HarmonyPatch(typeof(Settlement), nameof(Settlement.ShowRelatedQuests), MethodType.Getter)] -static class SettlementShowRelatedQuestsPatch -{ - static void Postfix(ref bool __result) - { - if (Multiplayer.Client != null && Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction) - __result = false; - } -} - -[HarmonyPatch(typeof(WorldInspectPane), nameof(WorldInspectPane.PaneTopY), MethodType.Getter)] -static class WorldInspectPanePaneTopYPatch -{ - static void Postfix(ref float __result) - { - if (Multiplayer.Client != null && Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction) - __result += 35f; - } -} - -[HarmonyPatch(typeof(MainTabWindow_Quests), nameof(MainTabWindow_Quests.DoRewardsPrefsButton))] -public static class MainTabWindow_QuestsDoRewardsPrefsButtonPatch -{ - public static bool Prefix(MainTabWindow_Quests __instance, Rect rect, out Rect __result) - { - if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) - { - __result = rect; - return true; - } - rect.yMin = rect.yMax - 40f; - - float buttonHeight = 40f; - - float rewardButtonWidth = rect.width * 0.5f - 5f; - float toggleButtonWidth = rect.width - rewardButtonWidth - 10f; - - Rect rewardRect = new Rect(rect.x, rect.yMax - buttonHeight, rewardButtonWidth, buttonHeight); - Text.Font = GameFont.Small; - if (Widgets.ButtonText(rewardRect, "ChooseRewards".Translate(), true, true, true)) - { - Find.WindowStack.Add(new Dialog_RewardPrefsConfig()); - } - - Rect toggleRect = new Rect(rewardRect.xMax + 10f, rewardRect.y, toggleButtonWidth, buttonHeight); - bool value = Multiplayer.settings.hideOtherPlayersQuests; - string label = value ? "MpHideQuestsOn".Translate() : "MpHideQuestsOff".Translate(); - if (Widgets.ButtonText(toggleRect, label, true, true)) - { - Multiplayer.settings.hideOtherPlayersQuests = !value; - SoundDefOf.Tick_High.PlayOneShotOnCamera(); - } - TooltipHandler.TipRegion(toggleRect, "MpHideQuestsDesc".Translate()); - - __result = rect; - return false; - } -} - -[HarmonyPatch(typeof(ColonistBar), nameof(ColonistBar.CheckRecacheEntries))] -public static class ColonistBarCheckRecacheEntriesPatch -{ - static IEnumerable Transpiler(IEnumerable insts) - { - var findMapsGetter = AccessTools.PropertyGetter(typeof(Find), nameof(Find.Maps)); - foreach (var inst in insts) - { - if (inst.Calls(findMapsGetter)) - { - yield return new CodeInstruction(OpCodes.Call, - AccessTools.Method(typeof(ColonistBarCheckRecacheEntriesPatch), nameof(GetFilteredMaps))); - } - else - { - yield return inst; - } - } - } - - public static IEnumerable GetFilteredMaps() - { - if (Multiplayer.Client == null || !Multiplayer.settings.hideOtherPlayersInColonistBar) - return Find.Maps; - - return Find.Maps.Where(map => - map.mapPawns.FreeColonistsSpawned.Any() || - map.Parent?.Faction == Faction.OfPlayer); - } -} - -[HarmonyPatch(typeof(WorldRoutePlanner), nameof(WorldRoutePlanner.DoRoutePlannerButton))] -public static class WorldRoutePlannerPatch -{ - static bool Prefix() - { - return Multiplayer.Client == null || Multiplayer.RealPlayerFaction != Multiplayer.WorldComp.spectatorFaction; - } -} - -[HarmonyPatch(typeof(WorldGizmoUtility), nameof(WorldGizmoUtility.TryGetCaravanGizmo))] -public static class WorldGizmoUtilityTryGetCaravanGizmoPatch -{ - static bool Prefix(ref Gizmo gizmo, ref bool __result) - { - if (Multiplayer.Client != null && Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction) - { - gizmo = null; - __result = false; - return false; - } - return true; - } -} - -[HarmonyPatch(typeof(MainButtonsRoot), nameof(MainButtonsRoot.DoButtons))] -static class MainButtonsRootDoButtonsPatch -{ - private static bool IsSpectator => - Multiplayer.Client != null && - Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction; - - private static readonly string[] SpectatorButtons = { "Menu", "World" }; - - private static readonly FieldRef> AllButtonsRef = AccessTools.FieldRefAccess>("allButtonsInOrder"); - - private static readonly Dictionary SpectatorButtonOrder = SpectatorButtons.Select((name, idx) => new { name, idx }).ToDictionary(x => x.name, x => x.idx); - - [HarmonyPrefix] - private static bool ReplaceOriginalDrawing(MainButtonsRoot __instance) - { - if (!IsSpectator) - return true; - - try - { - var allButtons = AllButtonsRef(__instance); - if (allButtons == null) - return true; - - var toDraw = SpectatorButtons - .Select(name => allButtons.Find(b => b.defName == name)) - .Where(b => b?.Worker.Visible ?? false) - .ToList(); - - DrawCornerButtons(toDraw); - return false; - } - catch (Exception ex) - { - Log.Error($"[MainButtonsRootPatch] ReplaceOriginalDrawing failed: {ex}"); - return true; - } - } - - [HarmonyPostfix] - private static void FilterButtons(MainButtonsRoot __instance) - { - try - { - var allButtons = AllButtonsRef(__instance); - if (allButtons == null) - return; - - if (IsSpectator) - { - // Keep only SpectatorButtons, sorted by our map - var filtered = allButtons - .Where(b => SpectatorButtonOrder.ContainsKey(b.defName)) - .OrderBy(b => SpectatorButtonOrder[b.defName]) - .ToList(); - - AllButtonsRef(__instance) = filtered; - } - else - { - // Restore original full list in normal mode - var original = DefDatabase.AllDefsListForReading - .Where(b => b.order >= 0) - .OrderBy(b => b.order) - .ToList(); - - AllButtonsRef(__instance) = original; - } - } - catch (Exception ex) - { - Log.Error($"[MainButtonsRootPatch] FilterButtons failed: {ex}"); - } - } - - private static void DrawCornerButtons(List buttons) - { - const float buttonHeight = 35f; - const float defaultPadding = 20f; - const float spacing = 0f; - - float x = UI.screenWidth; - float y = UI.screenHeight - buttonHeight; - - foreach (var btn in buttons) - { - float padding = btn.defName == "World" ? 120f : defaultPadding; - float width = btn.ShortenedLabelCapWidth + padding; - - x -= width; - btn.Worker.DoButton(new Rect(x, y, width, buttonHeight)); - x -= spacing; - } - } -} - - -[HarmonyPatch(typeof(Pawn_DraftController), nameof(Pawn_DraftController.GetGizmos))] -static class DisableDraftGizmo -{ - static IEnumerable Postfix(IEnumerable gizmos, Pawn_DraftController __instance) - { - return __instance.pawn.Faction == Faction.OfPlayer ? gizmos : Enumerable.Empty(); - } -} - -[HarmonyPatch(typeof(Pawn), nameof(Pawn.GetGizmos))] -static class PawnChangeRelationGizmo -{ - static IEnumerable Postfix(IEnumerable gizmos, Pawn __instance) - { - foreach (var gizmo in gizmos) - yield return gizmo; - - if (Multiplayer.Client == null || Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction) - yield break; - - if (__instance.Faction is { IsPlayer: true } && __instance.Faction != Faction.OfPlayer) - { - var otherFaction = __instance.Faction; - - yield return new Command_Action - { - defaultLabel = "Change faction relation", - icon = MultiplayerStatic.ChangeRelationIcon, - action = () => - { - List list = new List - { - new(FactionRelationKind.Hostile.ToString(), () => - { - SetRelation(otherFaction, FactionRelationKind.Hostile); - }), - new(FactionRelationKind.Neutral.ToString(), () => - { - SetRelation(otherFaction, FactionRelationKind.Neutral); - }) - }; - - Find.WindowStack.Add(new FloatMenu(list)); - } - }; - } - } - - [SyncMethod] - static void SetRelation(Faction other, FactionRelationKind kind) - { - Faction.OfPlayer.SetRelation(new FactionRelation(other, kind)); - - foreach (Map map in Find.Maps) - map.attackTargetsCache.Notify_FactionHostilityChanged(Faction.OfPlayer, other); - } -} - -[HarmonyPatch(typeof(SettlementDefeatUtility), nameof(SettlementDefeatUtility.CheckDefeated))] -static class CheckDefeatedPatch -{ - static bool Prefix(Settlement factionBase) - { - return factionBase.Faction is not { IsPlayer: true }; - } -} - -[HarmonyPatch(typeof(MapParent), nameof(MapParent.CheckRemoveMapNow))] -static class CheckRemoveMapNowPatch -{ - static bool Prefix(MapParent __instance) - { - if (Multiplayer.Client == null) return true; - if (__instance is Camp) return true; - - bool parentBelongsToNonPlayerFaction = __instance.Faction is not { IsPlayer: true }; - - return parentBelongsToNonPlayerFaction; - } -} - -[HarmonyPatch(typeof(Settlement), nameof(Settlement.Attackable), MethodType.Getter)] -static class SettlementAttackablePatch -{ - static bool Prefix(Settlement __instance) => __instance.Faction is not { IsPlayer: true }; -} - -[HarmonyPatch(typeof(Settlement), nameof(Settlement.Material), MethodType.Getter)] -static class SettlementNullFactionPatch1 -{ - static bool Prefix(Settlement __instance, ref Material __result) - { - if (__instance.factionInt == null) - { - __result = BaseContent.BadMat; - return false; - } - - return true; - } -} - -[HarmonyPatch(typeof(Settlement), nameof(Settlement.ExpandingIcon), MethodType.Getter)] -static class SettlementNullFactionPatch2 -{ - static bool Prefix(Settlement __instance, ref Texture2D __result) - { - if (__instance.factionInt == null) - { - __result = BaseContent.BadTex; - return false; - } - - return true; - } -} - -[HarmonyPatch(typeof(GoodwillSituationManager), nameof(GoodwillSituationManager.GetNaturalGoodwill))] -static class GetNaturalGoodwillPatch -{ - static bool Prefix(Faction other) - { - return other is not { IsPlayer: true }; - } -} - -[HarmonyPatch(typeof(GoodwillSituationManager), nameof(GoodwillSituationManager.GetMaxGoodwill))] -static class GetMaxGoodwillPatch -{ - static bool Prefix(Faction other) - { - return other is not { IsPlayer: true }; - } -} - -[HarmonyPatch(typeof(ScenPart_StartingAnimal), nameof(ScenPart_StartingAnimal.PetWeight))] -static class StartingAnimalPatch -{ - static IEnumerable Transpiler(IEnumerable insts) - { - var playerFactionField = AccessTools.Field(typeof(GameInitData), nameof(GameInitData.playerFaction)); - var factionOfPlayer = FactionOfPlayer; - - foreach (var inst in insts) - { - yield return inst; - - if (inst.operand as FieldInfo == playerFactionField) - yield return new CodeInstruction(OpCodes.Call, factionOfPlayer.Method); - } - } - - static Faction FactionOfPlayer(Faction f) - { - return Faction.OfPlayer; - } -} - -[HarmonyPatch(typeof(Pawn), nameof(Pawn.IsColonist), MethodType.Getter)] -static class PawnIsColonistPatch -{ - static IEnumerable Transpiler(IEnumerable insts) - { - var isPlayerMethodGetter = AccessTools.PropertyGetter(typeof(Faction), nameof(Faction.IsPlayer)); - var factionIsPlayer = FactionIsPlayer; - - foreach (var inst in insts) - { - if (inst.operand as MethodInfo == isPlayerMethodGetter) - inst.operand = factionIsPlayer.Method; - - yield return inst; - } - } - - static bool FactionIsPlayer(Faction f) - { - return f == Find.FactionManager.OfPlayer; - } -} - -[HarmonyPatch(typeof(Ideo), nameof(Ideo.RecacheColonistBelieverCount))] -static class RecacheColonistBelieverCountPatch -{ - private static MethodInfo allColonists = AccessTools.PropertyGetter(typeof(PawnsFinder), nameof(PawnsFinder.AllMapsCaravansAndTravellingTransporters_Alive_FreeColonists_NoCryptosleep)); - - static IEnumerable Transpiler(IEnumerable insts) - { - foreach (var inst in insts) - { - if (inst.operand as MethodInfo == allColonists) - inst.operand = AccessTools.Method(typeof(RecacheColonistBelieverCountPatch), nameof(ColonistsAllPlayerFactions)); - yield return inst; - } - } - - private static List colonistsAllFactions = new(); - - private static List ColonistsAllPlayerFactions() - { - colonistsAllFactions.Clear(); - - foreach (var p in PawnsFinder.AllMapsCaravansAndTravellingTransporters_Alive) - { - if (IsColonistAnyFaction(p) && p.HostFaction == null && !p.InCryptosleep) - colonistsAllFactions.Add(p); - } - - return colonistsAllFactions; - } - - // Note: IsColonist is patched above to compare to Faction.OfPlayer - public static bool IsColonistAnyFaction(Pawn p) - { - if (p.Faction is { IsPlayer: true } && p.RaceProps.Humanlike) - return !p.IsSlave || p.guest.SlaveIsSecure; - return false; - } - - public static bool IsColonyMechAnyFaction(Pawn p) - { - if (ModsConfig.BiotechActive && p.RaceProps.IsMechanoid && p.Faction is { IsPlayer: true } && p.MentalStateDef == null) - return p.HostFaction == null || p.IsSlave; - return false; - } - - public static bool IsColonySubhumanAnyFaction(Pawn p) - { - return p.IsSubhuman && p.Faction is { IsPlayer: true }; - } -} - -[HarmonyPatch(typeof(MapPawns), nameof(MapPawns.AnyPawnBlockingMapRemoval), MethodType.Getter)] -static class AnyPawnBlockingMapRemovalPatch -{ - private static MethodInfo isColonist = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsColonist)); - private static MethodInfo isColonyMech = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsColonyMech)); - - static IEnumerable Transpiler(IEnumerable insts) - { - foreach (var inst in insts) - { - if (inst.operand as MethodInfo == isColonyMech) - inst.operand = AccessTools.Method(typeof(RecacheColonistBelieverCountPatch), nameof(RecacheColonistBelieverCountPatch.IsColonyMechAnyFaction)); - - yield return inst; - } - } - - static void Postfix(MapPawns __instance, ref bool __result) - { - for (int i = 0; i < __instance.pawnsSpawned.Count; i++) - { - var p = __instance.pawnsSpawned[i]; - if (p.Faction is { IsPlayer: true } || p.HostFaction is { IsPlayer: true }) - { - Job curJob = p.CurJob; - if (curJob is { exitMapOnArrival: true }) - { - __result = true; - break; - } - - if (p.health.hediffSet.InLabor()) - { - __result = true; - break; - } - } - } - } -} - -[HarmonyPatch(typeof(MapPawns), nameof(MapPawns.IsValidColonyPawn))] -static class IsValidColonyPawnPatch -{ - private static MethodInfo isColonist = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsColonist)); - private static MethodInfo isColonySubhuman = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsColonySubhuman)); - - static IEnumerable Transpiler(IEnumerable insts) - { - foreach (var inst in insts) - { - if (inst.operand as MethodInfo == isColonist) - inst.operand = AccessTools.Method(typeof(RecacheColonistBelieverCountPatch), nameof(RecacheColonistBelieverCountPatch.IsColonistAnyFaction)); - - if (inst.operand as MethodInfo == isColonySubhuman) - inst.operand = AccessTools.Method(typeof(RecacheColonistBelieverCountPatch), nameof(RecacheColonistBelieverCountPatch.IsColonySubhumanAnyFaction)); - - yield return inst; - } - } -} - -[HarmonyPatch(typeof(Precept_Role), nameof(Precept_Role.ValidatePawn))] -static class ValidatePawnPatch -{ - private static MethodInfo isFreeNonSlaveColonist = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsFreeNonSlaveColonist)); - - static IEnumerable Transpiler(IEnumerable insts) - { - foreach (var inst in insts) - { - if (inst.operand as MethodInfo == isFreeNonSlaveColonist) - inst.operand = AccessTools.Method(typeof(ValidatePawnPatch), nameof(IsFreeNonSlaveColonistAnyFaction)); - - yield return inst; - } - } - - public static bool IsFreeNonSlaveColonistAnyFaction(Pawn p) - { - return p.Faction is { IsPlayer: true } && p.RaceProps.Humanlike && p.HostFaction == null && !p.IsSlave; - } -} - -[HarmonyPatch(typeof(Faction), nameof(Faction.HasGoodwill), MethodType.Getter)] -static class PlayerFactionsHaveGoodwill -{ - static void Postfix(Faction __instance, ref bool __result) - { - if (__instance.IsPlayer) - __result = true; - } -} - -[HarmonyPatch(typeof(GenHostility), nameof(GenHostility.IsActiveThreatToPlayer))] -static class IsActiveThreatToAnyPlayer -{ - static void Postfix(IAttackTarget target, ref bool __result) - { - foreach (var f in Find.FactionManager.AllFactions) - if (f.IsPlayer) - __result |= GenHostility.IsActiveThreatTo(target, f); - } -} - -[HarmonyPatch] -static class TakeToBedGuestFactionPatch -{ - static IEnumerable TargetMethods() - { - yield return AccessTools.DeclaredMethod(typeof(JobDriver_TakeToBed), nameof(JobDriver_TakeToBed.CheckMakeTakeeGuest)); - yield return AccessTools.DeclaredMethod(typeof(JobDriver_CarryDownedPawn), nameof(JobDriver_CarryDownedPawn.CheckMakeTakeeGuest)); - } - static bool Prefix(JobDriver __instance) - { - var takee = __instance.job.GetTarget(TargetIndex.A).Pawn; - return takee?.Faction?.IsPlayer != true && takee?.HostFaction?.IsPlayer != true; - } -} - - -[HarmonyPatch(typeof(LetterStack), nameof(LetterStack.ReceiveLetter), typeof(Letter), typeof(string), typeof(int), typeof(bool))] -static class LetterStackReceiveOnlyMyFaction -{ - // todo the letter might get culled from the archive if it isn't in the stack and Sync depends on the archive - static void Postfix(LetterStack __instance, Letter let) - { - if (Multiplayer.RealPlayerFaction != Faction.OfPlayer) - __instance.letters.Remove(let); - } -} - -[HarmonyPatch(typeof(LetterStack), nameof(LetterStack.ReceiveLetter), typeof(Letter), typeof(string), typeof(int), typeof(bool))] -static class LetterStackReceiveSoundOnlyMyFaction -{ - private static MethodInfo PlayOneShotOnCamera = - typeof(SoundStarter).GetMethod(nameof(SoundStarter.PlayOneShotOnCamera)); - - static IEnumerable Transpiler(IEnumerable insts) - { - foreach (var inst in insts) - { - if (inst.operand as MethodInfo == PlayOneShotOnCamera) - yield return new CodeInstruction( - OpCodes.Call, - SymbolExtensions.GetMethodInfo((SoundDef s, Map m) => PlaySoundReplacement(s, m))); - else - yield return inst; - } - } - - static void PlaySoundReplacement(SoundDef sound, Map map) - { - if (Multiplayer.RealPlayerFaction == Faction.OfPlayer) - sound.PlayOneShotOnCamera(map); - } -} - -[HarmonyPatch(typeof(Apparel), nameof(Apparel.WornGraphicPath), MethodType.Getter)] -static class ApparelWornGraphicPathGetterPatch -{ - private static FieldInfo thingIDNumberField = AccessTools.Field(typeof(Thing), nameof(Thing.thingIDNumber)); - - static IEnumerable Transpiler(IEnumerable insts) - { - foreach (var inst in insts) - { - yield return inst; - - // This instruction is part of wornGraphicPaths[thingIDNumber % wornGraphicPaths.Count] - // The function makes sure the id is positive - if (inst.operand as FieldInfo == thingIDNumberField) - yield return new CodeInstruction(OpCodes.Call, - AccessTools.Method(typeof(ApparelWornGraphicPathGetterPatch), nameof(MakeIdPositive))); - } - } - - private static int MakeIdPositive(int id) - { - return id < 0 ? -id : id; - } -} - -#region Map generation - -[HarmonyPatch(typeof(GenStep_Monolith), nameof(GenStep_Monolith.GenerateMonolith))] -public static class GenStepMonolithPatch -{ - static bool Prefix() - { - if (Multiplayer.Client == null) - return true; - - var anomalyComp = Current.Game.components.OfType().FirstOrDefault(); - - return anomalyComp?.monolith == null; - } -} - -[HarmonyPatch(typeof(ScenPart_ScatterThings), nameof(ScenPart_ScatterThings.GenerateIntoMap))] -static class ScenPartScatterThingsPatch -{ - static bool Prefix() - { - return Multiplayer.Client == null || FactionCreator.generatingMap; - } -} - -[HarmonyPatch(typeof(ScenPart_PlayerPawnsArriveMethod), nameof(ScenPart_PlayerPawnsArriveMethod.GenerateIntoMap))] -static class ScenPartPlayerPawnsArriveMethodPatch -{ - static bool Prefix() - { - return Multiplayer.Client == null || FactionCreator.generatingMap; - } -} - -#endregion - -[HarmonyPatch(typeof(CharacterCardUtility), nameof(CharacterCardUtility.DoTopStack))] -static class CharacterCardUtilityDontDrawIdeoPlate -{ - private static FieldInfo classicModeField = AccessTools.Field(typeof(IdeoManager), nameof(IdeoManager.classicMode)); - - static IEnumerable Transpiler(IEnumerable insts) - { - foreach (var inst in insts) - { - yield return inst; - - // Don't draw the ideo plate while choosing starting pawns in multifaction - if (inst.operand as FieldInfo == classicModeField) - { - yield return new CodeInstruction(OpCodes.Ldarg_2); - yield return new CodeInstruction(OpCodes.Call, - AccessTools.Method(typeof(CharacterCardUtilityDontDrawIdeoPlate), nameof(DontDrawIdeoPlate))); - yield return new CodeInstruction(OpCodes.Or); - } - } - } - - private static bool DontDrawIdeoPlate(bool generating) - { - return Multiplayer.Client != null && generating; - } -} - -[HarmonyPatch(typeof(CompShuttle), nameof(CompShuttle.ContainedColonistCount), MethodType.Getter)] -static class CompShuttle_ContainedColonistCount_Patch -{ - static IEnumerable Transpiler(IEnumerable insts) - { - var isFreeColonist = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsFreeColonist)); - var replacement = AccessTools.Method(typeof(CompShuttle_ContainedColonistCount_Patch), - nameof(IsFreeColonistAnyPlayerFaction)); - - foreach (var ci in insts) - { - if (ci.Calls(isFreeColonist)) - { - ci.opcode = OpCodes.Call; - ci.operand = replacement; - } - yield return ci; - } - } - public static bool IsFreeColonistAnyPlayerFaction(Pawn pawn) - { - if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) - return pawn.IsFreeColonist; - - return pawn.Faction != null && - pawn.Faction.IsPlayer && - pawn.RaceProps.Humanlike && - (!pawn.IsSlave || pawn.guest.SlaveIsSecure) && - !pawn.IsSubhuman && - pawn.HostFaction == null; - } - -} - -[HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.RemoveGravshipFromMap))] -static class RemoveGravshipFromMap_Patch -{ - static void Prefix(Building_GravEngine engine, out bool __state) - { - __state = false; - if (Multiplayer.Client == null) return; - if (!Multiplayer.GameComp.multifaction) return; - if (engine == null) return; - - engine.Map.PushFaction(engine.Faction); - - __state = true; - } - - static void Finalizer(Building_GravEngine engine, bool __state) - { - if (!__state) return; - engine.Map.PopFaction(); - } -} - -[HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.PlaceGravship))] -static class PlaceGravship_FactionContext_Patch -{ - static void Prefix(Gravship gravship, Map map, out bool __state) - { - __state = false; - if (Multiplayer.Client == null) return; - if (!Multiplayer.GameComp.multifaction) return; - - map.PushFaction(gravship.Faction); - - __state = true; - } - - static void Finalizer(Gravship gravship, Map map, bool __state) - { - if (!__state) return; - map.PopFaction(); - } -} - -[HarmonyPatch(typeof(QuestPart_LendColonistsToFaction), nameof(QuestPart_LendColonistsToFaction.Enable))] -static class QuestPart_LendColonistsToFaction_Enable_Patch -{ - static IEnumerable Transpiler(IEnumerable insts) - { - var isFreeColonist = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsFreeColonist)); - var replacement = AccessTools.Method(typeof(CompShuttle_ContainedColonistCount_Patch), - nameof(CompShuttle_ContainedColonistCount_Patch.IsFreeColonistAnyPlayerFaction)); - foreach (var ci in insts) - { - if (ci.Calls(isFreeColonist)) - { - ci.opcode = OpCodes.Call; - ci.operand = replacement; - } - yield return ci; - } - } -} - -[HarmonyPatch(typeof(Map), nameof(Map.IsPlayerHome), MethodType.Getter)] -static class Map_IsPlayerHome_Spectator_Patch -{ - static bool Prefix(Map __instance, ref bool __result) - { - if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction || - Faction.OfPlayer != Multiplayer.WorldComp.spectatorFaction) - return true; - - { - // Is repeat through all player faction a better idea? - if (!__instance.wasSpawnedViaGravShipLanding) - { - MapInfo mapInfo = __instance.info; - if (((mapInfo != null) ? mapInfo.parent : null) == null || __instance.info.parent.Faction?.IsPlayer != true || !__instance.info.parent.def.canBePlayerHome) - { - __result = GravshipUtility.PlayerHasGravEngine(__instance); - return false; - } - } - __result = true; - return false; - } - } -} - -[HarmonyPatch] -static class Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch -{ - static MethodInfo TargetMethod() - { - return AccessTools.EnumeratorMoveNext(AccessTools.Method(typeof(Pawn_TraderTracker), nameof(Pawn_TraderTracker.ColonyThingsWillingToBuy))); - } - public static List AllBuildingsColonistOfDefOfPlayer(this ListerBuildings lister, ThingDef def) - { - if (def.CanHaveFaction) - return lister.AllBuildingsColonistOfDef(def).FindAll(building => building.Faction == Faction.OfPlayer); - else - return lister.AllBuildingsColonistOfDef(def); - } - public static IEnumerable AllColonistBuildingsOfTypeOfPlayer(this ListerBuildings lister) - { - return lister.AllColonistBuildingsOfType().Where(t => - { - if (t is Building building && (!building.def.CanHaveFaction || building.Faction == Faction.OfPlayer)) - return true; - return false; - }); - } - static IEnumerable Transpiler(IEnumerable insts) - { - var allBuildingsColonistOfDef = AccessTools.Method(typeof(ListerBuildings), nameof(ListerBuildings.AllBuildingsColonistOfDef)); - var allBuildingsColonistOfDefOfPlayer = AccessTools.Method(typeof(Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch), nameof(Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch.AllBuildingsColonistOfDefOfPlayer)); - var allColonistBuildingsOfType = AccessTools.Method(typeof(ListerBuildings), nameof(ListerBuildings.AllColonistBuildingsOfType)).MakeGenericMethod([typeof(IHaulSource)]); - var allColonistBuildingsOfTypeOfPlayer = AccessTools.Method(typeof(Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch), nameof(Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch.AllColonistBuildingsOfTypeOfPlayer)).MakeGenericMethod([typeof(IHaulSource)]); - foreach (var ci in insts) - { - if (ci.Calls(allBuildingsColonistOfDef)) - { - ci.opcode = OpCodes.Call; - ci.operand = allBuildingsColonistOfDefOfPlayer; - } - else if (ci.Calls(allColonistBuildingsOfType)) - { - ci.opcode = OpCodes.Call; - ci.operand = allColonistBuildingsOfTypeOfPlayer; - } - yield return ci; - } - } -} - -[HarmonyPatch] -static class TradeUtility_AllLaunchableThingsForTrade_Patch -{ - static MethodInfo TargetMethod() - { - return AccessTools.EnumeratorMoveNext(AccessTools.Method(typeof(TradeUtility), nameof(TradeUtility.AllLaunchableThingsForTrade))); - } - - public static IEnumerable AllPoweredOfPlayer(Map map) - { - return Building_OrbitalTradeBeacon.AllPowered(map).Where(beacon => beacon.Faction == Faction.OfPlayer); - } - static IEnumerable Transpiler(IEnumerable insts) - { - var allPowered = AccessTools.Method(typeof(Building_OrbitalTradeBeacon), nameof(Building_OrbitalTradeBeacon.AllPowered)); - var allPoweredOfPlayer = AccessTools.Method(typeof(TradeUtility_AllLaunchableThingsForTrade_Patch), nameof(TradeUtility_AllLaunchableThingsForTrade_Patch.AllPoweredOfPlayer)); - foreach (var ci in insts) - { - if (ci.Calls(allPowered)) - { - ci.opcode = OpCodes.Call; - ci.operand = allPoweredOfPlayer; - } - - yield return ci; - } - } -} +using HarmonyLib; +using KTrie; +using Multiplayer.API; +using Multiplayer.Client.Factions; +using RimWorld; +using RimWorld.Planet; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; + +using UnityEngine; +using Verse; +using Verse.AI; +using Verse.Sound; +using static HarmonyLib.AccessTools; + +namespace Multiplayer.Client.Patches; + +[HarmonyPatch(typeof(MainTabWindow_Quests), nameof(MainTabWindow_Quests.DoRow))] +public static class MainTabWindow_QuestsDoRowPatch +{ + public static void Prefix(ref Rect rect, Quest quest) + { + if (Multiplayer.Client != null && !Multiplayer.settings.hideOtherPlayersQuests) + { + Faction playerFaction; + if (quest.TryGetPlayerFaction(out playerFaction)) + { + Rect iconRect = new Rect(rect.x + 2f, rect.y + 2f, 4f, rect.height - 4f); + Widgets.DrawBoxSolid(iconRect, playerFaction.Color); + rect.xMin += 8f; + TooltipHandler.TipRegion(rect, "MpQuestDesc".Translate(playerFaction.Name, playerFaction == Faction.OfPlayer ? ". (you)" : ".")); + } + else + { + TooltipHandler.TipRegion(rect, "MpPublicQuest".Translate()); + } + } + } +} + +[HarmonyPatch(typeof(MainTabWindow_Quests), nameof(MainTabWindow_Quests.ShouldListNow))] +public static class MainTabWindow_QuestsShouldListNowPatch +{ + static bool Prefix(Quest quest, ref bool __result) + { + if (Multiplayer.Client != null && Multiplayer.settings.hideOtherPlayersQuests) + { + Faction playerFaction; + if (quest.TryGetPlayerFaction(out playerFaction) && playerFaction != Faction.OfPlayer) + { + __result = false; + return false; + } + } + return true; + } +} + +[HarmonyPatch(typeof(WorldObject), nameof(WorldObject.ShowRelatedQuests), MethodType.Getter)] +static class WorldObjectShowRelatedQuestsPatch +{ + static void Postfix(ref bool __result) + { + if (Multiplayer.Client != null && Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction) + __result = false; + } +} + +[HarmonyPatch(typeof(Settlement), nameof(Settlement.ShowRelatedQuests), MethodType.Getter)] +static class SettlementShowRelatedQuestsPatch +{ + static void Postfix(ref bool __result) + { + if (Multiplayer.Client != null && Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction) + __result = false; + } +} + +[HarmonyPatch(typeof(WorldInspectPane), nameof(WorldInspectPane.PaneTopY), MethodType.Getter)] +static class WorldInspectPanePaneTopYPatch +{ + static void Postfix(ref float __result) + { + if (Multiplayer.Client != null && Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction) + __result += 35f; + } +} + +[HarmonyPatch(typeof(MainTabWindow_Quests), nameof(MainTabWindow_Quests.DoRewardsPrefsButton))] +public static class MainTabWindow_QuestsDoRewardsPrefsButtonPatch +{ + public static bool Prefix(MainTabWindow_Quests __instance, Rect rect, out Rect __result) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + { + __result = rect; + return true; + } + rect.yMin = rect.yMax - 40f; + + float buttonHeight = 40f; + + float rewardButtonWidth = rect.width * 0.5f - 5f; + float toggleButtonWidth = rect.width - rewardButtonWidth - 10f; + + Rect rewardRect = new Rect(rect.x, rect.yMax - buttonHeight, rewardButtonWidth, buttonHeight); + Text.Font = GameFont.Small; + if (Widgets.ButtonText(rewardRect, "ChooseRewards".Translate(), true, true, true)) + { + Find.WindowStack.Add(new Dialog_RewardPrefsConfig()); + } + + Rect toggleRect = new Rect(rewardRect.xMax + 10f, rewardRect.y, toggleButtonWidth, buttonHeight); + bool value = Multiplayer.settings.hideOtherPlayersQuests; + string label = value ? "MpHideQuestsOn".Translate() : "MpHideQuestsOff".Translate(); + if (Widgets.ButtonText(toggleRect, label, true, true)) + { + Multiplayer.settings.hideOtherPlayersQuests = !value; + SoundDefOf.Tick_High.PlayOneShotOnCamera(); + } + TooltipHandler.TipRegion(toggleRect, "MpHideQuestsDesc".Translate()); + + __result = rect; + return false; + } +} + +[HarmonyPatch(typeof(ColonistBar), nameof(ColonistBar.CheckRecacheEntries))] +public static class ColonistBarCheckRecacheEntriesPatch +{ + static IEnumerable Transpiler(IEnumerable insts) + { + var findMapsGetter = AccessTools.PropertyGetter(typeof(Find), nameof(Find.Maps)); + foreach (var inst in insts) + { + if (inst.Calls(findMapsGetter)) + { + yield return new CodeInstruction(OpCodes.Call, + AccessTools.Method(typeof(ColonistBarCheckRecacheEntriesPatch), nameof(GetFilteredMaps))); + } + else + { + yield return inst; + } + } + } + + public static IEnumerable GetFilteredMaps() + { + if (Multiplayer.Client == null || !Multiplayer.settings.hideOtherPlayersInColonistBar) + return Find.Maps; + + return Find.Maps.Where(map => + map.mapPawns.FreeColonistsSpawned.Any() || + map.Parent?.Faction == Faction.OfPlayer); + } +} + +[HarmonyPatch(typeof(WorldRoutePlanner), nameof(WorldRoutePlanner.DoRoutePlannerButton))] +public static class WorldRoutePlannerPatch +{ + static bool Prefix() + { + return Multiplayer.Client == null || Multiplayer.RealPlayerFaction != Multiplayer.WorldComp.spectatorFaction; + } +} + +[HarmonyPatch(typeof(WorldGizmoUtility), nameof(WorldGizmoUtility.TryGetCaravanGizmo))] +public static class WorldGizmoUtilityTryGetCaravanGizmoPatch +{ + static bool Prefix(ref Gizmo gizmo, ref bool __result) + { + if (Multiplayer.Client != null && Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction) + { + gizmo = null; + __result = false; + return false; + } + return true; + } +} + +[HarmonyPatch(typeof(MainButtonsRoot), nameof(MainButtonsRoot.DoButtons))] +static class MainButtonsRootDoButtonsPatch +{ + private static bool IsSpectator => + Multiplayer.Client != null && + Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction; + + private static readonly string[] SpectatorButtons = { "Menu", "World" }; + + private static readonly FieldRef> AllButtonsRef = AccessTools.FieldRefAccess>("allButtonsInOrder"); + + private static readonly Dictionary SpectatorButtonOrder = SpectatorButtons.Select((name, idx) => new { name, idx }).ToDictionary(x => x.name, x => x.idx); + + [HarmonyPrefix] + private static bool ReplaceOriginalDrawing(MainButtonsRoot __instance) + { + if (!IsSpectator) + return true; + + try + { + var allButtons = AllButtonsRef(__instance); + if (allButtons == null) + return true; + + var toDraw = SpectatorButtons + .Select(name => allButtons.Find(b => b.defName == name)) + .Where(b => b?.Worker.Visible ?? false) + .ToList(); + + DrawCornerButtons(toDraw); + return false; + } + catch (Exception ex) + { + Log.Error($"[MainButtonsRootPatch] ReplaceOriginalDrawing failed: {ex}"); + return true; + } + } + + [HarmonyPostfix] + private static void FilterButtons(MainButtonsRoot __instance) + { + try + { + var allButtons = AllButtonsRef(__instance); + if (allButtons == null) + return; + + if (IsSpectator) + { + // Keep only SpectatorButtons, sorted by our map + var filtered = allButtons + .Where(b => SpectatorButtonOrder.ContainsKey(b.defName)) + .OrderBy(b => SpectatorButtonOrder[b.defName]) + .ToList(); + + AllButtonsRef(__instance) = filtered; + } + else + { + // Restore original full list in normal mode + var original = DefDatabase.AllDefsListForReading + .Where(b => b.order >= 0) + .OrderBy(b => b.order) + .ToList(); + + AllButtonsRef(__instance) = original; + } + } + catch (Exception ex) + { + Log.Error($"[MainButtonsRootPatch] FilterButtons failed: {ex}"); + } + } + + private static void DrawCornerButtons(List buttons) + { + const float buttonHeight = 35f; + const float defaultPadding = 20f; + const float spacing = 0f; + + float x = UI.screenWidth; + float y = UI.screenHeight - buttonHeight; + + foreach (var btn in buttons) + { + float padding = btn.defName == "World" ? 120f : defaultPadding; + float width = btn.ShortenedLabelCapWidth + padding; + + x -= width; + btn.Worker.DoButton(new Rect(x, y, width, buttonHeight)); + x -= spacing; + } + } +} + + +[HarmonyPatch(typeof(Pawn_DraftController), nameof(Pawn_DraftController.GetGizmos))] +static class DisableDraftGizmo +{ + static IEnumerable Postfix(IEnumerable gizmos, Pawn_DraftController __instance) + { + return __instance.pawn.Faction == Faction.OfPlayer ? gizmos : Enumerable.Empty(); + } +} + +[HarmonyPatch(typeof(Pawn), nameof(Pawn.GetGizmos))] +static class PawnChangeRelationGizmo +{ + static IEnumerable Postfix(IEnumerable gizmos, Pawn __instance) + { + foreach (var gizmo in gizmos) + yield return gizmo; + + if (Multiplayer.Client == null || Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction) + yield break; + + if (__instance.Faction is { IsPlayer: true } && __instance.Faction != Faction.OfPlayer) + { + var otherFaction = __instance.Faction; + + yield return new Command_Action + { + defaultLabel = "Change faction relation", + icon = MultiplayerStatic.ChangeRelationIcon, + action = () => + { + List list = new List + { + new(FactionRelationKind.Hostile.ToString(), () => + { + SetRelation(otherFaction, FactionRelationKind.Hostile); + }), + new(FactionRelationKind.Neutral.ToString(), () => + { + SetRelation(otherFaction, FactionRelationKind.Neutral); + }) + }; + + Find.WindowStack.Add(new FloatMenu(list)); + } + }; + } + } + + [SyncMethod] + static void SetRelation(Faction other, FactionRelationKind kind) + { + Faction.OfPlayer.SetRelation(new FactionRelation(other, kind)); + + foreach (Map map in Find.Maps) + map.attackTargetsCache.Notify_FactionHostilityChanged(Faction.OfPlayer, other); + } +} + +[HarmonyPatch(typeof(SettlementDefeatUtility), nameof(SettlementDefeatUtility.CheckDefeated))] +static class CheckDefeatedPatch +{ + static bool Prefix(Settlement factionBase) + { + return factionBase.Faction is not { IsPlayer: true }; + } +} + +[HarmonyPatch(typeof(MapParent), nameof(MapParent.CheckRemoveMapNow))] +static class CheckRemoveMapNowPatch +{ + static bool Prefix(MapParent __instance) + { + if (Multiplayer.Client == null) return true; + if (__instance is Camp) return true; + + bool parentBelongsToNonPlayerFaction = __instance.Faction is not { IsPlayer: true }; + + return parentBelongsToNonPlayerFaction; + } +} + +[HarmonyPatch(typeof(Settlement), nameof(Settlement.Attackable), MethodType.Getter)] +static class SettlementAttackablePatch +{ + static bool Prefix(Settlement __instance) => __instance.Faction is not { IsPlayer: true }; +} + +[HarmonyPatch(typeof(Settlement), nameof(Settlement.Material), MethodType.Getter)] +static class SettlementNullFactionPatch1 +{ + static bool Prefix(Settlement __instance, ref Material __result) + { + if (__instance.factionInt == null) + { + __result = BaseContent.BadMat; + return false; + } + + return true; + } +} + +[HarmonyPatch(typeof(Settlement), nameof(Settlement.ExpandingIcon), MethodType.Getter)] +static class SettlementNullFactionPatch2 +{ + static bool Prefix(Settlement __instance, ref Texture2D __result) + { + if (__instance.factionInt == null) + { + __result = BaseContent.BadTex; + return false; + } + + return true; + } +} + +[HarmonyPatch(typeof(GoodwillSituationManager), nameof(GoodwillSituationManager.GetNaturalGoodwill))] +static class GetNaturalGoodwillPatch +{ + static bool Prefix(Faction other) + { + return other is not { IsPlayer: true }; + } +} + +[HarmonyPatch(typeof(GoodwillSituationManager), nameof(GoodwillSituationManager.GetMaxGoodwill))] +static class GetMaxGoodwillPatch +{ + static bool Prefix(Faction other) + { + return other is not { IsPlayer: true }; + } +} + +[HarmonyPatch(typeof(ScenPart_StartingAnimal), nameof(ScenPart_StartingAnimal.PetWeight))] +static class StartingAnimalPatch +{ + static IEnumerable Transpiler(IEnumerable insts) + { + var playerFactionField = AccessTools.Field(typeof(GameInitData), nameof(GameInitData.playerFaction)); + var factionOfPlayer = FactionOfPlayer; + + foreach (var inst in insts) + { + yield return inst; + + if (inst.operand as FieldInfo == playerFactionField) + yield return new CodeInstruction(OpCodes.Call, factionOfPlayer.Method); + } + } + + static Faction FactionOfPlayer(Faction f) + { + return Faction.OfPlayer; + } +} + +[HarmonyPatch(typeof(Pawn), nameof(Pawn.IsColonist), MethodType.Getter)] +static class PawnIsColonistPatch +{ + static IEnumerable Transpiler(IEnumerable insts) + { + var isPlayerMethodGetter = AccessTools.PropertyGetter(typeof(Faction), nameof(Faction.IsPlayer)); + var factionIsPlayer = FactionIsPlayer; + + foreach (var inst in insts) + { + if (inst.operand as MethodInfo == isPlayerMethodGetter) + inst.operand = factionIsPlayer.Method; + + yield return inst; + } + } + + static bool FactionIsPlayer(Faction f) + { + return f == Find.FactionManager.OfPlayer; + } +} + +[HarmonyPatch(typeof(Ideo), nameof(Ideo.RecacheColonistBelieverCount))] +static class RecacheColonistBelieverCountPatch +{ + private static MethodInfo allColonists = AccessTools.PropertyGetter(typeof(PawnsFinder), nameof(PawnsFinder.AllMapsCaravansAndTravellingTransporters_Alive_FreeColonists_NoCryptosleep)); + + static IEnumerable Transpiler(IEnumerable insts) + { + foreach (var inst in insts) + { + if (inst.operand as MethodInfo == allColonists) + inst.operand = AccessTools.Method(typeof(RecacheColonistBelieverCountPatch), nameof(ColonistsAllPlayerFactions)); + yield return inst; + } + } + + private static List colonistsAllFactions = new(); + + private static List ColonistsAllPlayerFactions() + { + colonistsAllFactions.Clear(); + + foreach (var p in PawnsFinder.AllMapsCaravansAndTravellingTransporters_Alive) + { + if (IsColonistAnyFaction(p) && p.HostFaction == null && !p.InCryptosleep) + colonistsAllFactions.Add(p); + } + + return colonistsAllFactions; + } + + // Note: IsColonist is patched above to compare to Faction.OfPlayer + public static bool IsColonistAnyFaction(Pawn p) + { + if (p.Faction is { IsPlayer: true } && p.RaceProps.Humanlike) + return !p.IsSlave || p.guest.SlaveIsSecure; + return false; + } + + public static bool IsColonyMechAnyFaction(Pawn p) + { + if (ModsConfig.BiotechActive && p.RaceProps.IsMechanoid && p.Faction is { IsPlayer: true } && p.MentalStateDef == null) + return p.HostFaction == null || p.IsSlave; + return false; + } + + public static bool IsColonySubhumanAnyFaction(Pawn p) + { + return p.IsSubhuman && p.Faction is { IsPlayer: true }; + } +} + +[HarmonyPatch(typeof(MapPawns), nameof(MapPawns.AnyPawnBlockingMapRemoval), MethodType.Getter)] +static class AnyPawnBlockingMapRemovalPatch +{ + private static MethodInfo isColonist = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsColonist)); + private static MethodInfo isColonyMech = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsColonyMech)); + + static IEnumerable Transpiler(IEnumerable insts) + { + foreach (var inst in insts) + { + if (inst.operand as MethodInfo == isColonyMech) + inst.operand = AccessTools.Method(typeof(RecacheColonistBelieverCountPatch), nameof(RecacheColonistBelieverCountPatch.IsColonyMechAnyFaction)); + + yield return inst; + } + } + + static void Postfix(MapPawns __instance, ref bool __result) + { + for (int i = 0; i < __instance.pawnsSpawned.Count; i++) + { + var p = __instance.pawnsSpawned[i]; + if (p.Faction is { IsPlayer: true } || p.HostFaction is { IsPlayer: true }) + { + Job curJob = p.CurJob; + if (curJob is { exitMapOnArrival: true }) + { + __result = true; + break; + } + + if (p.health.hediffSet.InLabor()) + { + __result = true; + break; + } + } + } + } +} + +[HarmonyPatch(typeof(MapPawns), nameof(MapPawns.IsValidColonyPawn))] +static class IsValidColonyPawnPatch +{ + private static MethodInfo isColonist = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsColonist)); + private static MethodInfo isColonySubhuman = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsColonySubhuman)); + + static IEnumerable Transpiler(IEnumerable insts) + { + foreach (var inst in insts) + { + if (inst.operand as MethodInfo == isColonist) + inst.operand = AccessTools.Method(typeof(RecacheColonistBelieverCountPatch), nameof(RecacheColonistBelieverCountPatch.IsColonistAnyFaction)); + + if (inst.operand as MethodInfo == isColonySubhuman) + inst.operand = AccessTools.Method(typeof(RecacheColonistBelieverCountPatch), nameof(RecacheColonistBelieverCountPatch.IsColonySubhumanAnyFaction)); + + yield return inst; + } + } +} + +[HarmonyPatch(typeof(Precept_Role), nameof(Precept_Role.ValidatePawn))] +static class ValidatePawnPatch +{ + private static MethodInfo isFreeNonSlaveColonist = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsFreeNonSlaveColonist)); + + static IEnumerable Transpiler(IEnumerable insts) + { + foreach (var inst in insts) + { + if (inst.operand as MethodInfo == isFreeNonSlaveColonist) + inst.operand = AccessTools.Method(typeof(ValidatePawnPatch), nameof(IsFreeNonSlaveColonistAnyFaction)); + + yield return inst; + } + } + + public static bool IsFreeNonSlaveColonistAnyFaction(Pawn p) + { + return p.Faction is { IsPlayer: true } && p.RaceProps.Humanlike && p.HostFaction == null && !p.IsSlave; + } +} + +[HarmonyPatch(typeof(Faction), nameof(Faction.HasGoodwill), MethodType.Getter)] +static class PlayerFactionsHaveGoodwill +{ + static void Postfix(Faction __instance, ref bool __result) + { + if (__instance.IsPlayer) + __result = true; + } +} + +[HarmonyPatch(typeof(GenHostility), nameof(GenHostility.IsActiveThreatToPlayer))] +static class IsActiveThreatToAnyPlayer +{ + static void Postfix(IAttackTarget target, ref bool __result) + { + foreach (var f in Find.FactionManager.AllFactions) + if (f.IsPlayer) + __result |= GenHostility.IsActiveThreatTo(target, f); + } +} + +[HarmonyPatch] +static class TakeToBedGuestFactionPatch +{ + static IEnumerable TargetMethods() + { + yield return AccessTools.DeclaredMethod(typeof(JobDriver_TakeToBed), nameof(JobDriver_TakeToBed.CheckMakeTakeeGuest)); + yield return AccessTools.DeclaredMethod(typeof(JobDriver_CarryDownedPawn), nameof(JobDriver_CarryDownedPawn.CheckMakeTakeeGuest)); + } + static bool Prefix(JobDriver __instance) + { + var takee = __instance.job.GetTarget(TargetIndex.A).Pawn; + return takee?.Faction?.IsPlayer != true && takee?.HostFaction?.IsPlayer != true; + } +} + + +[HarmonyPatch(typeof(LetterStack), nameof(LetterStack.ReceiveLetter), typeof(Letter), typeof(string), typeof(int), typeof(bool))] +static class LetterStackReceiveOnlyMyFaction +{ + // todo the letter might get culled from the archive if it isn't in the stack and Sync depends on the archive + static void Postfix(LetterStack __instance, Letter let) + { + if (Multiplayer.RealPlayerFaction != Faction.OfPlayer) + __instance.letters.Remove(let); + } +} + +[HarmonyPatch(typeof(LetterStack), nameof(LetterStack.ReceiveLetter), typeof(Letter), typeof(string), typeof(int), typeof(bool))] +static class LetterStackReceiveSoundOnlyMyFaction +{ + private static MethodInfo PlayOneShotOnCamera = + typeof(SoundStarter).GetMethod(nameof(SoundStarter.PlayOneShotOnCamera)); + + static IEnumerable Transpiler(IEnumerable insts) + { + foreach (var inst in insts) + { + if (inst.operand as MethodInfo == PlayOneShotOnCamera) + yield return new CodeInstruction( + OpCodes.Call, + SymbolExtensions.GetMethodInfo((SoundDef s, Map m) => PlaySoundReplacement(s, m))); + else + yield return inst; + } + } + + static void PlaySoundReplacement(SoundDef sound, Map map) + { + if (Multiplayer.RealPlayerFaction == Faction.OfPlayer) + sound.PlayOneShotOnCamera(map); + } +} + +[HarmonyPatch(typeof(Apparel), nameof(Apparel.WornGraphicPath), MethodType.Getter)] +static class ApparelWornGraphicPathGetterPatch +{ + private static FieldInfo thingIDNumberField = AccessTools.Field(typeof(Thing), nameof(Thing.thingIDNumber)); + + static IEnumerable Transpiler(IEnumerable insts) + { + foreach (var inst in insts) + { + yield return inst; + + // This instruction is part of wornGraphicPaths[thingIDNumber % wornGraphicPaths.Count] + // The function makes sure the id is positive + if (inst.operand as FieldInfo == thingIDNumberField) + yield return new CodeInstruction(OpCodes.Call, + AccessTools.Method(typeof(ApparelWornGraphicPathGetterPatch), nameof(MakeIdPositive))); + } + } + + private static int MakeIdPositive(int id) + { + return id < 0 ? -id : id; + } +} + +#region Map generation + +[HarmonyPatch(typeof(GenStep_Monolith), nameof(GenStep_Monolith.GenerateMonolith))] +public static class GenStepMonolithPatch +{ + static bool Prefix() + { + if (Multiplayer.Client == null) + return true; + + var anomalyComp = Current.Game.components.OfType().FirstOrDefault(); + + return anomalyComp?.monolith == null; + } +} + +[HarmonyPatch(typeof(ScenPart_ScatterThings), nameof(ScenPart_ScatterThings.GenerateIntoMap))] +static class ScenPartScatterThingsPatch +{ + static bool Prefix() + { + return Multiplayer.Client == null || FactionCreator.generatingMap; + } +} + +[HarmonyPatch(typeof(ScenPart_PlayerPawnsArriveMethod), nameof(ScenPart_PlayerPawnsArriveMethod.GenerateIntoMap))] +static class ScenPartPlayerPawnsArriveMethodPatch +{ + static bool Prefix() + { + return Multiplayer.Client == null || FactionCreator.generatingMap; + } +} + +#endregion + +[HarmonyPatch(typeof(CharacterCardUtility), nameof(CharacterCardUtility.DoTopStack))] +static class CharacterCardUtilityDontDrawIdeoPlate +{ + private static FieldInfo classicModeField = AccessTools.Field(typeof(IdeoManager), nameof(IdeoManager.classicMode)); + + static IEnumerable Transpiler(IEnumerable insts) + { + foreach (var inst in insts) + { + yield return inst; + + // Don't draw the ideo plate while choosing starting pawns in multifaction + if (inst.operand as FieldInfo == classicModeField) + { + yield return new CodeInstruction(OpCodes.Ldarg_2); + yield return new CodeInstruction(OpCodes.Call, + AccessTools.Method(typeof(CharacterCardUtilityDontDrawIdeoPlate), nameof(DontDrawIdeoPlate))); + yield return new CodeInstruction(OpCodes.Or); + } + } + } + + private static bool DontDrawIdeoPlate(bool generating) + { + return Multiplayer.Client != null && generating; + } +} + +[HarmonyPatch(typeof(CompShuttle), nameof(CompShuttle.ContainedColonistCount), MethodType.Getter)] +static class CompShuttle_ContainedColonistCount_Patch +{ + static IEnumerable Transpiler(IEnumerable insts) + { + var isFreeColonist = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsFreeColonist)); + var replacement = AccessTools.Method(typeof(CompShuttle_ContainedColonistCount_Patch), + nameof(IsFreeColonistAnyPlayerFaction)); + + foreach (var ci in insts) + { + if (ci.Calls(isFreeColonist)) + { + ci.opcode = OpCodes.Call; + ci.operand = replacement; + } + yield return ci; + } + } + public static bool IsFreeColonistAnyPlayerFaction(Pawn pawn) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return pawn.IsFreeColonist; + + return pawn.Faction != null && + pawn.Faction.IsPlayer && + pawn.RaceProps.Humanlike && + (!pawn.IsSlave || pawn.guest.SlaveIsSecure) && + !pawn.IsSubhuman && + pawn.HostFaction == null; + } + +} + +[HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.RemoveGravshipFromMap))] +static class RemoveGravshipFromMap_Patch +{ + static void Prefix(Building_GravEngine engine, out bool __state) + { + __state = false; + if (Multiplayer.Client == null) return; + if (!Multiplayer.GameComp.multifaction) return; + if (engine == null) return; + + engine.Map.PushFaction(engine.Faction); + + __state = true; + } + + static void Finalizer(Building_GravEngine engine, bool __state) + { + if (!__state) return; + engine.Map.PopFaction(); + } +} + +[HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.PlaceGravship))] +static class PlaceGravship_FactionContext_Patch +{ + static void Prefix(Gravship gravship, Map map, out bool __state) + { + __state = false; + if (Multiplayer.Client == null) return; + if (!Multiplayer.GameComp.multifaction) return; + + map.PushFaction(gravship.Faction); + + __state = true; + } + + static void Finalizer(Gravship gravship, Map map, bool __state) + { + if (!__state) return; + map.PopFaction(); + } +} + +[HarmonyPatch(typeof(QuestPart_LendColonistsToFaction), nameof(QuestPart_LendColonistsToFaction.Enable))] +static class QuestPart_LendColonistsToFaction_Enable_Patch +{ + static IEnumerable Transpiler(IEnumerable insts) + { + var isFreeColonist = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsFreeColonist)); + var replacement = AccessTools.Method(typeof(CompShuttle_ContainedColonistCount_Patch), + nameof(CompShuttle_ContainedColonistCount_Patch.IsFreeColonistAnyPlayerFaction)); + foreach (var ci in insts) + { + if (ci.Calls(isFreeColonist)) + { + ci.opcode = OpCodes.Call; + ci.operand = replacement; + } + yield return ci; + } + } +} + +[HarmonyPatch(typeof(Map), nameof(Map.IsPlayerHome), MethodType.Getter)] +static class Map_IsPlayerHome_Spectator_Patch +{ + static bool Prefix(Map __instance, ref bool __result) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction || + Faction.OfPlayer != Multiplayer.WorldComp.spectatorFaction) + return true; + + { + // Is repeat through all player faction a better idea? + if (!__instance.wasSpawnedViaGravShipLanding) + { + MapInfo mapInfo = __instance.info; + if (((mapInfo != null) ? mapInfo.parent : null) == null || __instance.info.parent.Faction?.IsPlayer != true || !__instance.info.parent.def.canBePlayerHome) + { + __result = GravshipUtility.PlayerHasGravEngine(__instance); + return false; + } + } + __result = true; + return false; + } + } +} + +[HarmonyPatch] +static class Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch +{ + static MethodInfo TargetMethod() + { + return AccessTools.EnumeratorMoveNext(AccessTools.Method(typeof(Pawn_TraderTracker), nameof(Pawn_TraderTracker.ColonyThingsWillingToBuy))); + } + public static List AllBuildingsColonistOfDefOfPlayer(this ListerBuildings lister, ThingDef def) + { + if (def.CanHaveFaction) + return lister.AllBuildingsColonistOfDef(def).FindAll(building => building.Faction == Faction.OfPlayer); + else + return lister.AllBuildingsColonistOfDef(def); + } + public static IEnumerable AllColonistBuildingsOfTypeOfPlayer(this ListerBuildings lister) + { + return lister.AllColonistBuildingsOfType().Where(t => + { + if (t is Building building && (!building.def.CanHaveFaction || building.Faction == Faction.OfPlayer)) + return true; + return false; + }); + } + static IEnumerable Transpiler(IEnumerable insts) + { + var allBuildingsColonistOfDef = AccessTools.Method(typeof(ListerBuildings), nameof(ListerBuildings.AllBuildingsColonistOfDef)); + var allBuildingsColonistOfDefOfPlayer = AccessTools.Method(typeof(Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch), nameof(Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch.AllBuildingsColonistOfDefOfPlayer)); + var allColonistBuildingsOfType = AccessTools.Method(typeof(ListerBuildings), nameof(ListerBuildings.AllColonistBuildingsOfType)).MakeGenericMethod([typeof(IHaulSource)]); + var allColonistBuildingsOfTypeOfPlayer = AccessTools.Method(typeof(Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch), nameof(Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch.AllColonistBuildingsOfTypeOfPlayer)).MakeGenericMethod([typeof(IHaulSource)]); + foreach (var ci in insts) + { + if (ci.Calls(allBuildingsColonistOfDef)) + { + ci.opcode = OpCodes.Call; + ci.operand = allBuildingsColonistOfDefOfPlayer; + } + else if (ci.Calls(allColonistBuildingsOfType)) + { + ci.opcode = OpCodes.Call; + ci.operand = allColonistBuildingsOfTypeOfPlayer; + } + yield return ci; + } + } +} + +[HarmonyPatch] +static class TradeUtility_AllLaunchableThingsForTrade_Patch +{ + static MethodInfo TargetMethod() + { + return AccessTools.EnumeratorMoveNext(AccessTools.Method(typeof(TradeUtility), nameof(TradeUtility.AllLaunchableThingsForTrade))); + } + + public static IEnumerable AllPoweredOfPlayer(Map map) + { + return Building_OrbitalTradeBeacon.AllPowered(map).Where(beacon => beacon.Faction == Faction.OfPlayer); + } + static IEnumerable Transpiler(IEnumerable insts) + { + var allPowered = AccessTools.Method(typeof(Building_OrbitalTradeBeacon), nameof(Building_OrbitalTradeBeacon.AllPowered)); + var allPoweredOfPlayer = AccessTools.Method(typeof(TradeUtility_AllLaunchableThingsForTrade_Patch), nameof(TradeUtility_AllLaunchableThingsForTrade_Patch.AllPoweredOfPlayer)); + foreach (var ci in insts) + { + if (ci.Calls(allPowered)) + { + ci.opcode = OpCodes.Call; + ci.operand = allPoweredOfPlayer; + } + + yield return ci; + } + } +} diff --git a/Source/Client/Multiplayer.cs b/Source/Client/Multiplayer.cs index 5ea4b1760..d770b9c36 100644 --- a/Source/Client/Multiplayer.cs +++ b/Source/Client/Multiplayer.cs @@ -1,257 +1,258 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Threading; -using HarmonyLib; -using Multiplayer.Client.AsyncTime; -using Multiplayer.Client.Comp; -using Multiplayer.Client.Desyncs; -using Multiplayer.Client.Patches; -using Multiplayer.Client.Util; -using Multiplayer.Common; -using RimWorld; -using UnityEngine; -using Verse; -#if EDIT_COMPILE_RELOAD -using HotSwap; -#endif - -namespace Multiplayer.Client -{ - public static class Multiplayer - { - public const string WebsiteLink = "https://rimworldmultiplayer.com"; - // There is also a link in About.xml. Remember to update both - public const string DiscordLink = "https://discord.gg/S4bxXpv"; - - public static Harmony harmony = new("multiplayer"); - public static MpSettings settings; - - public static MultiplayerGame game; - public static MultiplayerSession session; - - public static Common.SyncSerialization serialization; - - public static MultiplayerServer LocalServer { get; set; } - public static Thread localServerThread; - - public static ConnectionBase Client => session?.client; - public static PacketLogWindow WriterLog => session?.writerLog; - public static PacketLogWindow ReaderLog => session?.readerLog; - public static bool IsReplay => session?.replay ?? false; - - public static string username; - - public static bool reloading; - - public static MultiplayerGameComp GameComp => game.gameComp; - public static MultiplayerWorldComp WorldComp => game.worldComp; - public static AsyncWorldTimeComp AsyncWorldTime => game.asyncWorldTimeComp; - public static ThingsById ThingsById => game.thingsById; - - public static bool ShowDevInfo => Prefs.DevMode && settings.showDevInfo; - public static bool GhostMode => session is { ghostModeCheckbox: true }; - - public static Faction RealPlayerFaction => Client != null ? game.RealPlayerFaction : Faction.OfPlayer; - - public static bool ExecutingCmds => TickPatch.currentExecutingCmdType != null; - public static bool ExecutingCmdDebugTool => TickPatch.currentExecutingCmdType == CommandType.DebugTools; - public static bool Ticking => AsyncWorldTimeComp.tickingWorld || AsyncTimeComp.tickingMap != null || ConstantTicker.ticking; - public static Map MapContext => AsyncTimeComp.tickingMap ?? AsyncTimeComp.executingCmdMap; - - public static bool dontSync; - public static bool ShouldSync => InInterface && !dontSync; - public static bool InInterface => - Client != null - && !Ticking - && !ExecutingCmds - && !reloading - && Current.ProgramState == ProgramState.Playing - && LongEventHandler.currentEvent == null; - - public static string ReplaysDir => GenFilePaths.FolderUnderSaveData("MpReplays"); - public static string DesyncsDir => GenFilePaths.FolderUnderSaveData("MpDesyncs"); - public static string LogsDir => GenFilePaths.FolderUnderSaveData("MpLogs"); - public static string CacheDir => GenFilePaths.FolderUnderSaveData("MpCache"); - - public static Stopwatch clock = Stopwatch.StartNew(); - - public static bool arbiterInstance; - public static bool loadingErrors; - public static Stopwatch harmonyWatch = new(); - - public static ModContentPack modContentPack; - - public static void InitMultiplayer(ModContentPack content) - { -#if EDIT_COMPILE_RELOAD - EcrLog.messageCallback = Log.Message; - EcrLog.errorCallback = Log.Error; - // EditCompileReload.RegisterAssemblyWatcher("Mods/Multiplayer/Source/Client/bin", "MultiplayerCommon.dll_orig"); - EditCompileReload.RegisterAssemblyWatcher("Mods/Multiplayer/Source/Client/bin", "Multiplayer.dll_orig"); -#endif - - modContentPack = content; - Native.EarlyInit( - Application.platform switch - { - RuntimePlatform.LinuxEditor => Native.NativeOS.Linux, - RuntimePlatform.LinuxPlayer => Native.NativeOS.Linux, - RuntimePlatform.OSXEditor => Native.NativeOS.OSX, - RuntimePlatform.OSXPlayer => Native.NativeOS.OSX, - _ => Native.NativeOS.Windows - }); - - DisableOmitFramePointer(); - JittedMethods.Init(); - - MultiplayerLoader.Multiplayer.settingsWindowDrawer = - rect => MpSettingsUI.DoSettingsWindowContents(settings, rect); - - using (DeepProfilerWrapper.Section("Multiplayer CacheTypeHierarchy")) - TypeCache.CacheTypeHierarchy(); - - using (DeepProfilerWrapper.Section("Multiplayer CacheTypeByName")) - TypeCache.CacheTypeByName(); - - if (GenCommandLine.CommandLineArgPassed("profiler")) - { - SimpleProfiler.CheckAvailable(); - Log.Message($"Profiler: {SimpleProfiler.available}"); - SimpleProfiler.Init("prof"); - } - - if (GenCommandLine.CommandLineArgPassed("arbiter")) - { - ArbiterWindowFix.Run(); - arbiterInstance = true; - } - - ScribeLike.provider = new ScribeProvider(); - settings = MultiplayerLoader.Multiplayer.instance!.GetSettings(); - - EarlyInit.ProcessEnvironment(); - EarlyInit.EarlyPatches(harmony); - MpReflection.allAssembliesHook = RwAllAssemblies; - EarlyInit.InitSync(); - CheckInterfaceVersions(); - - LongEventHandler.ExecuteWhenFinished(() => { - // Double Execute ensures it'll run last. - LongEventHandler.ExecuteWhenFinished(EarlyInit.LatePatches); - }); - - MultiplayerData.modCtorRoundMode = RoundMode.GetCurrentRoundMode(); - VersionChecker.Init(); - } - - [MethodImpl(MethodImplOptions.NoInlining)] +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading; +using HarmonyLib; +using Multiplayer.Client.AsyncTime; +using Multiplayer.Client.Comp; +using Multiplayer.Client.Desyncs; +using Multiplayer.Client.Patches; +using Multiplayer.Client.Util; +using Multiplayer.Common; +using RimWorld; +using UnityEngine; +using Verse; +#if EDIT_COMPILE_RELOAD +using HotSwap; +#endif + +namespace Multiplayer.Client +{ + public static class Multiplayer + { + public const string WebsiteLink = "https://rimworldmultiplayer.com"; + // There is also a link in About.xml. Remember to update both + public const string DiscordLink = "https://discord.gg/S4bxXpv"; + + public static Harmony harmony = new("multiplayer"); + public static MpSettings settings; + + public static MultiplayerGame game; + public static MultiplayerSession session; + + public static Common.SyncSerialization serialization; + + public static MultiplayerServer LocalServer { get; set; } + public static Thread localServerThread; + + public static ConnectionBase Client => session?.client; + public static PacketLogWindow WriterLog => session?.writerLog; + public static PacketLogWindow ReaderLog => session?.readerLog; + public static bool IsReplay => session?.replay ?? false; + + public static string username; + + public static bool reloading; + + public static MultiplayerGameComp GameComp => game.gameComp; + public static MultiplayerWorldComp WorldComp => game.worldComp; + public static AsyncWorldTimeComp AsyncWorldTime => game.asyncWorldTimeComp; + public static ThingsById ThingsById => game.thingsById; + + public static bool ShowDevInfo => Prefs.DevMode && settings.showDevInfo; + public static bool GhostMode => session is { ghostModeCheckbox: true }; + + public static Faction RealPlayerFaction => Client != null ? game.RealPlayerFaction : Faction.OfPlayer; + + public static bool ExecutingCmds => TickPatch.currentExecutingCmdType != null; + public static bool ExecutingCmdDebugTool => TickPatch.currentExecutingCmdType == CommandType.DebugTools; + public static bool Ticking => AsyncWorldTimeComp.tickingWorld || AsyncTimeComp.tickingMap != null || ConstantTicker.ticking; + public static Map MapContext => AsyncTimeComp.tickingMap ?? AsyncTimeComp.executingCmdMap; + + public static bool dontSync; + public static bool ShouldSync => InInterface && !dontSync; + public static bool InInterface => + Client != null + && !Ticking + && !ExecutingCmds + && !reloading + && Current.ProgramState == ProgramState.Playing + && LongEventHandler.currentEvent == null; + + public static string ReplaysDir => GenFilePaths.FolderUnderSaveData("MpReplays"); + public static string DesyncsDir => GenFilePaths.FolderUnderSaveData("MpDesyncs"); + public static string LogsDir => GenFilePaths.FolderUnderSaveData("MpLogs"); + public static string CacheDir => GenFilePaths.FolderUnderSaveData("MpCache"); + + public static Stopwatch clock = Stopwatch.StartNew(); + + public static bool arbiterInstance; + public static bool loadingErrors; + public static Stopwatch harmonyWatch = new(); + + public static ModContentPack modContentPack; + + public static void InitMultiplayer(ModContentPack content) + { +#if EDIT_COMPILE_RELOAD + EcrLog.messageCallback = Log.Message; + EcrLog.errorCallback = Log.Error; + // EditCompileReload.RegisterAssemblyWatcher("Mods/Multiplayer/Source/Client/bin", "MultiplayerCommon.dll_orig"); + EditCompileReload.RegisterAssemblyWatcher("Mods/Multiplayer/Source/Client/bin", "Multiplayer.dll_orig"); +#endif + + modContentPack = content; + Native.EarlyInit( + Application.platform switch + { + RuntimePlatform.LinuxEditor => Native.NativeOS.Linux, + RuntimePlatform.LinuxPlayer => Native.NativeOS.Linux, + RuntimePlatform.OSXEditor => Native.NativeOS.OSX, + RuntimePlatform.OSXPlayer => Native.NativeOS.OSX, + _ => Native.NativeOS.Windows + }); + + DisableOmitFramePointer(); + JittedMethods.Init(); + + MultiplayerLoader.Multiplayer.settingsWindowDrawer = + rect => MpSettingsUI.DoSettingsWindowContents(settings, rect); + + using (DeepProfilerWrapper.Section("Multiplayer CacheTypeHierarchy")) + TypeCache.CacheTypeHierarchy(); + + using (DeepProfilerWrapper.Section("Multiplayer CacheTypeByName")) + TypeCache.CacheTypeByName(); + + if (GenCommandLine.CommandLineArgPassed("profiler")) + { + SimpleProfiler.CheckAvailable(); + Log.Message($"Profiler: {SimpleProfiler.available}"); + SimpleProfiler.Init("prof"); + } + + if (GenCommandLine.CommandLineArgPassed("arbiter")) + { + ArbiterWindowFix.Run(); + arbiterInstance = true; + } + + ScribeLike.provider = new ScribeProvider(); + settings = MultiplayerLoader.Multiplayer.instance!.GetSettings(); + + EarlyInit.ProcessEnvironment(); + EarlyInit.EarlyPatches(harmony); + MpReflection.allAssembliesHook = RwAllAssemblies; + EarlyInit.InitSync(); + CheckInterfaceVersions(); + + LongEventHandler.ExecuteWhenFinished(() => { + // Double Execute ensures it'll run last. + LongEventHandler.ExecuteWhenFinished(EarlyInit.LatePatches); + }); + + MultiplayerData.modCtorRoundMode = RoundMode.GetCurrentRoundMode(); + VersionChecker.Init(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] private static void DisableOmitFramePointer() { + if (!Native.IsMonoRuntime) return; Native.mini_parse_debug_option("disable_omit_fp"); - } - - private static void CheckInterfaceVersions() - { - var mpAssembly = AppDomain.CurrentDomain.GetAssemblies().First(a => a.GetName().Name == "Multiplayer"); - var curVersion = new Version( - (mpAssembly.GetCustomAttributes(typeof(AssemblyFileVersionAttribute), false)[0] as AssemblyFileVersionAttribute).Version - ); - - Log.Message($"Current MultiplayerAPI version: {curVersion}"); - - foreach (var mod in LoadedModManager.RunningMods) { - if (mod.assemblies.loadedAssemblies.NullOrEmpty()) - continue; - - if (mod == modContentPack) - continue; - - // Test if mod is using multiplayer api - if (!mod.assemblies.loadedAssemblies.Any(a => a.GetName().Name == MpVersion.ApiAssemblyName)) { - continue; - } - - // Retrieve the original dll - var info = MultiplayerData.GetModAssemblies(mod) - .Select(f => FileVersionInfo.GetVersionInfo(f.FullName)) - .FirstOrDefault(v => v.ProductName == "Multiplayer"); - - if (info == null) { - // There are certain mods that don't include the API, namely compat - // Can we test them? - continue; - } - - var version = new Version(info.FileVersion); - - Log.Message($"Mod {mod.Name} has MultiplayerAPI client ({version})"); - - if (curVersion > version) - Log.Warning($"Mod {mod.Name} uses an older API version (mod: {version}, current: {curVersion})"); - else if (curVersion < version) - Log.Error($"Mod {mod.Name} uses a newer API version! (mod: {version}, current: {curVersion})\nMake sure the Multiplayer mod is up to date"); - } - } - - public static void StopMultiplayerAndClearAllWindows() - { - StopMultiplayer(); - MpUI.ClearWindowStack(); - } - - public static void StopMultiplayer() - { - Log.Message($"Stopping multiplayer session from {new StackTrace().GetFrame(1).GetMethod().FullDescription()}"); - - OnMainThread.ClearScheduled(); - LongEventHandler.ClearQueuedEvents(); - - if (session != null) - { - session.Stop(); - session = null; - - Prefs.Apply(); - } - - if (LocalServer != null) - { - LocalServer.running = false; - localServerThread?.Join(); - LocalServer.TryStop(); - LocalServer = null; - } - - game?.OnDestroy(); - game = null; - - TickPatch.Reset(); - VTRSync.Reset(); - - Find.WindowStack?.WindowOfType()?.Cleanup(true); - SyncFieldUtil.ClearAllBufferedChanges(); - - if (arbiterInstance) - { - arbiterInstance = false; - Application.Quit(); - } - } - - private static IEnumerable RwAllAssemblies() - { - yield return Assembly.GetAssembly(typeof(Game)); - - foreach (ModContentPack mod in LoadedModManager.RunningMods) - foreach (Assembly assembly in mod.assemblies.loadedAssemblies) - yield return assembly; - - if (Assembly.GetEntryAssembly() != null) - yield return Assembly.GetEntryAssembly(); - } - } -} + } + + private static void CheckInterfaceVersions() + { + var mpAssembly = AppDomain.CurrentDomain.GetAssemblies().First(a => a.GetName().Name == "Multiplayer"); + var curVersion = new Version( + (mpAssembly.GetCustomAttributes(typeof(AssemblyFileVersionAttribute), false)[0] as AssemblyFileVersionAttribute).Version + ); + + Log.Message($"Current MultiplayerAPI version: {curVersion}"); + + foreach (var mod in LoadedModManager.RunningMods) { + if (mod.assemblies.loadedAssemblies.NullOrEmpty()) + continue; + + if (mod == modContentPack) + continue; + + // Test if mod is using multiplayer api + if (!mod.assemblies.loadedAssemblies.Any(a => a.GetName().Name == MpVersion.ApiAssemblyName)) { + continue; + } + + // Retrieve the original dll + var info = MultiplayerData.GetModAssemblies(mod) + .Select(f => FileVersionInfo.GetVersionInfo(f.FullName)) + .FirstOrDefault(v => v.ProductName == "Multiplayer"); + + if (info == null) { + // There are certain mods that don't include the API, namely compat + // Can we test them? + continue; + } + + var version = new Version(info.FileVersion); + + Log.Message($"Mod {mod.Name} has MultiplayerAPI client ({version})"); + + if (curVersion > version) + Log.Warning($"Mod {mod.Name} uses an older API version (mod: {version}, current: {curVersion})"); + else if (curVersion < version) + Log.Error($"Mod {mod.Name} uses a newer API version! (mod: {version}, current: {curVersion})\nMake sure the Multiplayer mod is up to date"); + } + } + + public static void StopMultiplayerAndClearAllWindows() + { + StopMultiplayer(); + MpUI.ClearWindowStack(); + } + + public static void StopMultiplayer() + { + Log.Message($"Stopping multiplayer session from {new StackTrace().GetFrame(1).GetMethod().FullDescription()}"); + + OnMainThread.ClearScheduled(); + LongEventHandler.ClearQueuedEvents(); + + if (session != null) + { + session.Stop(); + session = null; + + Prefs.Apply(); + } + + if (LocalServer != null) + { + LocalServer.running = false; + localServerThread?.Join(); + LocalServer.TryStop(); + LocalServer = null; + } + + game?.OnDestroy(); + game = null; + + TickPatch.Reset(); + VTRSync.Reset(); + + Find.WindowStack?.WindowOfType()?.Cleanup(true); + SyncFieldUtil.ClearAllBufferedChanges(); + + if (arbiterInstance) + { + arbiterInstance = false; + Application.Quit(); + } + } + + private static IEnumerable RwAllAssemblies() + { + yield return Assembly.GetAssembly(typeof(Game)); + + foreach (ModContentPack mod in LoadedModManager.RunningMods) + foreach (Assembly assembly in mod.assemblies.loadedAssemblies) + yield return assembly; + + if (Assembly.GetEntryAssembly() != null) + yield return Assembly.GetEntryAssembly(); + } + } +} diff --git a/Source/Client/Multiplayer.csproj b/Source/Client/Multiplayer.csproj index 26cdeb7b1..b021d98dd 100644 --- a/Source/Client/Multiplayer.csproj +++ b/Source/Client/Multiplayer.csproj @@ -1,84 +1,85 @@ - - - - net48 - true - 12 - false - bin - false - false - 0.6.2 - false - Multiplayer.Client - Multiplayer - - - - False - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ..\..\ - - - - $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)'))=$(AssemblyName)/ - - - - - - - - - - - - - - - - - - + + + + net48 + net10.0 + true + 12 + false + bin + false + false + 0.6.2 + false + Multiplayer.Client + Multiplayer + + + + False + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ..\..\ + + + + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)'))=$(AssemblyName)/ + + + + + + + + + + + + + + + + + + diff --git a/Source/Client/Patches/GravshipTravelSessionPatches.cs b/Source/Client/Patches/GravshipTravelSessionPatches.cs index 4939f821c..8b5e84b4f 100644 --- a/Source/Client/Patches/GravshipTravelSessionPatches.cs +++ b/Source/Client/Patches/GravshipTravelSessionPatches.cs @@ -1,258 +1,258 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using HarmonyLib; -using Multiplayer.API; -using Multiplayer.Client.Persistent; -using Multiplayer.Client.Util; -using RimWorld; -using RimWorld.Planet; -using Verse; -using Verse.Sound; - -namespace Multiplayer.Client.Patches -{ - #region Input - - [HarmonyPatch(typeof(GravshipUtility), nameof(GravshipUtility.PreLaunchConfirmation))] - public static class PatchGravshipPreLaunchConfirmation - { - static void Prefix(Building_GravEngine engine, ref Action launchAction) - { - if (Multiplayer.Client == null) return; - - GravshipTravelUtils.OpenSessionAt(engine.Map.Tile); - } - } - - [HarmonyPatch] - public static class PatchGravshipPreLaunchCancel - { - static MethodBase TargetMethod() - { - return MpMethodUtil.GetLambda(typeof(GravshipUtility), nameof(GravshipUtility.PreLaunchConfirmation), lambdaOrdinal: 4); - } - - static void Postfix(Dialog_MessageBox __instance) - { - if (Multiplayer.Client == null) return; - if (!Multiplayer.ExecutingCmds) return; - - GravshipTravelUtils.CloseSessionAt(Find.CurrentMap.Tile); - GravshipTravelUtils.CloseGravshipPrelaunchDialog(); - } - } - - [HarmonyPatch(typeof(CompPilotConsole), nameof(CompPilotConsole.StartChoosingDestination_NewTemp))] - public static class PatchPilotConsoleStartChoosingDestination - { - static void Postfix(CompPilotConsole __instance, bool launching) - { - if (Multiplayer.Client == null) return; - if (!launching) return; - - GravshipTravelUtils.CloseGravshipPrelaunchDialog(); - GravshipTravelUtils.OpenSessionAt(__instance.engine.Map.Tile); - } - } - - [HarmonyPatch] - static class PatchTilePickerCancelLambda - { - static MethodBase TargetMethod() - { - return MpMethodUtil.GetLambda(typeof(CompPilotConsole), nameof(CompPilotConsole.StartChoosingDestination_NewTemp), lambdaOrdinal: 4); - } - - static void Prefix(bool ___launching, ref bool __state) - { - if (Multiplayer.Client == null || ___launching || Multiplayer.dontSync) - return; - - Multiplayer.dontSync = true; - __state = true; - } - - // TODO: Something in Feedback.cs seems to block the wantedMode switch. - // For now, it's set manually - consider keeping it this way permanently. - static void Finalizer(bool ___launching, PlanetTile ___curTile, bool __state) - { - if (Multiplayer.Client == null) return; - if (__state) Multiplayer.dontSync = false; - if (!Multiplayer.ExecutingCmds || !___launching) return; - - Find.World.renderer.wantedMode = WorldRenderMode.None; - Find.TilePicker.StopTargetingInt(); - GravshipTravelUtils.CloseSessionAt(___curTile); - } - } - - [HarmonyPatch] - public static class PatchGravshipMapArriveMethods - { - static IEnumerable TargetMethods() - { - yield return AccessTools.Method(typeof(GravshipUtility), nameof(GravshipUtility.ArriveExistingMap)); - yield return AccessTools.Method(typeof(GravshipUtility), nameof(GravshipUtility.ArriveNewMap)); - } - static void Postfix(Gravship gravship) - { - if (Multiplayer.Client == null) return; - - GravshipTravelUtils.OpenSessionAt(gravship.destinationTile); - } - } - - [HarmonyPatch(typeof(Designator_MoveGravship), nameof(Designator_MoveGravship.DesignateSingleCell))] - public static class PatchGravshipDesignatorDeselectForAllClients - { - static void Prefix() => CancelDesignatorDeselection.EnableCanceling(); - - static void Finalizer() => CancelDesignatorDeselection.DisableCanceling(); - } - - // TODO: Is there a better way to synchronize this method? - [HarmonyPatch(typeof(GravshipLandingMarker), nameof(GravshipLandingMarker.BeginLanding))] - public static class PatchBeginLandingToSyncWithClients - { - static bool Prefix(GravshipLandingMarker __instance) - { - if (Multiplayer.Client == null) return true; - if (Multiplayer.ExecutingCmds) return true; - - SyncBeginLanding(__instance); - - return false; - } - - [SyncMethod] - public static void SyncBeginLanding(GravshipLandingMarker landingMarker) - { - var gravshipController = Find.GravshipController; - - if (landingMarker == null || landingMarker.Tile == null) - { - MpLog.Error($"[MP] SyncConfirmGravshipLanding: Marker [{landingMarker != null}] Tile [{landingMarker?.Tile != null}]."); - return; - } - - gravshipController.landingMarker = landingMarker; - landingMarker.BeginLanding(gravshipController); - gravshipController.landingMarker = null; - - if (!TickPatch.currentExecutingCmdIssuedBySelf) - SoundDefOf.Gravship_Land.PlayOneShotOnCamera(); - } - } - - [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.AbortLanding))] - public static class PatchAbortLandingToCloseSession - { - static void Prefix(WorldComponent_GravshipController __instance, ref bool __state) - { - if (Multiplayer.Client == null) return; - if (!Multiplayer.ExecutingCmds) return; - - GravshipTravelUtils.CloseSessionAt(__instance.landingTile); - } - } - - [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.PlaceGravship))] - public static class PatchPlaceGravshipToUpdateSystemsAfterTheySpawn - { - static void Postfix(Map map) - { - if (Multiplayer.Client == null) return; - - map.glowGrid.GlowGridUpdate_First(); - } - } - - #endregion - - #region Landing/Takeoff freeze - - [HarmonyPatch] - public static class PatchGravshipCutsceneToFreeze - { - static IEnumerable TargetMethods() - { - yield return AccessTools.Method(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.InitiateTakeoff)); - yield return AccessTools.Method(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.InitiateLanding)); - } - +using System; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using Multiplayer.API; +using Multiplayer.Client.Persistent; +using Multiplayer.Client.Util; +using RimWorld; +using RimWorld.Planet; +using Verse; +using Verse.Sound; + +namespace Multiplayer.Client.Patches +{ + #region Input + + [HarmonyPatch(typeof(GravshipUtility), nameof(GravshipUtility.PreLaunchConfirmation))] + public static class PatchGravshipPreLaunchConfirmation + { + static void Prefix(Building_GravEngine engine, ref Action launchAction) + { + if (Multiplayer.Client == null) return; + + GravshipTravelUtils.OpenSessionAt(engine.Map.Tile); + } + } + + [HarmonyPatch] + public static class PatchGravshipPreLaunchCancel + { + static MethodBase TargetMethod() + { + return MpMethodUtil.GetLambda(typeof(GravshipUtility), nameof(GravshipUtility.PreLaunchConfirmation), lambdaOrdinal: 4); + } + static void Postfix() - { - if (Multiplayer.Client == null) return; - - GravshipTravelUtils.StartFreeze(); - } - } - - [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.TakeoffEnded))] - public static class PatchGravshipTakeoffEnded - { - static void Prefix(WorldComponent_GravshipController __instance) - { - if (Multiplayer.Client == null) return; - - GravshipTravelUtils.StopFreeze(); - GravshipTravelUtils.CloseSessionAt(__instance.takeoffTile); - } - } - - // TODO: Is the random pushing here necessary? This might be related to issue #638. - [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.LandingEnded))] - public static class PatchGravshipLandingEnded - { - static void Prefix(WorldComponent_GravshipController __instance) - { - if (Multiplayer.Client == null) return; - - Rand.PushState(); - Rand.StateCompressed = __instance.map.AsyncTime().randState; - - GravshipTravelUtils.StopFreeze(); - GravshipTravelUtils.CloseSessionAt(__instance.gravship.destinationTile); - - // LandingEnded calls Find.TickManager.CurTimeSpeed = TimeSpeed.Normal, but that doesn't work in MP as we - // have our own way of changing the map speed. - __instance.map.AsyncTime().DesiredTimeSpeed = TimeSpeed.Normal; - } - - static void Finalizer() - { - if (Multiplayer.Client == null) return; - Rand.PopState(); - } - } - #endregion - - // TODO: Check what this actually does and whether it’s still necessary - // Stop the landing co message from showing every game tick - [HarmonyPatch(typeof(TickManager), nameof(TickManager.PlayerCanControl), MethodType.Getter)] - public static class PatchTickmanagerPlayerCanControlGetter - { - private static bool shownLandingMessage = false; - - static bool Prefix(ref AcceptanceReport __result) - { - if (Multiplayer.Client == null) return true; - - WorldComponent_GravshipController gravshipController = Find.GravshipController; - if (gravshipController != null && gravshipController.LandingAreaConfirmationInProgress) - { - __result = shownLandingMessage ? false : "MessageConfirmLandingAreaFirst".Translate(); - shownLandingMessage = true; - return false; - } - - // Not in a landing session, use vanilla logic for player control - __result = Current.Game.PlayerHasControl; - return false; - } - - // Call this when the landing session ends (e.g., in your session cleanup) - public static void ResetLandingMessageFlag() => shownLandingMessage = false; - } -} + { + if (Multiplayer.Client == null) return; + if (!Multiplayer.ExecutingCmds) return; + + GravshipTravelUtils.CloseSessionAt(Find.CurrentMap.Tile); + GravshipTravelUtils.CloseGravshipPrelaunchDialog(); + } + } + + [HarmonyPatch(typeof(CompPilotConsole), nameof(CompPilotConsole.StartChoosingDestination_NewTemp))] + public static class PatchPilotConsoleStartChoosingDestination + { + static void Postfix(CompPilotConsole __instance, bool launching) + { + if (Multiplayer.Client == null) return; + if (!launching) return; + + GravshipTravelUtils.CloseGravshipPrelaunchDialog(); + GravshipTravelUtils.OpenSessionAt(__instance.engine.Map.Tile); + } + } + + [HarmonyPatch] + static class PatchTilePickerCancelLambda + { + static MethodBase TargetMethod() + { + return MpMethodUtil.GetLambda(typeof(CompPilotConsole), nameof(CompPilotConsole.StartChoosingDestination_NewTemp), lambdaOrdinal: 4); + } + + static void Prefix(bool ___launching, ref bool __state) + { + if (Multiplayer.Client == null || ___launching || Multiplayer.dontSync) + return; + + Multiplayer.dontSync = true; + __state = true; + } + + // TODO: Something in Feedback.cs seems to block the wantedMode switch. + // For now, it's set manually - consider keeping it this way permanently. + static void Finalizer(bool ___launching, PlanetTile ___curTile, bool __state) + { + if (Multiplayer.Client == null) return; + if (__state) Multiplayer.dontSync = false; + if (!Multiplayer.ExecutingCmds || !___launching) return; + + Find.World.renderer.wantedMode = WorldRenderMode.None; + Find.TilePicker.StopTargetingInt(); + GravshipTravelUtils.CloseSessionAt(___curTile); + } + } + + [HarmonyPatch] + public static class PatchGravshipMapArriveMethods + { + static IEnumerable TargetMethods() + { + yield return AccessTools.Method(typeof(GravshipUtility), nameof(GravshipUtility.ArriveExistingMap)); + yield return AccessTools.Method(typeof(GravshipUtility), nameof(GravshipUtility.ArriveNewMap)); + } + static void Postfix(Gravship gravship) + { + if (Multiplayer.Client == null) return; + + GravshipTravelUtils.OpenSessionAt(gravship.destinationTile); + } + } + + [HarmonyPatch(typeof(Designator_MoveGravship), nameof(Designator_MoveGravship.DesignateSingleCell))] + public static class PatchGravshipDesignatorDeselectForAllClients + { + static void Prefix() => CancelDesignatorDeselection.EnableCanceling(); + + static void Finalizer() => CancelDesignatorDeselection.DisableCanceling(); + } + + // TODO: Is there a better way to synchronize this method? + [HarmonyPatch(typeof(GravshipLandingMarker), nameof(GravshipLandingMarker.BeginLanding))] + public static class PatchBeginLandingToSyncWithClients + { + static bool Prefix(GravshipLandingMarker __instance) + { + if (Multiplayer.Client == null) return true; + if (Multiplayer.ExecutingCmds) return true; + + SyncBeginLanding(__instance); + + return false; + } + + [SyncMethod] + public static void SyncBeginLanding(GravshipLandingMarker landingMarker) + { + var gravshipController = Find.GravshipController; + + if (landingMarker == null || landingMarker.Tile == null) + { + MpLog.Error($"[MP] SyncConfirmGravshipLanding: Marker [{landingMarker != null}] Tile [{landingMarker?.Tile != null}]."); + return; + } + + gravshipController.landingMarker = landingMarker; + landingMarker.BeginLanding(gravshipController); + gravshipController.landingMarker = null; + + if (!TickPatch.currentExecutingCmdIssuedBySelf) + SoundDefOf.Gravship_Land.PlayOneShotOnCamera(); + } + } + + [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.AbortLanding))] + public static class PatchAbortLandingToCloseSession + { + static void Prefix(WorldComponent_GravshipController __instance, ref bool __state) + { + if (Multiplayer.Client == null) return; + if (!Multiplayer.ExecutingCmds) return; + + GravshipTravelUtils.CloseSessionAt(__instance.landingTile); + } + } + + [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.PlaceGravship))] + public static class PatchPlaceGravshipToUpdateSystemsAfterTheySpawn + { + static void Postfix(Map map) + { + if (Multiplayer.Client == null) return; + + map.glowGrid.GlowGridUpdate_First(); + } + } + + #endregion + + #region Landing/Takeoff freeze + + [HarmonyPatch] + public static class PatchGravshipCutsceneToFreeze + { + static IEnumerable TargetMethods() + { + yield return AccessTools.Method(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.InitiateTakeoff)); + yield return AccessTools.Method(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.InitiateLanding)); + } + + static void Postfix() + { + if (Multiplayer.Client == null) return; + + GravshipTravelUtils.StartFreeze(); + } + } + + [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.TakeoffEnded))] + public static class PatchGravshipTakeoffEnded + { + static void Prefix(WorldComponent_GravshipController __instance) + { + if (Multiplayer.Client == null) return; + + GravshipTravelUtils.StopFreeze(); + GravshipTravelUtils.CloseSessionAt(__instance.takeoffTile); + } + } + + // TODO: Is the random pushing here necessary? This might be related to issue #638. + [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.LandingEnded))] + public static class PatchGravshipLandingEnded + { + static void Prefix(WorldComponent_GravshipController __instance) + { + if (Multiplayer.Client == null) return; + + Rand.PushState(); + Rand.StateCompressed = __instance.map.AsyncTime().randState; + + GravshipTravelUtils.StopFreeze(); + GravshipTravelUtils.CloseSessionAt(__instance.gravship.destinationTile); + + // LandingEnded calls Find.TickManager.CurTimeSpeed = TimeSpeed.Normal, but that doesn't work in MP as we + // have our own way of changing the map speed. + __instance.map.AsyncTime().DesiredTimeSpeed = TimeSpeed.Normal; + } + + static void Finalizer() + { + if (Multiplayer.Client == null) return; + Rand.PopState(); + } + } + #endregion + + // TODO: Check what this actually does and whether it’s still necessary + // Stop the landing co message from showing every game tick + [HarmonyPatch(typeof(TickManager), nameof(TickManager.PlayerCanControl), MethodType.Getter)] + public static class PatchTickmanagerPlayerCanControlGetter + { + private static bool shownLandingMessage = false; + + static bool Prefix(ref AcceptanceReport __result) + { + if (Multiplayer.Client == null) return true; + + WorldComponent_GravshipController gravshipController = Find.GravshipController; + if (gravshipController != null && gravshipController.LandingAreaConfirmationInProgress) + { + __result = shownLandingMessage ? false : "MessageConfirmLandingAreaFirst".Translate(); + shownLandingMessage = true; + return false; + } + + // Not in a landing session, use vanilla logic for player control + __result = Current.Game.PlayerHasControl; + return false; + } + + // Call this when the landing session ends (e.g., in your session cleanup) + public static void ResetLandingMessageFlag() => shownLandingMessage = false; + } +} diff --git a/Source/Client/Patches/VanillaTweaks.cs b/Source/Client/Patches/VanillaTweaks.cs index a83a1a147..25292b70e 100644 --- a/Source/Client/Patches/VanillaTweaks.cs +++ b/Source/Client/Patches/VanillaTweaks.cs @@ -1,149 +1,151 @@ -using HarmonyLib; -using RimWorld; -using System.Collections.Generic; -using System.Reflection; -using System.Reflection.Emit; -using UnityEngine; -using Verse; - -namespace Multiplayer.Client -{ - // Fixes a lag spike when opening debug tools - [HarmonyPatch(typeof(UIRoot))] - [HarmonyPatch(nameof(UIRoot.UIRootOnGUI))] - static class UIRootPatch - { - static bool done; - - static void Prefix() - { - if (done) return; +using HarmonyLib; +using RimWorld; +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Emit; +using UnityEngine; +using Verse; + +namespace Multiplayer.Client +{ + // Fixes a lag spike when opening debug tools + [HarmonyPatch(typeof(UIRoot))] + [HarmonyPatch(nameof(UIRoot.UIRootOnGUI))] + static class UIRootPatch + { + static bool done; + + static void Prefix() + { + if (done) return; GUI.skin.font = Text.fontStyles[1].font; +#if !RIMWORLD_1_7 Text.fontStyles[1].font.fontNames = new[] { "arial", "arialbd", "ariali", "arialbi" }; - done = true; - } - } - - // Fix window focus handling - [HarmonyPatch(typeof(WindowStack))] - [HarmonyPatch(nameof(WindowStack.CloseWindowsBecauseClicked))] - public static class WindowFocusPatch - { - static void Prefix(WindowStack __instance, Window clickedWindow) - { - for (int i = Find.WindowStack.Windows.Count - 1; i >= 0; i--) - { - Window window = Find.WindowStack.Windows[i]; - __instance.focusedWindow = window; - - if (window == clickedWindow || window.closeOnClickedOutside) return; - UI.UnfocusCurrentControl(); - } - - __instance.focusedWindow = null; - } - } - - [HarmonyPatch(typeof(IncidentWorker_PawnsArrive), nameof(IncidentWorker_PawnsArrive.FactionCanBeGroupSource))] - static class FactionCanBeGroupSourcePatch - { - static void Postfix(Faction f, ref bool __result) - { - __result &= f.def.pawnGroupMakers?.Count > 0; - } - } - - [HarmonyPatch(typeof(WindowStack), nameof(WindowStack.AddNewImmediateWindow))] - static class LongEventWindowPreventCameraMotion - { - public const int LongEventWindowId = 62893994; - - // ReSharper disable once InconsistentNaming - static void Postfix(int ID) - { - if (ID == -LongEventWindowId || ID == -IngameModal.ModalWindowId) - { - var window = Find.WindowStack.windows.Find(w => w.ID == ID); - - window.absorbInputAroundWindow = true; - window.preventCameraMotion = true; - } - } - } - - [HarmonyPatch(typeof(Window), nameof(Window.WindowOnGUI))] - static class WindowDrawDarkBackground - { - static void Prefix(Window __instance) - { - if (Current.ProgramState == ProgramState.Entry) return; - - if (__instance.ID == -LongEventWindowPreventCameraMotion.LongEventWindowId || - __instance.ID == -IngameModal.ModalWindowId || - __instance is DisconnectedWindow || - __instance is CaravanFormingProxy - ) - Widgets.DrawBoxSolid(new Rect(0, 0, UI.screenWidth, UI.screenHeight), new Color(0, 0, 0, 0.5f)); - } - } - - // Fixes a bug with long event handler's immediate window draw order - [HarmonyPatch(typeof(WindowStack), nameof(WindowStack.ImmediateWindow))] - static class AddImmediateWindowsDuringLayouting - { - static IEnumerable Transpiler(IEnumerable insts) - { - bool found = false; - - foreach (var inst in insts) - { - if (!found && inst.opcode == OpCodes.Ldc_I4_7) - { - yield return new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(AddImmediateWindowsDuringLayouting), nameof(Process))); - found = true; - } - - yield return inst; - } - } - - static EventType Process(EventType type) => type == EventType.Layout ? EventType.Repaint : type; - } - - [HarmonyPatch(typeof(Widgets), nameof(Widgets.DrawLine))] - static class DrawLineOnlyOnRepaint - { - static bool Prefix() => Event.current.type == EventType.Repaint; - } - - [HarmonyPatch] - public static class WidgetsResolveParsePatch - { - static MethodBase TargetMethod() - { - return AccessTools.Method(typeof(Widgets), nameof(Widgets.ResolveParseNow)).MakeGenericMethod(typeof(int)); - } - - // Fix input field handling - static void Prefix(bool force, ref int val, ref string buffer, ref string edited) - { - if (force) - edited = Widgets.ToStringTypedIn(val); - } - } - - // Fix a lag spike when spawning/destroying things which affect the exit grid - [HarmonyPatch(typeof(ExitMapGrid), nameof(ExitMapGrid.Grid), MethodType.Getter)] - static class ExitMapGridPatch - { - static bool Prefix(ExitMapGrid __instance, ref BoolGrid __result) - { - // A copy of the getter without a costly call to MapUsesExitGrid - if (__instance.dirty) - __instance.Rebuild(); - __result = __instance.exitMapGrid; - return false; - } - } - -} +#endif + done = true; + } + } + + // Fix window focus handling + [HarmonyPatch(typeof(WindowStack))] + [HarmonyPatch(nameof(WindowStack.CloseWindowsBecauseClicked))] + public static class WindowFocusPatch + { + static void Prefix(WindowStack __instance, Window clickedWindow) + { + for (int i = Find.WindowStack.Windows.Count - 1; i >= 0; i--) + { + Window window = Find.WindowStack.Windows[i]; + __instance.focusedWindow = window; + + if (window == clickedWindow || window.closeOnClickedOutside) return; + UI.UnfocusCurrentControl(); + } + + __instance.focusedWindow = null; + } + } + + [HarmonyPatch(typeof(IncidentWorker_PawnsArrive), nameof(IncidentWorker_PawnsArrive.FactionCanBeGroupSource))] + static class FactionCanBeGroupSourcePatch + { + static void Postfix(Faction f, ref bool __result) + { + __result &= f.def.pawnGroupMakers?.Count > 0; + } + } + + [HarmonyPatch(typeof(WindowStack), nameof(WindowStack.AddNewImmediateWindow))] + static class LongEventWindowPreventCameraMotion + { + public const int LongEventWindowId = 62893994; + + // ReSharper disable once InconsistentNaming + static void Postfix(int ID) + { + if (ID == -LongEventWindowId || ID == -IngameModal.ModalWindowId) + { + var window = Find.WindowStack.windows.Find(w => w.ID == ID); + + window.absorbInputAroundWindow = true; + window.preventCameraMotion = true; + } + } + } + + [HarmonyPatch(typeof(Window), nameof(Window.WindowOnGUI))] + static class WindowDrawDarkBackground + { + static void Prefix(Window __instance) + { + if (Current.ProgramState == ProgramState.Entry) return; + + if (__instance.ID == -LongEventWindowPreventCameraMotion.LongEventWindowId || + __instance.ID == -IngameModal.ModalWindowId || + __instance is DisconnectedWindow || + __instance is CaravanFormingProxy + ) + Widgets.DrawBoxSolid(new Rect(0, 0, UI.screenWidth, UI.screenHeight), new Color(0, 0, 0, 0.5f)); + } + } + + // Fixes a bug with long event handler's immediate window draw order + [HarmonyPatch(typeof(WindowStack), nameof(WindowStack.ImmediateWindow))] + static class AddImmediateWindowsDuringLayouting + { + static IEnumerable Transpiler(IEnumerable insts) + { + bool found = false; + + foreach (var inst in insts) + { + if (!found && inst.opcode == OpCodes.Ldc_I4_7) + { + yield return new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(AddImmediateWindowsDuringLayouting), nameof(Process))); + found = true; + } + + yield return inst; + } + } + + static EventType Process(EventType type) => type == EventType.Layout ? EventType.Repaint : type; + } + + [HarmonyPatch(typeof(Widgets), nameof(Widgets.DrawLine))] + static class DrawLineOnlyOnRepaint + { + static bool Prefix() => Event.current.type == EventType.Repaint; + } + + [HarmonyPatch] + public static class WidgetsResolveParsePatch + { + static MethodBase TargetMethod() + { + return AccessTools.Method(typeof(Widgets), nameof(Widgets.ResolveParseNow)).MakeGenericMethod(typeof(int)); + } + + // Fix input field handling + static void Prefix(bool force, ref int val, ref string buffer, ref string edited) + { + if (force) + edited = Widgets.ToStringTypedIn(val); + } + } + + // Fix a lag spike when spawning/destroying things which affect the exit grid + [HarmonyPatch(typeof(ExitMapGrid), nameof(ExitMapGrid.Grid), MethodType.Getter)] + static class ExitMapGridPatch + { + static bool Prefix(ExitMapGrid __instance, ref BoolGrid __result) + { + // A copy of the getter without a costly call to MapUsesExitGrid + if (__instance.dirty) + __instance.Rebuild(); + __result = __instance.exitMapGrid; + return false; + } + } + +} diff --git a/Source/Client/Syncing/SyncTemplates.cs b/Source/Client/Syncing/SyncTemplates.cs index 88a379f93..c01410980 100644 --- a/Source/Client/Syncing/SyncTemplates.cs +++ b/Source/Client/Syncing/SyncTemplates.cs @@ -1,123 +1,103 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Reflection.Emit; -using HarmonyLib; - -namespace Multiplayer.Client -{ - public static class SyncTemplates - { - public static HarmonyMethod CreateTranspiler() => new HarmonyMethod(m_Transpiler) { - priority = Priority.First - }; - - static bool General(int internalId, object instance, object[] args) - { - if (Multiplayer.ShouldSync) { - Sync.internalIdToSyncMethod[internalId].DoSync(instance, args); - return false; - } - - return true; - } - - static readonly MethodInfo m_General = SymbolExtensions.GetMethodInfo(() => General(0, null, Array.Empty())); - static readonly MethodInfo m_Transpiler = SymbolExtensions.GetMethodInfo(() => Transpiler(null, null, null)); - - static IEnumerable Transpiler(MethodBase original, IEnumerable instructions, ILGenerator gen) - { - int idx; - var label = gen.DefineLabel(); - var parameters = original.GetParameters(); - - idx = 0; - foreach (var pInfo in parameters) { - var argIndex = idx++ + (original.IsStatic ? 0 : 1); - var pType = pInfo.ParameterType; - if (pInfo.IsOut) { +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; +using HarmonyLib; + +namespace Multiplayer.Client +{ + public static class SyncTemplates + { + public static HarmonyMethod CreateTranspiler() => new HarmonyMethod(m_Transpiler) { + priority = Priority.First + }; + + static bool General(int internalId, object instance, object[] args) + { + if (Multiplayer.ShouldSync) { + Sync.internalIdToSyncMethod[internalId].DoSync(instance, args); + return false; + } + + return true; + } + + static readonly MethodInfo m_General = SymbolExtensions.GetMethodInfo(() => General(0, null, Array.Empty())); + static readonly MethodInfo m_Transpiler = SymbolExtensions.GetMethodInfo(() => Transpiler(null, null, null)); + + static IEnumerable Transpiler(MethodBase original, IEnumerable instructions, ILGenerator gen) + { + int idx; + var label = gen.DefineLabel(); + var parameters = original.GetParameters(); + + idx = 0; + foreach (var pInfo in parameters) { + var argIndex = idx++ + (original.IsStatic ? 0 : 1); + var pType = pInfo.ParameterType; + if (pInfo.IsOut) { pType = pType.GetElementType(); yield return new CodeInstruction(OpCodes.Ldarg, argIndex); - yield return CreateDefaultCodes(gen, pType).Last(); - if (AccessTools.IsClass(pType)) - yield return new CodeInstruction(OpCodes.Stind_Ref); - if (AccessTools.IsValue(pType)) { - if (pType == typeof(float)) - yield return new CodeInstruction(OpCodes.Stind_R4, (float) 0); - else if (pType == typeof(double)) - yield return new CodeInstruction(OpCodes.Stind_R8, (double) 0); - else if (pType == typeof(long)) - yield return new CodeInstruction(OpCodes.Stind_I8, (long) 0); - else - yield return new CodeInstruction(OpCodes.Stind_I4, 0); - } - } - } - - yield return new CodeInstruction(OpCodes.Ldc_I4, Sync.methodBaseToInternalId[original]); - yield return new CodeInstruction(original.IsStatic ? OpCodes.Ldnull : OpCodes.Ldarg_0); - - yield return new CodeInstruction(OpCodes.Ldc_I4, parameters.Length); - yield return new CodeInstruction(OpCodes.Newarr, typeof(object)); - - idx = 0; - var arrayIdx = 0; - foreach (var pInfo in parameters) { - var argIndex = idx++ + (original.IsStatic ? 0 : 1); - var pType = pInfo.ParameterType; - yield return new CodeInstruction(OpCodes.Dup); - yield return new CodeInstruction(OpCodes.Ldc_I4, arrayIdx++); - yield return new CodeInstruction(OpCodes.Ldarg, argIndex); - if (pType.IsByRef) { - pType = pType.GetElementType(); - if (pType.IsValueType) { - yield return new CodeInstruction(OpCodes.Ldobj, pType); - yield return new CodeInstruction(OpCodes.Box, pType); - } else - yield return new CodeInstruction(OpCodes.Ldind_Ref, pType); - } else if (pType.IsValueType) - yield return new CodeInstruction(OpCodes.Box, pType); - yield return new CodeInstruction(OpCodes.Stelem_Ref); - } - yield return new CodeInstruction(OpCodes.Call, m_General); - yield return new CodeInstruction(OpCodes.Brtrue, label); - foreach (var code in CreateDefaultCodes(gen, AccessTools.GetReturnedType(original))) - yield return code; - yield return new CodeInstruction(OpCodes.Ret); - - var list = instructions.ToList(); - list.First().labels.Add(label); - foreach (var instruction in list) - yield return instruction; - } - + yield return new CodeInstruction(OpCodes.Initobj, pType); + } + } + + yield return new CodeInstruction(OpCodes.Ldc_I4, Sync.methodBaseToInternalId[original]); + yield return new CodeInstruction(original.IsStatic ? OpCodes.Ldnull : OpCodes.Ldarg_0); + + yield return new CodeInstruction(OpCodes.Ldc_I4, parameters.Length); + yield return new CodeInstruction(OpCodes.Newarr, typeof(object)); + + idx = 0; + var arrayIdx = 0; + foreach (var pInfo in parameters) { + var argIndex = idx++ + (original.IsStatic ? 0 : 1); + var pType = pInfo.ParameterType; + yield return new CodeInstruction(OpCodes.Dup); + yield return new CodeInstruction(OpCodes.Ldc_I4, arrayIdx++); + yield return new CodeInstruction(OpCodes.Ldarg, argIndex); + if (pType.IsByRef) { + pType = pType.GetElementType(); + if (pType.IsValueType) { + yield return new CodeInstruction(OpCodes.Ldobj, pType); + yield return new CodeInstruction(OpCodes.Box, pType); + } else + yield return new CodeInstruction(OpCodes.Ldind_Ref, pType); + } else if (pType.IsValueType) + yield return new CodeInstruction(OpCodes.Box, pType); + yield return new CodeInstruction(OpCodes.Stelem_Ref); + } + yield return new CodeInstruction(OpCodes.Call, m_General); + yield return new CodeInstruction(OpCodes.Brtrue, label); + foreach (var code in CreateDefaultCodes(gen, AccessTools.GetReturnedType(original))) + yield return code; + yield return new CodeInstruction(OpCodes.Ret); + + var list = instructions.ToList(); + list.First().labels.Add(label); + foreach (var instruction in list) + yield return instruction; + } + static IEnumerable CreateDefaultCodes(ILGenerator generator, Type type) { + if (type == typeof(void)) yield break; if (type.IsByRef) type = type.GetElementType(); - if (AccessTools.IsClass(type)) { - yield return new CodeInstruction(OpCodes.Ldnull); - yield break; - } - if (AccessTools.IsStruct(type)) { - var v = generator.DeclareLocal(type); - yield return new CodeInstruction(OpCodes.Ldloca, v); + if (!type.IsValueType) { + yield return new CodeInstruction(OpCodes.Ldnull); + yield break; + } + if (type.IsValueType) { + var v = generator.DeclareLocal(type); + yield return new CodeInstruction(OpCodes.Ldloca, v); yield return new CodeInstruction(OpCodes.Initobj, type); + yield return new CodeInstruction(OpCodes.Ldloc, v); yield break; } - if (AccessTools.IsValue(type)) { - if (type == typeof(float)) - yield return new CodeInstruction(OpCodes.Ldc_R4, (float) 0); - else if (type == typeof(double)) - yield return new CodeInstruction(OpCodes.Ldc_R8, (double) 0); - else if (type == typeof(long)) - yield return new CodeInstruction(OpCodes.Ldc_I8, (long) 0); - else - yield return new CodeInstruction(OpCodes.Ldc_I4, 0); - } - } - - } -} + } + + } +} diff --git a/Source/Client/UI/MainMenuAnimation.cs b/Source/Client/UI/MainMenuAnimation.cs index c1810c71f..73f7ad27b 100644 --- a/Source/Client/UI/MainMenuAnimation.cs +++ b/Source/Client/UI/MainMenuAnimation.cs @@ -1,439 +1,440 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using HarmonyLib; -using Multiplayer.Client.Util; -using RimWorld; -using UnityEngine; -using Verse; -using Random = System.Random; - -namespace Multiplayer.Client -{ - [HarmonyPatch(typeof(UI_BackgroundMain), nameof(UI_BackgroundMain.DoOverlay))] - [StaticConstructorOnStartup] - static class MainMenuAnimation - { - static Texture2D texture; - - const float r = 2282; // radius of planet - const float cx = r + 80, cy = r + 384; // center coords - const float origWidth = 8000, origHeight = 5000; - const int texWidth = (int)cx + 1871; // max extent in the x direction - const int texHeight = 5000; - - // The background might be drawn at the same time as the loading of a map on a different thread calls Verse.Rand - // Verse.Rand can't be used here because it isn't thread-safe - private static Random rand = new(SystemInfo.deviceUniqueIdentifier.GetHashCode()); - - private static float newPulseTimer; - - static MainMenuAnimation() - { - using (DeepProfilerWrapper.Section("Multiplayer main menu animation texture gen")) - { - texture = new(texWidth, texHeight, TextureFormat.RGBA32, false); - RegenTexture(); - } - } - - static void Prefix(UI_BackgroundMain __instance, Rect bgRect) - { - if (Input.GetKeyDown(KeyCode.KeypadPeriod)) - DoMainMenuPatch.hideMainMenu = !DoMainMenuPatch.hideMainMenu; - - if (!Multiplayer.settings.showMainMenuAnim - || __instance.overrideBGImage != null && __instance.overrideBGImage != UI_BackgroundMain.BGPlanet) - return; - - if (Input.GetKeyDown(KeyCode.End)) - RegenTexture(); - - GUI.DrawTexture(bgRect with { width = texWidth * bgRect.width / origWidth }, texture, ScaleMode.ScaleToFit); - -#if DEBUG - if (Input.GetKey(KeyCode.LeftShift)) - DrawEdges(bgRect); -#endif - - if (Event.current.type != EventType.Repaint) return; - - newPulseTimer += Time.deltaTime; - if (newPulseTimer > 1 / 12f) - { - var edge = edges[rand.Next(edges.Count)]; - var switchEndpoints = rand.NextDouble() < 0.5; - pulses.Add(new Pulse(switchEndpoints ? edge.v2 : edge.v1, switchEndpoints ? edge.v1 : edge.v2) { starting = true }); - newPulseTimer = 0; - } - - DrawPulses(bgRect); - } - - static void DrawPulses(Rect bgRect) - { - for (int i = pulses.Count - 1; i >= 0; i--) - { - var pulse = pulses[i]; - pulse.progress += Math.Min(Time.deltaTime, 0.05f) * 0.25f / pulse.dist; - - if (pulse.progress > 1f) - { - pulses.RemoveAt(i); - - if (!pulse.EaseOut) - pulses.Add(new Pulse(pulse.end, pulse.nextEnd) - { - ending = rand.NextDouble() < 0.4f - }); - } - - var start = Quaternion.Euler(pulse.start.theta, pulse.start.phi, 0); - var end = Quaternion.Euler(pulse.end.theta, pulse.end.phi, 0); - - var x = pulse.progress; - - var progress = - pulse.EaseIn && pulse.EaseOut ? x < 0.5 ? 2 * x * x : 1 - (float)Math.Pow(-2 * x + 2, 2) / 2 : - pulse.EaseIn ? x * x : - pulse.EaseOut ? 1 - (1 - x) * (1 - x) : - x; - - float theta = Quaternion.Lerp(start, end, progress).eulerAngles.x; - float phi = Quaternion.Lerp(start, end, progress).eulerAngles.y; - - float posX = cx + r * Mathf.Cos(theta * Mathf.Deg2Rad) * Mathf.Sin(phi * Mathf.Deg2Rad); - float posY = cy + r * Mathf.Sin(theta * Mathf.Deg2Rad); - - const float alphaEase = 0.3f; - var alpha = - pulse.EaseIn && x < alphaEase ? x / alphaEase : - pulse.EaseOut && x > 1f - alphaEase ? (1 - x) / alphaEase : - 1; - - using (MpStyle.Set(new Color(1, 1, 1, alpha))) - GUI.DrawTexture( - new Rect( - GetPos(bgRect, posX, posY), - new(6, 6) - ), - // After changing the game language, RimWorld destroys and reloads all assets but doesn't - // rerun static constructors, so resource fields reference destroyed resources - // The pulses disappear after changing the language - MultiplayerStatic.Pulse - ); - } - } - - static Vert GetNewEnd(Pulse pulse) - { - Vert newEnd = null; - var attempts = 0; - - while (newEnd == null && attempts < 20) - { - var randEdge = edges[rand.Next(edges.Count)]; - var otherVert = randEdge.OtherVert(pulse.end); - if (otherVert != null && otherVert != pulse.start && otherVert != pulse.end) - newEnd = otherVert; - attempts++; - } - - return newEnd; - } - - const float rotate = 35f; - const float maskTheta = 10; - - static void DrawEdges(Rect bgRect) - { - { - var proj1 = r * Project(maskTheta, -90).RotatedBy(rotate) + new Vector2(cx, cy); - var proj2 = r * Project(maskTheta, 90).RotatedBy(rotate) + new Vector2(cx, cy); - Widgets.DrawLine(GetPos(bgRect, proj1.x, proj1.y), GetPos(bgRect, proj2.x, proj2.y), Color.blue, 2f); - } - - foreach (var e in edges) - { - var start = Quaternion.Euler(e.v1.theta, e.v1.phi, 0); - var end = Quaternion.Euler(e.v2.theta, e.v2.phi, 0); - - float prevX = 0; - float prevY = 0; - - for (float d = 0; d <= 1; d += 0.25f) - { - float theta = Quaternion.Lerp(start, end, d).eulerAngles.x; - float phi = Quaternion.Lerp(start, end, d).eulerAngles.y; - - var proj = Project(theta, phi); - float x = cx + r * proj.x; - float y = cy + r * proj.y; - - if (prevX != 0 && prevY != 0) - { - var lineStart = GetPos(bgRect, prevX, prevY); - var lineEnd = GetPos(bgRect, x, y); - - if (d == 0.5f) - MpUI.Label(new Rect(lineStart, new(100, 100)), $"{e.dist}", GameFont.Tiny); - - Widgets.DrawLine(lineStart, lineEnd, Color.red, 2f); - } - - prevX = x; - prevY = y; - } - } - } - - static float SphereDist(float t1, float p1, float t2, float p2) - { - return Mathf.Acos( - Mathf.Sin(t1 * Mathf.Deg2Rad) * Mathf.Sin(t2 * Mathf.Deg2Rad) - + Mathf.Cos(t1 * Mathf.Deg2Rad) * Mathf.Cos(t2 * Mathf.Deg2Rad) * Mathf.Cos((p2 - p1) * Mathf.Deg2Rad) - ); - } - - private static List verts = new(); - private static List edges = new(); - private static List pulses = new(); - - static void Mst() - { - foreach (var v in verts) - { - v.cost = float.PositiveInfinity; - v.costVert = null; - } - - var q = verts.ToList(); - - while (q.Count > 0) - { - var v = q.MinBy(a => a.cost); - q.Remove(v); - - if (v.costVert != null) - edges.Add(new Edge(v, v.costVert)); - - foreach (var w in q) - { - var dist = SphereDist(v.theta, v.phi, w.theta, w.phi); - if (dist < w.cost) - { - w.cost = dist; - w.costVert = v; - } - } - } - } - - static Vector2 InverseSpherical(float x, float y, float r) - { - var ro = new Vector2(x, y).magnitude; - var c = Mathf.Asin(ro / r); - return new Vector2( - Mathf.Asin(y * Mathf.Sin(c) / ro) * Mathf.Rad2Deg, - Mathf.Atan2(x * Mathf.Sin(c), ro * Mathf.Cos(c)) * Mathf.Rad2Deg - ); - } - - static Vector2 GetPos(Rect rect, float x, float y) - { - return rect.min + new Vector2(rect.width * x / origWidth, rect.height * y / origHeight); - } - - static Vector2 Project(float theta, float phi) - { - return new Vector2( - Mathf.Cos(theta * Mathf.Deg2Rad) * Mathf.Sin(phi * Mathf.Deg2Rad), - Mathf.Sin(theta * Mathf.Deg2Rad) - ); - } - - static void RegenTexture() - { - using var _ = DeepProfilerWrapper.Section("RegenTexture"); - - verts.Clear(); - - // vaniller cities - verts.Add(new Vert { theta = 60, phi = 34 }); - verts.Add(new Vert { theta = -4, phi = -68 }); - verts.Add(new Vert { theta = -2, phi = -68 }); - verts.Add(new Vert { theta = 4, phi = -50 }); - verts.Add(new Vert { theta = 18, phi = -61 }); - - edges.Clear(); - pulses.Clear(); - - var colors = new Color[texWidth * texHeight]; - - // Cities - for (int i = 0; i < 20;) - { - float cityTheta = rand.Range(maskTheta, 75f); - float cityPhi = rand.Range(-75f, 75f); - - // ugh - var pr = Project(cityTheta, cityPhi).RotatedBy(rotate); - var inv = InverseSpherical(pr.x, pr.y, 1f); - cityTheta = inv.x; - cityPhi = inv.y; - - // Avoid the light side - var cityXY = r * Project(cityTheta, cityPhi) + new Vector2(cx, cy); - const float maskR = 2160; - if ((new Vector2(520 + maskR, 64 + maskR) - cityXY).magnitude < maskR) - continue; - - var blobs = rand.Next(2, 5); - if (rand.NextDouble() < 0.15) blobs += rand.Next(2, 4); - - Vector2? lit = null; - - // Blobs - for (int k = 0; k < blobs; k++) - { - var blobSpan = 2.5f + Math.Max(0, (blobs - 3) * 0.25f); - float theta = cityTheta + rand.Range(-blobSpan, blobSpan); - float phi = cityPhi + rand.Range(-blobSpan, blobSpan); - - // Dots - for (int j = 0; j < 7; j++) - { - var blobSize = j == 0 || rand.NextDouble() < 0.3f ? 15 : 10; - - float dotTheta = rand.Range(-1f, 1f); - float dotPhi = rand.Range(-1f, 1f); - - for (int y = -blobSize; y <= blobSize; y++) - for (int x = -blobSize; x <= blobSize; x++) - if (x * x + y * y < blobSize * blobSize) - { - var proj = Project(theta + dotTheta, phi + dotPhi); - float centerX = cx + r * proj.x; - float centerY = cy + r * proj.y; - - var c = 1 - new Vector2(x,y).magnitude / blobSize; - var fromCenter = 1 - new Vector2(dotTheta, dotPhi).magnitude / 2; - var fromCenter2 = 1 - new Vector2(theta - cityTheta, phi - cityPhi).magnitude / 5; - var a = c * fromCenter * fromCenter2; - - if (j == 0) a *= 3f; - else if (a > 0.4) a *= 2f; - - var pos = new Vector2(centerX, centerY); - var color = new Color(0.93f, 0.75f, 0.55f, a * a * 2); - var pixelX = (int)pos.x + x; - var pixelY = (int)origHeight - (int)pos.y + y; - colors[pixelX + pixelY * texWidth] = color; - - if (color.a >= 1 && lit == null) - { - lit = new Vector2(theta + dotTheta, phi + dotPhi); - } - } - } - } - - if (lit != null) - verts.Add(new Vert { theta = lit.Value.x, phi = lit.Value.y }); - - i++; - } - - using (DeepProfilerWrapper.Section("SetPixels and Apply")) - { - texture.SetPixels(colors); - texture.Apply(); - } - - Mst(); - } - - class Vert - { - public float theta; - public float phi; - - public float cost; - public Vert costVert; - } - - class Edge - { - public readonly Vert v1, v2; - public readonly float dist; - - public Edge(Vert v1, Vert v2) - { - this.v1 = v1; - this.v2 = v2; - - dist = SphereDist(v1.theta, v1.phi, v2.theta, v2.phi); - } - - protected bool Equals(Edge other) - { - return - Equals(v1, other.v1) && Equals(v2, other.v2) - || Equals(v1, other.v2) && Equals(v2, other.v1); - } - - public override bool Equals(object obj) - { - if (ReferenceEquals(null, obj)) return false; - if (ReferenceEquals(this, obj)) return true; - return obj.GetType() == GetType() && Equals((Edge)obj); - } - - public override int GetHashCode() - { - return v1.GetHashCode() * v2.GetHashCode(); - } - - public Vert OtherVert(Vert v) - { - return v1 == v ? v2 : v2 == v ? v1 : null; - } - } - - class Pulse - { - public float progress; - public Vert start; - public Vert end; - public float dist; - - public bool starting; - public bool ending; - public Vert nextEnd; - - public bool EaseIn => starting; - public bool EaseOut => ending || nextEnd == null; - - public Pulse(Vert start, Vert end) - { - this.start = start; - this.end = end; - - dist = SphereDist(start.theta, start.phi, end.theta, end.phi); - nextEnd = GetNewEnd(this); - } - } - } - - [HarmonyPatch(typeof(UIRoot_Entry), nameof(UIRoot_Entry.ShouldDoMainMenu), MethodType.Getter)] - static class DoMainMenuPatch - { - public static bool hideMainMenu; - - static void Postfix(ref bool __result) - { - if (hideMainMenu) - __result = false; - } - } -} +using System; +using System.Collections.Generic; +using System.Linq; +using HarmonyLib; +using Multiplayer.Client.Util; +using RimWorld; +using UnityEngine; +using Verse; +using Random = System.Random; + +namespace Multiplayer.Client +{ + [HarmonyPatch(typeof(UI_BackgroundMain), nameof(UI_BackgroundMain.DoOverlay))] + [StaticConstructorOnStartup] + static class MainMenuAnimation + { + static Texture2D texture; + + const float r = 2282; // radius of planet + const float cx = r + 80, cy = r + 384; // center coords + const float origWidth = 8000, origHeight = 5000; + const int texWidth = (int)cx + 1871; // max extent in the x direction + const int texHeight = 5000; + + // The background might be drawn at the same time as the loading of a map on a different thread calls Verse.Rand + // Verse.Rand can't be used here because it isn't thread-safe + // Cosmetic only: no dependency on Unity's hardware identifier. + private static Random rand = new(); + + private static float newPulseTimer; + + static MainMenuAnimation() + { + using (DeepProfilerWrapper.Section("Multiplayer main menu animation texture gen")) + { + texture = new(texWidth, texHeight, TextureFormat.RGBA32, false); + RegenTexture(); + } + } + + static void Prefix(UI_BackgroundMain __instance, Rect bgRect) + { + if (Input.GetKeyDown(KeyCode.KeypadPeriod)) + DoMainMenuPatch.hideMainMenu = !DoMainMenuPatch.hideMainMenu; + + if (!Multiplayer.settings.showMainMenuAnim + || __instance.overrideBGImage != null && __instance.overrideBGImage != UI_BackgroundMain.BGPlanet) + return; + + if (Input.GetKeyDown(KeyCode.End)) + RegenTexture(); + + GUI.DrawTexture(bgRect with { width = texWidth * bgRect.width / origWidth }, texture, ScaleMode.ScaleToFit); + +#if DEBUG + if (Input.GetKey(KeyCode.LeftShift)) + DrawEdges(bgRect); +#endif + + if (Event.current.type != EventType.Repaint) return; + + newPulseTimer += Time.deltaTime; + if (newPulseTimer > 1 / 12f) + { + var edge = edges[rand.Next(edges.Count)]; + var switchEndpoints = rand.NextDouble() < 0.5; + pulses.Add(new Pulse(switchEndpoints ? edge.v2 : edge.v1, switchEndpoints ? edge.v1 : edge.v2) { starting = true }); + newPulseTimer = 0; + } + + DrawPulses(bgRect); + } + + static void DrawPulses(Rect bgRect) + { + for (int i = pulses.Count - 1; i >= 0; i--) + { + var pulse = pulses[i]; + pulse.progress += Math.Min(Time.deltaTime, 0.05f) * 0.25f / pulse.dist; + + if (pulse.progress > 1f) + { + pulses.RemoveAt(i); + + if (!pulse.EaseOut) + pulses.Add(new Pulse(pulse.end, pulse.nextEnd) + { + ending = rand.NextDouble() < 0.4f + }); + } + + var start = Quaternion.Euler(pulse.start.theta, pulse.start.phi, 0); + var end = Quaternion.Euler(pulse.end.theta, pulse.end.phi, 0); + + var x = pulse.progress; + + var progress = + pulse.EaseIn && pulse.EaseOut ? x < 0.5 ? 2 * x * x : 1 - (float)Math.Pow(-2 * x + 2, 2) / 2 : + pulse.EaseIn ? x * x : + pulse.EaseOut ? 1 - (1 - x) * (1 - x) : + x; + + float theta = Quaternion.Lerp(start, end, progress).eulerAngles.x; + float phi = Quaternion.Lerp(start, end, progress).eulerAngles.y; + + float posX = cx + r * Mathf.Cos(theta * Mathf.Deg2Rad) * Mathf.Sin(phi * Mathf.Deg2Rad); + float posY = cy + r * Mathf.Sin(theta * Mathf.Deg2Rad); + + const float alphaEase = 0.3f; + var alpha = + pulse.EaseIn && x < alphaEase ? x / alphaEase : + pulse.EaseOut && x > 1f - alphaEase ? (1 - x) / alphaEase : + 1; + + using (MpStyle.Set(new Color(1, 1, 1, alpha))) + GUI.DrawTexture( + new Rect( + GetPos(bgRect, posX, posY), + new(6, 6) + ), + // After changing the game language, RimWorld destroys and reloads all assets but doesn't + // rerun static constructors, so resource fields reference destroyed resources + // The pulses disappear after changing the language + MultiplayerStatic.Pulse + ); + } + } + + static Vert GetNewEnd(Pulse pulse) + { + Vert newEnd = null; + var attempts = 0; + + while (newEnd == null && attempts < 20) + { + var randEdge = edges[rand.Next(edges.Count)]; + var otherVert = randEdge.OtherVert(pulse.end); + if (otherVert != null && otherVert != pulse.start && otherVert != pulse.end) + newEnd = otherVert; + attempts++; + } + + return newEnd; + } + + const float rotate = 35f; + const float maskTheta = 10; + + static void DrawEdges(Rect bgRect) + { + { + var proj1 = r * Project(maskTheta, -90).RotatedBy(rotate) + new Vector2(cx, cy); + var proj2 = r * Project(maskTheta, 90).RotatedBy(rotate) + new Vector2(cx, cy); + Widgets.DrawLine(GetPos(bgRect, proj1.x, proj1.y), GetPos(bgRect, proj2.x, proj2.y), Color.blue, 2f); + } + + foreach (var e in edges) + { + var start = Quaternion.Euler(e.v1.theta, e.v1.phi, 0); + var end = Quaternion.Euler(e.v2.theta, e.v2.phi, 0); + + float prevX = 0; + float prevY = 0; + + for (float d = 0; d <= 1; d += 0.25f) + { + float theta = Quaternion.Lerp(start, end, d).eulerAngles.x; + float phi = Quaternion.Lerp(start, end, d).eulerAngles.y; + + var proj = Project(theta, phi); + float x = cx + r * proj.x; + float y = cy + r * proj.y; + + if (prevX != 0 && prevY != 0) + { + var lineStart = GetPos(bgRect, prevX, prevY); + var lineEnd = GetPos(bgRect, x, y); + + if (d == 0.5f) + MpUI.Label(new Rect(lineStart, new(100, 100)), $"{e.dist}", GameFont.Tiny); + + Widgets.DrawLine(lineStart, lineEnd, Color.red, 2f); + } + + prevX = x; + prevY = y; + } + } + } + + static float SphereDist(float t1, float p1, float t2, float p2) + { + return Mathf.Acos( + Mathf.Sin(t1 * Mathf.Deg2Rad) * Mathf.Sin(t2 * Mathf.Deg2Rad) + + Mathf.Cos(t1 * Mathf.Deg2Rad) * Mathf.Cos(t2 * Mathf.Deg2Rad) * Mathf.Cos((p2 - p1) * Mathf.Deg2Rad) + ); + } + + private static List verts = new(); + private static List edges = new(); + private static List pulses = new(); + + static void Mst() + { + foreach (var v in verts) + { + v.cost = float.PositiveInfinity; + v.costVert = null; + } + + var q = verts.ToList(); + + while (q.Count > 0) + { + var v = q.MinBy(a => a.cost); + q.Remove(v); + + if (v.costVert != null) + edges.Add(new Edge(v, v.costVert)); + + foreach (var w in q) + { + var dist = SphereDist(v.theta, v.phi, w.theta, w.phi); + if (dist < w.cost) + { + w.cost = dist; + w.costVert = v; + } + } + } + } + + static Vector2 InverseSpherical(float x, float y, float r) + { + var ro = new Vector2(x, y).magnitude; + var c = Mathf.Asin(ro / r); + return new Vector2( + Mathf.Asin(y * Mathf.Sin(c) / ro) * Mathf.Rad2Deg, + Mathf.Atan2(x * Mathf.Sin(c), ro * Mathf.Cos(c)) * Mathf.Rad2Deg + ); + } + + static Vector2 GetPos(Rect rect, float x, float y) + { + return rect.min + new Vector2(rect.width * x / origWidth, rect.height * y / origHeight); + } + + static Vector2 Project(float theta, float phi) + { + return new Vector2( + Mathf.Cos(theta * Mathf.Deg2Rad) * Mathf.Sin(phi * Mathf.Deg2Rad), + Mathf.Sin(theta * Mathf.Deg2Rad) + ); + } + + static void RegenTexture() + { + using var _ = DeepProfilerWrapper.Section("RegenTexture"); + + verts.Clear(); + + // vaniller cities + verts.Add(new Vert { theta = 60, phi = 34 }); + verts.Add(new Vert { theta = -4, phi = -68 }); + verts.Add(new Vert { theta = -2, phi = -68 }); + verts.Add(new Vert { theta = 4, phi = -50 }); + verts.Add(new Vert { theta = 18, phi = -61 }); + + edges.Clear(); + pulses.Clear(); + + var colors = new Color[texWidth * texHeight]; + + // Cities + for (int i = 0; i < 20;) + { + float cityTheta = rand.Range(maskTheta, 75f); + float cityPhi = rand.Range(-75f, 75f); + + // ugh + var pr = Project(cityTheta, cityPhi).RotatedBy(rotate); + var inv = InverseSpherical(pr.x, pr.y, 1f); + cityTheta = inv.x; + cityPhi = inv.y; + + // Avoid the light side + var cityXY = r * Project(cityTheta, cityPhi) + new Vector2(cx, cy); + const float maskR = 2160; + if ((new Vector2(520 + maskR, 64 + maskR) - cityXY).magnitude < maskR) + continue; + + var blobs = rand.Next(2, 5); + if (rand.NextDouble() < 0.15) blobs += rand.Next(2, 4); + + Vector2? lit = null; + + // Blobs + for (int k = 0; k < blobs; k++) + { + var blobSpan = 2.5f + Math.Max(0, (blobs - 3) * 0.25f); + float theta = cityTheta + rand.Range(-blobSpan, blobSpan); + float phi = cityPhi + rand.Range(-blobSpan, blobSpan); + + // Dots + for (int j = 0; j < 7; j++) + { + var blobSize = j == 0 || rand.NextDouble() < 0.3f ? 15 : 10; + + float dotTheta = rand.Range(-1f, 1f); + float dotPhi = rand.Range(-1f, 1f); + + for (int y = -blobSize; y <= blobSize; y++) + for (int x = -blobSize; x <= blobSize; x++) + if (x * x + y * y < blobSize * blobSize) + { + var proj = Project(theta + dotTheta, phi + dotPhi); + float centerX = cx + r * proj.x; + float centerY = cy + r * proj.y; + + var c = 1 - new Vector2(x,y).magnitude / blobSize; + var fromCenter = 1 - new Vector2(dotTheta, dotPhi).magnitude / 2; + var fromCenter2 = 1 - new Vector2(theta - cityTheta, phi - cityPhi).magnitude / 5; + var a = c * fromCenter * fromCenter2; + + if (j == 0) a *= 3f; + else if (a > 0.4) a *= 2f; + + var pos = new Vector2(centerX, centerY); + var color = new Color(0.93f, 0.75f, 0.55f, a * a * 2); + var pixelX = (int)pos.x + x; + var pixelY = (int)origHeight - (int)pos.y + y; + colors[pixelX + pixelY * texWidth] = color; + + if (color.a >= 1 && lit == null) + { + lit = new Vector2(theta + dotTheta, phi + dotPhi); + } + } + } + } + + if (lit != null) + verts.Add(new Vert { theta = lit.Value.x, phi = lit.Value.y }); + + i++; + } + + using (DeepProfilerWrapper.Section("SetPixels and Apply")) + { + texture.SetPixels(colors); + texture.Apply(); + } + + Mst(); + } + + class Vert + { + public float theta; + public float phi; + + public float cost; + public Vert costVert; + } + + class Edge + { + public readonly Vert v1, v2; + public readonly float dist; + + public Edge(Vert v1, Vert v2) + { + this.v1 = v1; + this.v2 = v2; + + dist = SphereDist(v1.theta, v1.phi, v2.theta, v2.phi); + } + + protected bool Equals(Edge other) + { + return + Equals(v1, other.v1) && Equals(v2, other.v2) + || Equals(v1, other.v2) && Equals(v2, other.v1); + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + return obj.GetType() == GetType() && Equals((Edge)obj); + } + + public override int GetHashCode() + { + return v1.GetHashCode() * v2.GetHashCode(); + } + + public Vert OtherVert(Vert v) + { + return v1 == v ? v2 : v2 == v ? v1 : null; + } + } + + class Pulse + { + public float progress; + public Vert start; + public Vert end; + public float dist; + + public bool starting; + public bool ending; + public Vert nextEnd; + + public bool EaseIn => starting; + public bool EaseOut => ending || nextEnd == null; + + public Pulse(Vert start, Vert end) + { + this.start = start; + this.end = end; + + dist = SphereDist(start.theta, start.phi, end.theta, end.phi); + nextEnd = GetNewEnd(this); + } + } + } + + [HarmonyPatch(typeof(UIRoot_Entry), nameof(UIRoot_Entry.ShouldDoMainMenu), MethodType.Getter)] + static class DoMainMenuPatch + { + public static bool hideMainMenu; + + static void Postfix(ref bool __result) + { + if (hideMainMenu) + __result = false; + } + } +} diff --git a/Source/Client/Util/MpUI.cs b/Source/Client/Util/MpUI.cs index b80ea128d..f055dde2b 100644 --- a/Source/Client/Util/MpUI.cs +++ b/Source/Client/Util/MpUI.cs @@ -1,276 +1,278 @@ -using System.Collections.Generic; -using HarmonyLib; -using RimWorld; -using UnityEngine; -using Verse; -using Verse.Sound; - -namespace Multiplayer.Client.Util -{ - public static class MpUI - { - public static Vector2 Resolution => new Vector2(UI.screenWidth, UI.screenHeight); - - public static string FixedEllipsis() - { - int num = Mathf.FloorToInt(Time.realtimeSinceStartup) % 3; - return num switch - { - 0 => ". ", - 1 => ".. ", - _ => "..." - }; - } - - public static void DrawRotatedLine(Vector2 center, float length, float width, float angle, Color color) - { - var size = new Vector2(length, width); - var start = center - size / 2f; - Rect screenRect = new Rect(start.x, start.y, length, width); - Matrix4x4 m = Matrix4x4.TRS(center, Quaternion.Euler(0f, 0f, angle), Vector3.one) * Matrix4x4.TRS(-center, Quaternion.identity, Vector3.one); - - GL.PushMatrix(); - GL.MultMatrix(m); - GUI.DrawTexture(screenRect, Widgets.LineTexAA, ScaleMode.StretchToFill, true, 0f, color, 0f, 0f); - GL.PopMatrix(); - } - - public static void Label(Rect rect, string label, GameFont? font = null, TextAnchor? anchor = null, Color? color = null) - { - var prevFont = Text.Font; - var prevAnchor = Text.Anchor; - var prevColor = GUI.color; - - if (font != null) - Text.Font = font.Value; - - if (anchor != null) - Text.Anchor = anchor.Value; - - if (color != null) - GUI.color = color.Value; - - Widgets.Label(rect, label); - - GUI.color = prevColor; - Text.Anchor = prevAnchor; - Text.Font = prevFont; - } - - public static void Checkbox(float x, float y, ref bool checkOn, bool disabled, float size) - { - if (!disabled && Widgets.ButtonInvisible(new Rect(x, y, size, size), false)) - { - checkOn = !checkOn; - if (checkOn) - SoundDefOf.Checkbox_TurnedOn.PlayOneShotOnCamera(null); - else - SoundDefOf.Checkbox_TurnedOff.PlayOneShotOnCamera(null); - } - - Widgets.CheckboxDraw(x, y, checkOn, disabled, size); - } - - public static Rect CheckboxLabeled(Rect rect, string label, ref bool checkOn, bool disabled = false, ElementOrder order = ElementOrder.Apart, float size = 24f) - { - TextAnchor anchor = Text.Anchor; - Text.Anchor = TextAnchor.MiddleLeft; - - if (order == ElementOrder.Right) - { - float textWidth = Text.CalcSize(label).x; - rect.x = rect.xMax - textWidth - size - 5f; - rect.width = textWidth + size + 5f; - } - else if (order == ElementOrder.Left) - { - float textWidth = Text.CalcSize(label).x; - rect.width = textWidth + size + 5f; - } - - Widgets.Label(rect, label); - - Checkbox( - rect.xMax - size, - rect.y + (rect.height - size) / 2, - ref checkOn, - disabled, - size); - - Text.Anchor = anchor; - - return rect; - } - - public static string TextEntryLabeled(Rect rect, string label, string text, float labelWidth) - { - Rect labelRect = rect.Rounded(); - labelRect.width = labelWidth; - - using (MpStyle.Set(TextAnchor.MiddleRight)) - Widgets.Label(labelRect, label); - - rect.xMin += labelWidth; - return Widgets.TextField(rect, text); - } - - public static bool TextFieldNumericLabeled(Rect rect, string label, ref T val, ref string buffer, float labelWidth, float min = 0, float max = float.MaxValue, bool clickable = false, string tooltip = null) where T : struct - { - Rect labelRect = rect; - labelRect.width = labelWidth; - - using (MpStyle.Set(TextAnchor.MiddleRight)) - using (MpStyle.Set(WordWrap.NoWrap)) - Widgets.Label(labelRect, label); - - if (clickable) - Widgets.DrawHighlightIfMouseover(labelRect.MinX(labelRect.xMax - Text.CalcSize(label).x - 10)); - - if (tooltip != null) - TooltipHandler.TipRegion(labelRect, tooltip); - - rect.xMin += labelWidth; - Widgets.TextFieldNumeric(rect, ref val, ref buffer, min, max); - - return clickable && Widgets.ButtonInvisible(labelRect); - } - - public static void ClearWindowStack() - { - Find.WindowStack.windows.Clear(); - } - - public static void TryUnfocusCurrentNamedControl(Window window) - { - // Unfocus the input field after a click or ESC press - if ((Event.current.type == EventType.MouseDown || KeyBindingDefOf.Cancel.KeyDownEvent) && !GUI.GetNameOfFocusedControl().NullOrEmpty()) - { - Event.current.Use(); - UI.UnfocusCurrentControl(); - Find.WindowStack.Notify_ManuallySetFocus(window); - } - } - - public static void LabelWithTip(Rect rect, string str, string tip) - { - Widgets.Label(rect, str); - - if (Mouse.IsOver(rect) && tip != null) - TooltipHandler.TipRegion(rect, new TipSignal(tip, 3333)); - } - - public static void LabelTruncatedWithTip(Rect rect, string str, Dictionary cache) - { - var trunc = str.Truncate(rect.width, cache); - LabelWithTip(rect, trunc, trunc != str ? str : null); - } - - public static void CheckboxLabeledWithTip(Rect rect, string label, string tip, ref bool check, bool disabled = false) - { - Widgets.DrawHighlightIfMouseover(rect); - if (tip != null) - TooltipHandler.TipRegion(rect, tip); - CheckboxLabeled(rect, label, ref check, disabled); - } - - public static void CheckboxLabeledWithTipNoHighlight(Rect rect, string label, string tip, ref bool check, bool disabled = false, bool placeTextNearCheckbox = false) - { - if (tip != null) - TooltipHandler.TipRegion(rect, tip); - CheckboxLabeled(rect, label, ref check, disabled, order: placeTextNearCheckbox ? ElementOrder.Right : ElementOrder.Apart); - } - - public static bool ButtonTextWithTip(Rect rect, string label, string tip, bool disabled = false, int? tipId = null) - { - if (tip != null) - TooltipHandler.TipRegion(rect, new TipSignal(tip, tipId ?? tip.GetHashCode())); - - using (MpStyle.Set(TextAnchor.MiddleCenter)) - using (MpStyle.Set(disabled ? Color.grey : Color.white)) - { - var atlas = !disabled && Mouse.IsOver(rect) ? - (Input.GetMouseButton(0) ? Widgets.ButtonBGAtlasClick : Widgets.ButtonBGAtlasMouseover) : - Widgets.ButtonBGAtlas; - - Widgets.DrawAtlas(rect, atlas); - Widgets.Label(rect, label); - } - - var clicked = Widgets.ButtonInvisible(rect, false); - - if (!disabled) - { - MouseoverSounds.DoRegion(rect); - return clicked; - } - - if (clicked) - SoundDefOf.ClickReject.PlayOneShotOnCamera(); - - return false; - } - - public static float LabelFlexibleWidth(Rect rect, string label) - { - var size = Text.CalcSize(label); - rect.width = size.x; - Widgets.Label(rect, label); - return size.x; - } - - public static float LabelFlexibleWidthClickable(Rect rect, string label, ref bool clicked) - { - var size = Text.CalcSize(label); - rect.width = size.x; - Widgets.DrawHighlightIfMouseover(rect); - clicked = Widgets.ButtonInvisible(rect, false); - Widgets.Label(rect, label); - return size.x; - } - - const char PassChar = '\u2022'; - - public static void DoPasswordField(Rect rect, string controlName, ref string password) - { - password ??= ""; - - TextEditor te = - GUI.GetNameOfFocusedControl() == controlName ? - (TextEditor)GUIUtility.GetStateObject(typeof(TextEditor), GUIUtility.keyboardControl) : - null; - - Vector2 scrollOffset = default; - bool keyDown = Event.current.type == EventType.KeyDown; - if (te != null) - scrollOffset = te.scrollOffset; - - GUI.SetNextControlName(controlName); - - if (Event.current.type is EventType.Repaint or EventType.MouseDown) - GUI.TextField( - rect, - new string(PassChar, password.Length), - Text.textFieldStyles[1] - ); - else - password = GUI.TextField( - rect, - password, - Text.textFieldStyles[1] - ); - - if (te != null && keyDown) - { - te.scrollOffset = scrollOffset; +using System.Collections.Generic; +using HarmonyLib; +using RimWorld; +using UnityEngine; +using Verse; +using Verse.Sound; + +namespace Multiplayer.Client.Util +{ + public static class MpUI + { + public static Vector2 Resolution => new Vector2(UI.screenWidth, UI.screenHeight); + + public static string FixedEllipsis() + { + int num = Mathf.FloorToInt(Time.realtimeSinceStartup) % 3; + return num switch + { + 0 => ". ", + 1 => ".. ", + _ => "..." + }; + } + + public static void DrawRotatedLine(Vector2 center, float length, float width, float angle, Color color) + { + var size = new Vector2(length, width); + var start = center - size / 2f; + Rect screenRect = new Rect(start.x, start.y, length, width); + Matrix4x4 m = Matrix4x4.TRS(center, Quaternion.Euler(0f, 0f, angle), Vector3.one) * Matrix4x4.TRS(-center, Quaternion.identity, Vector3.one); + + GL.PushMatrix(); + GL.MultMatrix(m); + GUI.DrawTexture(screenRect, Widgets.LineTexAA, ScaleMode.StretchToFill, true, 0f, color, 0f, 0f); + GL.PopMatrix(); + } + + public static void Label(Rect rect, string label, GameFont? font = null, TextAnchor? anchor = null, Color? color = null) + { + var prevFont = Text.Font; + var prevAnchor = Text.Anchor; + var prevColor = GUI.color; + + if (font != null) + Text.Font = font.Value; + + if (anchor != null) + Text.Anchor = anchor.Value; + + if (color != null) + GUI.color = color.Value; + + Widgets.Label(rect, label); + + GUI.color = prevColor; + Text.Anchor = prevAnchor; + Text.Font = prevFont; + } + + public static void Checkbox(float x, float y, ref bool checkOn, bool disabled, float size) + { + if (!disabled && Widgets.ButtonInvisible(new Rect(x, y, size, size), false)) + { + checkOn = !checkOn; + if (checkOn) + SoundDefOf.Checkbox_TurnedOn.PlayOneShotOnCamera(null); + else + SoundDefOf.Checkbox_TurnedOff.PlayOneShotOnCamera(null); + } + + Widgets.CheckboxDraw(x, y, checkOn, disabled, size); + } + + public static Rect CheckboxLabeled(Rect rect, string label, ref bool checkOn, bool disabled = false, ElementOrder order = ElementOrder.Apart, float size = 24f) + { + TextAnchor anchor = Text.Anchor; + Text.Anchor = TextAnchor.MiddleLeft; + + if (order == ElementOrder.Right) + { + float textWidth = Text.CalcSize(label).x; + rect.x = rect.xMax - textWidth - size - 5f; + rect.width = textWidth + size + 5f; + } + else if (order == ElementOrder.Left) + { + float textWidth = Text.CalcSize(label).x; + rect.width = textWidth + size + 5f; + } + + Widgets.Label(rect, label); + + Checkbox( + rect.xMax - size, + rect.y + (rect.height - size) / 2, + ref checkOn, + disabled, + size); + + Text.Anchor = anchor; + + return rect; + } + + public static string TextEntryLabeled(Rect rect, string label, string text, float labelWidth) + { + Rect labelRect = rect.Rounded(); + labelRect.width = labelWidth; + + using (MpStyle.Set(TextAnchor.MiddleRight)) + Widgets.Label(labelRect, label); + + rect.xMin += labelWidth; + return Widgets.TextField(rect, text); + } + + public static bool TextFieldNumericLabeled(Rect rect, string label, ref T val, ref string buffer, float labelWidth, float min = 0, float max = float.MaxValue, bool clickable = false, string tooltip = null) where T : struct + { + Rect labelRect = rect; + labelRect.width = labelWidth; + + using (MpStyle.Set(TextAnchor.MiddleRight)) + using (MpStyle.Set(WordWrap.NoWrap)) + Widgets.Label(labelRect, label); + + if (clickable) + Widgets.DrawHighlightIfMouseover(labelRect.MinX(labelRect.xMax - Text.CalcSize(label).x - 10)); + + if (tooltip != null) + TooltipHandler.TipRegion(labelRect, tooltip); + + rect.xMin += labelWidth; + Widgets.TextFieldNumeric(rect, ref val, ref buffer, min, max); + + return clickable && Widgets.ButtonInvisible(labelRect); + } + + public static void ClearWindowStack() + { + Find.WindowStack.windows.Clear(); + } + + public static void TryUnfocusCurrentNamedControl(Window window) + { + // Unfocus the input field after a click or ESC press + if ((Event.current.type == EventType.MouseDown || KeyBindingDefOf.Cancel.KeyDownEvent) && !GUI.GetNameOfFocusedControl().NullOrEmpty()) + { + Event.current.Use(); + UI.UnfocusCurrentControl(); + Find.WindowStack.Notify_ManuallySetFocus(window); + } + } + + public static void LabelWithTip(Rect rect, string str, string tip) + { + Widgets.Label(rect, str); + + if (Mouse.IsOver(rect) && tip != null) + TooltipHandler.TipRegion(rect, new TipSignal(tip, 3333)); + } + + public static void LabelTruncatedWithTip(Rect rect, string str, Dictionary cache) + { + var trunc = str.Truncate(rect.width, cache); + LabelWithTip(rect, trunc, trunc != str ? str : null); + } + + public static void CheckboxLabeledWithTip(Rect rect, string label, string tip, ref bool check, bool disabled = false) + { + Widgets.DrawHighlightIfMouseover(rect); + if (tip != null) + TooltipHandler.TipRegion(rect, tip); + CheckboxLabeled(rect, label, ref check, disabled); + } + + public static void CheckboxLabeledWithTipNoHighlight(Rect rect, string label, string tip, ref bool check, bool disabled = false, bool placeTextNearCheckbox = false) + { + if (tip != null) + TooltipHandler.TipRegion(rect, tip); + CheckboxLabeled(rect, label, ref check, disabled, order: placeTextNearCheckbox ? ElementOrder.Right : ElementOrder.Apart); + } + + public static bool ButtonTextWithTip(Rect rect, string label, string tip, bool disabled = false, int? tipId = null) + { + if (tip != null) + TooltipHandler.TipRegion(rect, new TipSignal(tip, tipId ?? tip.GetHashCode())); + + using (MpStyle.Set(TextAnchor.MiddleCenter)) + using (MpStyle.Set(disabled ? Color.grey : Color.white)) + { + var atlas = !disabled && Mouse.IsOver(rect) ? + (Input.GetMouseButton(0) ? Widgets.ButtonBGAtlasClick : Widgets.ButtonBGAtlasMouseover) : + Widgets.ButtonBGAtlas; + + Widgets.DrawAtlas(rect, atlas); + Widgets.Label(rect, label); + } + + var clicked = Widgets.ButtonInvisible(rect, false); + + if (!disabled) + { + MouseoverSounds.DoRegion(rect); + return clicked; + } + + if (clicked) + SoundDefOf.ClickReject.PlayOneShotOnCamera(); + + return false; + } + + public static float LabelFlexibleWidth(Rect rect, string label) + { + var size = Text.CalcSize(label); + rect.width = size.x; + Widgets.Label(rect, label); + return size.x; + } + + public static float LabelFlexibleWidthClickable(Rect rect, string label, ref bool clicked) + { + var size = Text.CalcSize(label); + rect.width = size.x; + Widgets.DrawHighlightIfMouseover(rect); + clicked = Widgets.ButtonInvisible(rect, false); + Widgets.Label(rect, label); + return size.x; + } + + const char PassChar = '\u2022'; + + public static void DoPasswordField(Rect rect, string controlName, ref string password) + { + password ??= ""; + + TextEditor te = + GUI.GetNameOfFocusedControl() == controlName ? + (TextEditor)GUIUtility.GetStateObject(typeof(TextEditor), GUIUtility.keyboardControl) : + null; + + Vector2 scrollOffset = default; + bool keyDown = Event.current.type == EventType.KeyDown; + if (te != null) + scrollOffset = te.scrollOffset; + + GUI.SetNextControlName(controlName); + + if (Event.current.type is EventType.Repaint or EventType.MouseDown) + GUI.TextField( + rect, + new string(PassChar, password.Length), + Text.textFieldStyles[1] + ); + else + password = GUI.TextField( + rect, + password, + Text.textFieldStyles[1] + ); + + if (te != null && keyDown) + { + te.scrollOffset = scrollOffset; te.text = new string(PassChar, password.Length); +#if !RIMWORLD_1_7 AccessTools.Field(typeof(TextEditor), "m_RevealCursor").SetValue(te, true); te.UpdateScrollOffsetIfNeeded(new Event()); - } - } - } - - public enum ElementOrder - { - Apart, Left, Right - } -} +#endif + } + } + } + + public enum ElementOrder + { + Apart, Left, Right + } +} diff --git a/Source/Client/Util/MpUtil.cs b/Source/Client/Util/MpUtil.cs index 13264989b..ae7ee5f4b 100644 --- a/Source/Client/Util/MpUtil.cs +++ b/Source/Client/Util/MpUtil.cs @@ -1,203 +1,206 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Sockets; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.Serialization; -using System.Text; -using HarmonyLib; -using Verse; - -namespace Multiplayer.Client -{ - public static class MpUtil - { +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.Serialization; +using System.Text; +using HarmonyLib; +using Verse; + +namespace Multiplayer.Client +{ + public static class MpUtil + { public static unsafe void MarkNoInlining(MethodBase method) { + // This offset is a MonoMethod layout, not a CoreCLR MethodDesc. + // Harmony handles detouring on CoreCLR; writing here corrupts memory. + if (!Native.IsMonoRuntime) return; ushort* iflags = (ushort*)(method.MethodHandle.Value) + 1; - *iflags |= (ushort)MethodImplOptions.NoInlining; - } - - public static object NewObjectNoCtor(Type type) - { - return FormatterServices.GetUninitializedObject(type); - } - - public static T NewObjectNoCtor() - { - return (T)NewObjectNoCtor(typeof(T)); - } - - private static bool ipv6; - private static bool ipv6Checked; - - // Socket.OSSupportsIPv6 doesn't seem to work - public static bool SupportsIPv6() - { - if (!ipv6Checked) - { - try - { - new Socket(AddressFamily.InterNetworkV6, SocketType.Dgram, ProtocolType.Udp); - ipv6 = true; - } - catch - { - ipv6 = false; - } - - ipv6Checked = true; - } - - return ipv6; - } - - public static string RwDataFile(string filename) - { - return Path.Combine(GenFilePaths.SaveDataFolderPath, filename); - } - - public static To ShallowCopy(From from, To to) where To : From - { - foreach (var f in AccessTools.GetDeclaredFields(typeof(From))) - if (!f.IsStatic) - f.SetValue(to, f.GetValue(from)); - - return to; - } - - public static string TranslateWithDoubleNewLines(string keyBase, int count) - { - return Enumerable.Range(1, count).Select(n => (keyBase + n).Translate()).Join(delimiter: "\n\n"); - } - - public static string DelegateMethodInfo(MethodBase m) - { - return - m == null - ? "No method" - : $"{m.DeclaringType.DeclaringType?.FullDescription()} {m.DeclaringType.FullDescription()} {m.Name}" - .Replace("<", "[").Replace(">", "]"); - } - - public static MethodBase GetOriginalFromHarmonyReplacement(long replacementAddr) - { - // Todo: this is using a non-public API of Harmony, we should refactor to use https://harmony.pardeike.net/api/HarmonyLib.Harmony.html#HarmonyLib_Harmony_GetOriginalMethod_System_Reflection_MethodInfo_ - // as pardeike suggested in https://github.com/rwmt/Multiplayer/pull/270#issuecomment-1003298289 - return HarmonySharedState.originals - .FirstOrDefault(kv => kv.Key.MethodHandle.GetFunctionPointer().ToInt64() == replacementAddr).Value; - } - - public static string JoinStringsAtMost(this IEnumerable strs, int atMost = 3) - { - return strs.Take(atMost).Join() + (strs.Count() > atMost ? "..." : ""); - } - } - - public struct Container - { - public T Inner { get; } - - public Container(T value) - { - Inner = value; - } - - public static implicit operator Container(T value) - { - return new Container(value); - } - } - - public class UniqueList : IEnumerable - { - private List list = new List(); - private HashSet set = new HashSet(); - - public int Count => list.Count; - public T this[int index] => list[index]; - - public bool Add(T t) - { - if (set.Add(t)) - { - list.Add(t); - return true; - } - - return false; - } - - public T[] ToArray() - { - return list.ToArray(); - } - - public bool Contains(T t) - { - return set.Contains(t); - } - - public int IndexOf(T t) - { - return list.IndexOf(t); - } - - public IEnumerator GetEnumerator() - { - return list.GetEnumerator(); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return list.GetEnumerator(); - } - } - - public class IdentityComparer : IEqualityComparer - { - public static IdentityComparer Instance = new(); - - public bool Equals(T x, T y) - { - return object.Equals(x, y); - } - - public int GetHashCode(T obj) - { - return RuntimeHelpers.GetHashCode(obj); - } - } - - public sealed class Utf8StringWriter : StringWriter - { - public override Encoding Encoding => Encoding.UTF8; - } - - public class FixedSizeQueue : IEnumerable - { - private Queue q = new Queue(); - - public int Limit { get; set; } - - public void Enqueue(T obj) - { - q.Enqueue(obj); - while (q.Count > Limit) q.Dequeue(); - } - - public IEnumerator GetEnumerator() - { - return q.GetEnumerator(); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return q.GetEnumerator(); - } - } -} + *iflags |= (ushort)MethodImplOptions.NoInlining; + } + + public static object NewObjectNoCtor(Type type) + { + return FormatterServices.GetUninitializedObject(type); + } + + public static T NewObjectNoCtor() + { + return (T)NewObjectNoCtor(typeof(T)); + } + + private static bool ipv6; + private static bool ipv6Checked; + + // Socket.OSSupportsIPv6 doesn't seem to work + public static bool SupportsIPv6() + { + if (!ipv6Checked) + { + try + { + new Socket(AddressFamily.InterNetworkV6, SocketType.Dgram, ProtocolType.Udp); + ipv6 = true; + } + catch + { + ipv6 = false; + } + + ipv6Checked = true; + } + + return ipv6; + } + + public static string RwDataFile(string filename) + { + return Path.Combine(GenFilePaths.SaveDataFolderPath, filename); + } + + public static To ShallowCopy(From from, To to) where To : From + { + foreach (var f in AccessTools.GetDeclaredFields(typeof(From))) + if (!f.IsStatic) + f.SetValue(to, f.GetValue(from)); + + return to; + } + + public static string TranslateWithDoubleNewLines(string keyBase, int count) + { + return Enumerable.Range(1, count).Select(n => (keyBase + n).Translate()).Join(delimiter: "\n\n"); + } + + public static string DelegateMethodInfo(MethodBase m) + { + return + m == null + ? "No method" + : $"{m.DeclaringType.DeclaringType?.FullDescription()} {m.DeclaringType.FullDescription()} {m.Name}" + .Replace("<", "[").Replace(">", "]"); + } + + public static MethodBase GetOriginalFromHarmonyReplacement(long replacementAddr) + { + // Todo: this is using a non-public API of Harmony, we should refactor to use https://harmony.pardeike.net/api/HarmonyLib.Harmony.html#HarmonyLib_Harmony_GetOriginalMethod_System_Reflection_MethodInfo_ + // as pardeike suggested in https://github.com/rwmt/Multiplayer/pull/270#issuecomment-1003298289 + return HarmonySharedState.originals + .FirstOrDefault(kv => kv.Key.MethodHandle.GetFunctionPointer().ToInt64() == replacementAddr).Value; + } + + public static string JoinStringsAtMost(this IEnumerable strs, int atMost = 3) + { + return strs.Take(atMost).Join() + (strs.Count() > atMost ? "..." : ""); + } + } + + public struct Container + { + public T Inner { get; } + + public Container(T value) + { + Inner = value; + } + + public static implicit operator Container(T value) + { + return new Container(value); + } + } + + public class UniqueList : IEnumerable + { + private List list = new List(); + private HashSet set = new HashSet(); + + public int Count => list.Count; + public T this[int index] => list[index]; + + public bool Add(T t) + { + if (set.Add(t)) + { + list.Add(t); + return true; + } + + return false; + } + + public T[] ToArray() + { + return list.ToArray(); + } + + public bool Contains(T t) + { + return set.Contains(t); + } + + public int IndexOf(T t) + { + return list.IndexOf(t); + } + + public IEnumerator GetEnumerator() + { + return list.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return list.GetEnumerator(); + } + } + + public class IdentityComparer : IEqualityComparer + { + public static IdentityComparer Instance = new(); + + public bool Equals(T x, T y) + { + return object.Equals(x, y); + } + + public int GetHashCode(T obj) + { + return RuntimeHelpers.GetHashCode(obj); + } + } + + public sealed class Utf8StringWriter : StringWriter + { + public override Encoding Encoding => Encoding.UTF8; + } + + public class FixedSizeQueue : IEnumerable + { + private Queue q = new Queue(); + + public int Limit { get; set; } + + public void Enqueue(T obj) + { + q.Enqueue(obj); + while (q.Count > Limit) q.Dequeue(); + } + + public IEnumerator GetEnumerator() + { + return q.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return q.GetEnumerator(); + } + } +} diff --git a/Source/Common/Common.csproj b/Source/Common/Common.csproj index 9e94cb170..2f20117b2 100644 --- a/Source/Common/Common.csproj +++ b/Source/Common/Common.csproj @@ -1,42 +1,43 @@ - - - - net48 - true - enable - 12 - false - false - Multiplayer.Common - MultiplayerCommon - - - - - - - - - - - - - - - - - - - - - - - - - - $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)'))=$(AssemblyName)/ - - - - - + + + + net48 + net10.0 + true + enable + 12 + false + false + Multiplayer.Common + MultiplayerCommon + + + + + + + + + + + + + + + + + + + + + + + + + + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)'))=$(AssemblyName)/ + + + + + diff --git a/Source/Common/Native.cs b/Source/Common/Native.cs index 12abbd423..8b64b7b52 100644 --- a/Source/Common/Native.cs +++ b/Source/Common/Native.cs @@ -1,269 +1,275 @@ -using System; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using System.Threading; -using HarmonyLib; - -namespace Multiplayer.Client -{ +using System; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading; +using HarmonyLib; + +namespace Multiplayer.Client +{ public static class Native { - public enum NativeOS - { - Windows, OSX, Linux, Dummy - } - - public static IntPtr DomainPtr { get; private set; } - - // LMF is Last Managed Frame - // current_thread->internal_thread->thread_info->tls[4] - public static long LmfPtr { get; private set; } - - public static Func? HarmonyOriginalGetter { get; set; } - + // Waylith uses CoreCLR. Never load Mono libraries or dereference Mono + // thread structures on that runtime, even when running old mod assemblies. + public static bool IsMonoRuntime => Type.GetType("Mono.Runtime") != null; + + public enum NativeOS + { + Windows, OSX, Linux, Dummy + } + + public static IntPtr DomainPtr { get; private set; } + + // LMF is Last Managed Frame + // current_thread->internal_thread->thread_info->tls[4] + public static long LmfPtr { get; private set; } + + public static Func? HarmonyOriginalGetter { get; set; } + public static void EarlyInit(NativeOS os) { - if (os == NativeOS.Linux) - TheLinuxWay(); - if (os == NativeOS.OSX) - TheOSXWay(); - - EarlyInitInternal(); - } - - [MethodImpl(MethodImplOptions.NoInlining)] - static void TheLinuxWay() => mono_dllmap_insert_linux(IntPtr.Zero, MonoWindows, null, MonoLinux, null); - - [MethodImpl(MethodImplOptions.NoInlining)] - static void TheOSXWay() => mono_dllmap_insert_osx(IntPtr.Zero, MonoWindows, null, MonoOSX, null); - - [MethodImpl(MethodImplOptions.NoInlining)] - private static void EarlyInitInternal() - { - DomainPtr = mono_domain_get(); - } - + if (!IsMonoRuntime) return; + if (os == NativeOS.Linux) + TheLinuxWay(); + if (os == NativeOS.OSX) + TheOSXWay(); + + EarlyInitInternal(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static void TheLinuxWay() => mono_dllmap_insert_linux(IntPtr.Zero, MonoWindows, null, MonoLinux, null); + + [MethodImpl(MethodImplOptions.NoInlining)] + static void TheOSXWay() => mono_dllmap_insert_osx(IntPtr.Zero, MonoWindows, null, MonoOSX, null); + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void EarlyInitInternal() + { + DomainPtr = mono_domain_get(); + } + public static unsafe void InitLmfPtr(NativeOS os) { - // Don't bother on 32 bit runtimes - if (IntPtr.Size == 4) - return; - - const BindingFlags all = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | - BindingFlags.NonPublic; - - var internalThreadField = typeof(Thread).GetField("internal_thread", all); - var threadInfoField = internalThreadField.FieldType.GetField("runtime_thread_info", all); - var threadInfoPtr = (long)(IntPtr)threadInfoField.GetValue(internalThreadField.GetValue(Thread.CurrentThread)); - - // Struct offset found manually - // Navigate by string: "Handle Stack" - // Updated for RimWorld 1.6 (Unity 2022.3.35f1, Mono 6.13.0) - if (os == NativeOS.Linux) - LmfPtr = threadInfoPtr + 0x450; // Updated: 1.5 was 0x460, -16 bytes = 0x450 (following Windows pattern) - else if (os == NativeOS.Windows) - LmfPtr = threadInfoPtr + 0x418; // Updated for 1.6. Seems to work so far. - else if (os == NativeOS.OSX) - LmfPtr = threadInfoPtr + 0x3E8; // Updated: 1.5 was 0x3F8, -16 bytes = 0x3E8 (following Windows pattern) - else if (os == NativeOS.Dummy) - { - LmfPtr = (long)Marshal.AllocHGlobal(3 * 8); - *(long*)LmfPtr = LmfPtr; - *(long*)(LmfPtr + 8) = 0; - } - } - - private static IntPtr MaybeFindHarmonyOriginalMethod(long addr, bool harmonyOriginals) - { - var ji = mono_jit_info_table_find(DomainPtr, (IntPtr)addr); - if (ji == IntPtr.Zero) return IntPtr.Zero; - - var ptr = mono_jit_info_get_method(ji); - var codeStart = (long)mono_jit_info_get_code_start(ji); - - if (harmonyOriginals && HarmonyOriginalGetter != null) - { - var original = HarmonyOriginalGetter(codeStart); - if (original != null) - ptr = original.MethodHandle.Value; - } - - return ptr; - } - - public static string? MethodNameFromAddr(long addr, bool harmonyOriginals) - { - var ptr = MaybeFindHarmonyOriginalMethod(addr, harmonyOriginals); - if (ptr == IntPtr.Zero) return null; - var name = mono_debug_print_stack_frame(ptr, -1, DomainPtr); - return string.IsNullOrEmpty(name) ? null : name; - } - - public static string? MethodNameNormalizedFromAddr(long addr, bool harmonyOriginals) - { - var ptr = MaybeFindHarmonyOriginalMethod(addr, harmonyOriginals); - return ptr == IntPtr.Zero ? null : mono_method_get_reflection_name(ptr); - } - - private static ConstructorInfo runtimeMethodHandleCtor = AccessTools.Constructor(typeof(RuntimeMethodHandle), [typeof(IntPtr)]); - - public static bool GetMethodAggressiveInlining(long addr) - { - var domain = DomainPtr; - var ji = mono_jit_info_table_find(domain, (IntPtr)addr); - - if (ji == IntPtr.Zero) return false; - - var methodHandle = mono_jit_info_get_method(ji); - var methodBase = GetMethodBaseFromRuntimePointer(methodHandle); - - if (methodBase == null) return false; - - return (methodBase.MethodImplementationFlags & MethodImplAttributes.AggressiveInlining) != 0; - } - - public static MethodBase? GetMethodBaseFromRuntimePointer(IntPtr methodHandle) - { - if (methodHandle == IntPtr.Zero) - return null; - var rmh = (RuntimeMethodHandle)runtimeMethodHandleCtor.Invoke([methodHandle]); - var rth = new RuntimeTypeHandle(); - var methodBase = MethodBase.GetMethodFromHandle(rmh, rth); - return methodBase; - } - - // const string MonoWindows = "mono-2.0-sgen.dll"; - const string MonoWindows = "mono-2.0-bdwgc"; - const string MonoLinux = "libmonobdwgc-2.0.so"; - const string MonoOSX = "libmonobdwgc-2.0.dylib"; - - [DllImport(MonoLinux, EntryPoint = "mono_dllmap_insert")] - private static extern void mono_dllmap_insert_linux(IntPtr assembly, string? dll, string? func, string? tdll, string? tfunc); - - [DllImport(MonoOSX, EntryPoint = "mono_dllmap_insert")] - private static extern void mono_dllmap_insert_osx(IntPtr assembly, string? dll, string? func, string? tdll, string? tfunc); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_jit_info_table_find(IntPtr domain, IntPtr addr); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_jit_info_get_method(IntPtr ji); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_jit_info_get_code_start(IntPtr ji); - - [DllImport(MonoWindows)] - public static extern int mono_jit_info_get_code_size(IntPtr ji); - - [DllImport(MonoWindows)] - public static extern string mono_debug_print_stack_frame(IntPtr method, int nativeOffset, IntPtr domain); - - [DllImport(MonoWindows)] - public static extern int mini_parse_debug_option(string option); - - [DllImport(MonoWindows)] - public static extern void mono_set_defaults(int verboseLevel, int opts); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_domain_get(); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_compile_method(IntPtr method); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_class_from_mono_type(IntPtr type); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_class_vtable(IntPtr domain, IntPtr klass); - - [DllImport(MonoWindows)] - public static extern int mono_method_get_token(IntPtr method); - - [DllImport(MonoWindows)] - public static extern string mono_method_get_reflection_name(IntPtr method); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_method_get_class(IntPtr method); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_class_get_image(IntPtr klass); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_profiler_create(IntPtr profiler); - - public delegate void JitDoneCallback(IntPtr profiler, IntPtr method, IntPtr jinfo); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_profiler_set_jit_done_callback(IntPtr profiler, JitDoneCallback? cb); - - public static unsafe bool CctorRan(Type t) - { - return *((byte*)mono_class_vtable(mono_domain_get(), mono_class_from_mono_type(t.TypeHandle.Value)) + 45) != 0; - } - } - - /*public static class IcedDisasm - { - public unsafe static void DisasmMethod(long codeStart) - { - DisasmMethod(codeStart, Native.mono_jit_info_get_code_size(Native.mono_jit_info_table_find(Native.DomainPtr, (IntPtr)codeStart))); - } - - public unsafe static void DisasmMethod(long codeStart, long codeSize) - { - //Log.Message($"code {Convert.ToString(*(uint*)codeStart, 16)}"); - //return; - - var codeRip = (ulong)codeStart; - var codeReader = new UnsafeCodeReader() { start = (byte*)codeStart }; - var decoder = Decoder.Create(64, codeReader); - decoder.IP = codeRip; - ulong endRip = decoder.IP + (uint)codeSize + 8; - - var instructions = new InstructionList(); - while (decoder.IP < endRip) - decoder.Decode(out instructions.AllocUninitializedElement()); - - var formatter = new NasmFormatter(); - formatter.Options.DigitSeparator = "`"; - formatter.Options.FirstOperandCharIndex = 10; - var output = new StringOutput(); - - bool cond = (*(uint*)codeStart & 0xFFFFFF) != 0xEC8348 && *(byte*)codeStart != 0x55; - - Log.Message("Code size: " + codeSize, true); - - foreach (ref var instr in instructions) - { - var stringb = new StringBuilder(); - formatter.Format(instr, output); - stringb.Append(instr.IP.ToString("X16")); - stringb.Append(" "); - int instrLen = instr.Length; - int byteBaseIndex = (int)(instr.IP - codeRip); - for (int i = 0; i < instrLen; i++) - stringb.Append(((byte*)codeStart)[byteBaseIndex + i].ToString("X2")); - int missingBytes = 10 - instrLen; - for (int i = 0; i < missingBytes; i++) - stringb.Append(" "); - stringb.Append(" "); - stringb.Append(output.ToStringAndReset()); - //if (cond) - Log.Message(stringb.ToString(), ignoreStopLoggingLimit: true); - } - } - - public unsafe class UnsafeCodeReader : CodeReader - { - public byte* start; - - public unsafe override int ReadByte() - { - var val = *start; - start++; - return val; - } - } - }*/ -} + if (!IsMonoRuntime) return; + // Don't bother on 32 bit runtimes + if (IntPtr.Size == 4) + return; + + const BindingFlags all = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | + BindingFlags.NonPublic; + + var internalThreadField = typeof(Thread).GetField("internal_thread", all); + var threadInfoField = internalThreadField.FieldType.GetField("runtime_thread_info", all); + var threadInfoPtr = (long)(IntPtr)threadInfoField.GetValue(internalThreadField.GetValue(Thread.CurrentThread)); + + // Struct offset found manually + // Navigate by string: "Handle Stack" + // Updated for RimWorld 1.6 (Unity 2022.3.35f1, Mono 6.13.0) + if (os == NativeOS.Linux) + LmfPtr = threadInfoPtr + 0x450; // Updated: 1.5 was 0x460, -16 bytes = 0x450 (following Windows pattern) + else if (os == NativeOS.Windows) + LmfPtr = threadInfoPtr + 0x418; // Updated for 1.6. Seems to work so far. + else if (os == NativeOS.OSX) + LmfPtr = threadInfoPtr + 0x3E8; // Updated: 1.5 was 0x3F8, -16 bytes = 0x3E8 (following Windows pattern) + else if (os == NativeOS.Dummy) + { + LmfPtr = (long)Marshal.AllocHGlobal(3 * 8); + *(long*)LmfPtr = LmfPtr; + *(long*)(LmfPtr + 8) = 0; + } + } + + private static IntPtr MaybeFindHarmonyOriginalMethod(long addr, bool harmonyOriginals) + { + var ji = mono_jit_info_table_find(DomainPtr, (IntPtr)addr); + if (ji == IntPtr.Zero) return IntPtr.Zero; + + var ptr = mono_jit_info_get_method(ji); + var codeStart = (long)mono_jit_info_get_code_start(ji); + + if (harmonyOriginals && HarmonyOriginalGetter != null) + { + var original = HarmonyOriginalGetter(codeStart); + if (original != null) + ptr = original.MethodHandle.Value; + } + + return ptr; + } + + public static string? MethodNameFromAddr(long addr, bool harmonyOriginals) + { + var ptr = MaybeFindHarmonyOriginalMethod(addr, harmonyOriginals); + if (ptr == IntPtr.Zero) return null; + var name = mono_debug_print_stack_frame(ptr, -1, DomainPtr); + return string.IsNullOrEmpty(name) ? null : name; + } + + public static string? MethodNameNormalizedFromAddr(long addr, bool harmonyOriginals) + { + var ptr = MaybeFindHarmonyOriginalMethod(addr, harmonyOriginals); + return ptr == IntPtr.Zero ? null : mono_method_get_reflection_name(ptr); + } + + private static ConstructorInfo runtimeMethodHandleCtor = AccessTools.Constructor(typeof(RuntimeMethodHandle), [typeof(IntPtr)]); + + public static bool GetMethodAggressiveInlining(long addr) + { + var domain = DomainPtr; + var ji = mono_jit_info_table_find(domain, (IntPtr)addr); + + if (ji == IntPtr.Zero) return false; + + var methodHandle = mono_jit_info_get_method(ji); + var methodBase = GetMethodBaseFromRuntimePointer(methodHandle); + + if (methodBase == null) return false; + + return (methodBase.MethodImplementationFlags & MethodImplAttributes.AggressiveInlining) != 0; + } + + public static MethodBase? GetMethodBaseFromRuntimePointer(IntPtr methodHandle) + { + if (methodHandle == IntPtr.Zero) + return null; + var rmh = (RuntimeMethodHandle)runtimeMethodHandleCtor.Invoke([methodHandle]); + var rth = new RuntimeTypeHandle(); + var methodBase = MethodBase.GetMethodFromHandle(rmh, rth); + return methodBase; + } + + // const string MonoWindows = "mono-2.0-sgen.dll"; + const string MonoWindows = "mono-2.0-bdwgc"; + const string MonoLinux = "libmonobdwgc-2.0.so"; + const string MonoOSX = "libmonobdwgc-2.0.dylib"; + + [DllImport(MonoLinux, EntryPoint = "mono_dllmap_insert")] + private static extern void mono_dllmap_insert_linux(IntPtr assembly, string? dll, string? func, string? tdll, string? tfunc); + + [DllImport(MonoOSX, EntryPoint = "mono_dllmap_insert")] + private static extern void mono_dllmap_insert_osx(IntPtr assembly, string? dll, string? func, string? tdll, string? tfunc); + + [DllImport(MonoWindows)] + public static extern IntPtr mono_jit_info_table_find(IntPtr domain, IntPtr addr); + + [DllImport(MonoWindows)] + public static extern IntPtr mono_jit_info_get_method(IntPtr ji); + + [DllImport(MonoWindows)] + public static extern IntPtr mono_jit_info_get_code_start(IntPtr ji); + + [DllImport(MonoWindows)] + public static extern int mono_jit_info_get_code_size(IntPtr ji); + + [DllImport(MonoWindows)] + public static extern string mono_debug_print_stack_frame(IntPtr method, int nativeOffset, IntPtr domain); + + [DllImport(MonoWindows)] + public static extern int mini_parse_debug_option(string option); + + [DllImport(MonoWindows)] + public static extern void mono_set_defaults(int verboseLevel, int opts); + + [DllImport(MonoWindows)] + public static extern IntPtr mono_domain_get(); + + [DllImport(MonoWindows)] + public static extern IntPtr mono_compile_method(IntPtr method); + + [DllImport(MonoWindows)] + public static extern IntPtr mono_class_from_mono_type(IntPtr type); + + [DllImport(MonoWindows)] + public static extern IntPtr mono_class_vtable(IntPtr domain, IntPtr klass); + + [DllImport(MonoWindows)] + public static extern int mono_method_get_token(IntPtr method); + + [DllImport(MonoWindows)] + public static extern string mono_method_get_reflection_name(IntPtr method); + + [DllImport(MonoWindows)] + public static extern IntPtr mono_method_get_class(IntPtr method); + + [DllImport(MonoWindows)] + public static extern IntPtr mono_class_get_image(IntPtr klass); + + [DllImport(MonoWindows)] + public static extern IntPtr mono_profiler_create(IntPtr profiler); + + public delegate void JitDoneCallback(IntPtr profiler, IntPtr method, IntPtr jinfo); + + [DllImport(MonoWindows)] + public static extern IntPtr mono_profiler_set_jit_done_callback(IntPtr profiler, JitDoneCallback? cb); + + public static unsafe bool CctorRan(Type t) + { + return *((byte*)mono_class_vtable(mono_domain_get(), mono_class_from_mono_type(t.TypeHandle.Value)) + 45) != 0; + } + } + + /*public static class IcedDisasm + { + public unsafe static void DisasmMethod(long codeStart) + { + DisasmMethod(codeStart, Native.mono_jit_info_get_code_size(Native.mono_jit_info_table_find(Native.DomainPtr, (IntPtr)codeStart))); + } + + public unsafe static void DisasmMethod(long codeStart, long codeSize) + { + //Log.Message($"code {Convert.ToString(*(uint*)codeStart, 16)}"); + //return; + + var codeRip = (ulong)codeStart; + var codeReader = new UnsafeCodeReader() { start = (byte*)codeStart }; + var decoder = Decoder.Create(64, codeReader); + decoder.IP = codeRip; + ulong endRip = decoder.IP + (uint)codeSize + 8; + + var instructions = new InstructionList(); + while (decoder.IP < endRip) + decoder.Decode(out instructions.AllocUninitializedElement()); + + var formatter = new NasmFormatter(); + formatter.Options.DigitSeparator = "`"; + formatter.Options.FirstOperandCharIndex = 10; + var output = new StringOutput(); + + bool cond = (*(uint*)codeStart & 0xFFFFFF) != 0xEC8348 && *(byte*)codeStart != 0x55; + + Log.Message("Code size: " + codeSize, true); + + foreach (ref var instr in instructions) + { + var stringb = new StringBuilder(); + formatter.Format(instr, output); + stringb.Append(instr.IP.ToString("X16")); + stringb.Append(" "); + int instrLen = instr.Length; + int byteBaseIndex = (int)(instr.IP - codeRip); + for (int i = 0; i < instrLen; i++) + stringb.Append(((byte*)codeStart)[byteBaseIndex + i].ToString("X2")); + int missingBytes = 10 - instrLen; + for (int i = 0; i < missingBytes; i++) + stringb.Append(" "); + stringb.Append(" "); + stringb.Append(output.ToStringAndReset()); + //if (cond) + Log.Message(stringb.ToString(), ignoreStopLoggingLimit: true); + } + } + + public unsafe class UnsafeCodeReader : CodeReader + { + public byte* start; + + public unsafe override int ReadByte() + { + var val = *start; + start++; + return val; + } + } + }*/ +} diff --git a/Source/Directory.Build.targets b/Source/Directory.Build.targets new file mode 100644 index 000000000..91306404b --- /dev/null +++ b/Source/Directory.Build.targets @@ -0,0 +1,26 @@ + + + + net10.0 + $(DefineConstants);RIMWORLD_1_7 + true + + + + + + + + false + + + + + + + + + + + + diff --git a/Source/MultiplayerLoader/MultiplayerLoader.csproj b/Source/MultiplayerLoader/MultiplayerLoader.csproj index 018083c71..cd008eaea 100644 --- a/Source/MultiplayerLoader/MultiplayerLoader.csproj +++ b/Source/MultiplayerLoader/MultiplayerLoader.csproj @@ -1,37 +1,38 @@ - - - - net48 - true - 12 - false - false - MultiplayerLoader - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - - - $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)'))=$(AssemblyName)/ - - + + + + net48 + net10.0 + true + 12 + false + false + MultiplayerLoader + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)'))=$(AssemblyName)/ + + diff --git a/Source/MultiplayerLoader/Prepatches.cs b/Source/MultiplayerLoader/Prepatches.cs index 3261b5497..a42334c42 100644 --- a/Source/MultiplayerLoader/Prepatches.cs +++ b/Source/MultiplayerLoader/Prepatches.cs @@ -1,83 +1,85 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using Mono.Cecil; -using Mono.Cecil.Cil; -using MonoMod.Utils; -using Multiplayer.Common; -using Prepatcher; -using RimWorld; - -namespace MultiplayerLoader; - -public static class Prepatches -{ - [FreePatch] - static void DontSetResolution(ModuleDefinition module) - { - if (MpVersion.IsDebug) - { - var resolutionUtilityType = module.ImportReference(typeof(ResolutionUtility)).Resolve(); - resolutionUtilityType.FindMethod(nameof(ResolutionUtility.SetResolutionRaw))!.Body.Instructions.Insert( - 0, - Instruction.Create(OpCodes.Ret) - ); - } - } - +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Mono.Cecil; +using Mono.Cecil.Cil; +using MonoMod.Utils; +using Multiplayer.Common; +using Prepatcher; +using RimWorld; + +namespace MultiplayerLoader; + +public static class Prepatches +{ + [FreePatch] + static void DontSetResolution(ModuleDefinition module) + { + if (MpVersion.IsDebug) + { + var resolutionUtilityType = module.ImportReference(typeof(ResolutionUtility)).Resolve(); + resolutionUtilityType.FindMethod(nameof(ResolutionUtility.SetResolutionRaw))!.Body.Instructions.Insert( + 0, + Instruction.Create(OpCodes.Ret) + ); + } + } + [FreePatch] static void ApplySyncPrepatches(ModuleDefinition module) { +#if !RIMWORLD_1_7 SyncRituals.ApplyPrepatches(module); - } - - internal static void AddPrepatcherPrefix(ModuleDefinition module, int id, MethodInfo target, MethodInfo prefix) - { - var targetMethod = module.ImportReference(target).Resolve(); - var firstInst = targetMethod.Body.Instructions.First(); - - IEnumerable PrefixInstructions() - { - yield return Instruction.Create(OpCodes.Ldc_I4, id); // Load index - yield return Instruction.Create(OpCodes.Ldarg_0); // Load __instance - - // Create __args array - yield return Instruction.Create(OpCodes.Ldc_I4, target.GetParameters().Length); - yield return Instruction.Create(OpCodes.Newarr, module.TypeSystem.Object); - - int idx = 0; - foreach (var pInfo in target.GetParameters()) - { - var pType = pInfo.ParameterType; - - yield return Instruction.Create(OpCodes.Dup); - yield return Instruction.Create(OpCodes.Ldc_I4, idx); - yield return Instruction.Create(OpCodes.Ldarg, targetMethod.Parameters.ElementAt(idx)); - - if (pType.IsByRef) - throw new Exception("Byrefs aren't handled"); - if (pType.IsValueType) - yield return Instruction.Create(OpCodes.Box, module.ImportReference(pType)); - - yield return Instruction.Create(OpCodes.Stelem_Ref); - - idx++; - } - - yield return Instruction.Create(OpCodes.Call, module.ImportReference(prefix)); - yield return Instruction.Create(OpCodes.Brtrue, firstInst); - - if (target.ReturnType == typeof(bool)) - yield return Instruction.Create(OpCodes.Ldc_I4_0); - else if (target.ReturnType == typeof(void)) - yield return Instruction.Create(OpCodes.Nop); - else - throw new Exception($"Can't handle return type {target.ReturnType}"); - - yield return Instruction.Create(OpCodes.Ret); - } - - targetMethod.Body.Instructions.InsertRange(0, PrefixInstructions()); - } -} +#endif + } + + internal static void AddPrepatcherPrefix(ModuleDefinition module, int id, MethodInfo target, MethodInfo prefix) + { + var targetMethod = module.ImportReference(target).Resolve(); + var firstInst = targetMethod.Body.Instructions.First(); + + IEnumerable PrefixInstructions() + { + yield return Instruction.Create(OpCodes.Ldc_I4, id); // Load index + yield return Instruction.Create(OpCodes.Ldarg_0); // Load __instance + + // Create __args array + yield return Instruction.Create(OpCodes.Ldc_I4, target.GetParameters().Length); + yield return Instruction.Create(OpCodes.Newarr, module.TypeSystem.Object); + + int idx = 0; + foreach (var pInfo in target.GetParameters()) + { + var pType = pInfo.ParameterType; + + yield return Instruction.Create(OpCodes.Dup); + yield return Instruction.Create(OpCodes.Ldc_I4, idx); + yield return Instruction.Create(OpCodes.Ldarg, targetMethod.Parameters.ElementAt(idx)); + + if (pType.IsByRef) + throw new Exception("Byrefs aren't handled"); + if (pType.IsValueType) + yield return Instruction.Create(OpCodes.Box, module.ImportReference(pType)); + + yield return Instruction.Create(OpCodes.Stelem_Ref); + + idx++; + } + + yield return Instruction.Create(OpCodes.Call, module.ImportReference(prefix)); + yield return Instruction.Create(OpCodes.Brtrue, firstInst); + + if (target.ReturnType == typeof(bool)) + yield return Instruction.Create(OpCodes.Ldc_I4_0); + else if (target.ReturnType == typeof(void)) + yield return Instruction.Create(OpCodes.Nop); + else + throw new Exception($"Can't handle return type {target.ReturnType}"); + + yield return Instruction.Create(OpCodes.Ret); + } + + targetMethod.Body.Instructions.InsertRange(0, PrefixInstructions()); + } +} diff --git a/Source/MultiplayerLoader/SyncRituals.cs b/Source/MultiplayerLoader/SyncRituals.cs index 24a1c4af6..cb845dab1 100644 --- a/Source/MultiplayerLoader/SyncRituals.cs +++ b/Source/MultiplayerLoader/SyncRituals.cs @@ -1,135 +1,153 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using HarmonyLib; -using Mono.Cecil; -using Multiplayer.API; -using Multiplayer.Client.Util; -using RimWorld; - -namespace MultiplayerLoader; - -public static class SyncRituals -{ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using HarmonyLib; +using Mono.Cecil; +using Multiplayer.API; +using Multiplayer.Client.Util; +using RimWorld; + +namespace MultiplayerLoader; + +public static class SyncRituals +{ private static List syncMethodsWithPrepatches = new(); +#if RIMWORLD_1_7 + private static readonly Dictionary<(Type, string), ISyncMethodForPrepatcher> runtimeSyncMethods = new(); + private static readonly HashSet runtimePatchedMethods = new(); + private static readonly Harmony runtimeHarmony = new("rwmt.Multiplayer.WaylithRituals"); - public static bool PrepatcherPrefix(int index, object __instance, object[] __args) + public static bool RuntimePrefix(object __instance, object[] __args, MethodBase __originalMethod) { - // This prefix is applied to a generic method and called by all possible instantiations - // This check makes sure the type is correct - if (syncMethodsWithPrepatches[index].TargetType == __instance.GetType()) - return !syncMethodsWithPrepatches[index].DoSync(__instance, __args); - return true; + return !runtimeSyncMethods.TryGetValue((__instance.GetType(), __originalMethod.Name), out var sync) + || !sync.DoSync(__instance, __args); } - - private static MethodInfo syncMethod = AccessTools.Method("Multiplayer.Client.Sync:Method"); - - // This method is called twice (two passes) - // The first pass with module != null is called from Prepatcher and applies prepatches - // The second pass with module == null is called from Sync initialization and registers SyncMethods - public static void ApplyPrepatches(ModuleDefinition module) - { - void Register(Type baseType, string method, Type derivedType, Action postProcess = null) - { - if (module != null) - { - Prepatches.AddPrepatcherPrefix( - module, - syncMethodsWithPrepatches.Count, - AccessTools.Method(baseType, method), - AccessTools.Method(typeof(SyncRituals), nameof(PrepatcherPrefix)) - ); - - // The ids need to be in sync between passes (note the array is recreated after Prepatcher restarts the game) - syncMethodsWithPrepatches.Add(null); - } - else - { - // Sync.Method doesn't apply a Harmony patch and doesn't require the DeclaringType to be the target type - var newSyncMethod = (ISyncMethodForPrepatcher)syncMethod.Invoke(null, [derivedType, method, null]); +#endif + + public static bool PrepatcherPrefix(int index, object __instance, object[] __args) + { + // This prefix is applied to a generic method and called by all possible instantiations + // This check makes sure the type is correct + if (syncMethodsWithPrepatches[index].TargetType == __instance.GetType()) + return !syncMethodsWithPrepatches[index].DoSync(__instance, __args); + return true; + } + + private static MethodInfo syncMethod = AccessTools.Method("Multiplayer.Client.Sync:Method"); + + // This method is called twice (two passes) + // The first pass with module != null is called from Prepatcher and applies prepatches + // The second pass with module == null is called from Sync initialization and registers SyncMethods + public static void ApplyPrepatches(ModuleDefinition module) + { + void Register(Type baseType, string method, Type derivedType, Action postProcess = null) + { + if (module != null) + { + Prepatches.AddPrepatcherPrefix( + module, + syncMethodsWithPrepatches.Count, + AccessTools.Method(baseType, method), + AccessTools.Method(typeof(SyncRituals), nameof(PrepatcherPrefix)) + ); + + // The ids need to be in sync between passes (note the array is recreated after Prepatcher restarts the game) + syncMethodsWithPrepatches.Add(null); + } + else + { + // Sync.Method doesn't apply a Harmony patch and doesn't require the DeclaringType to be the target type + var newSyncMethod = (ISyncMethodForPrepatcher)syncMethod.Invoke(null, [derivedType, method, null]); postProcess?.Invoke(newSyncMethod); syncMethodsWithPrepatches.Add(newSyncMethod); - } - } - - // ======= PawnRitualRoleSelectionWidget ======= - - var ritualRolesSerializer = Serializer.New, (PawnRitualRoleSelectionWidget roleSelectionWidget, string Key)>( - RitualRoleWriter, - RitualRoleReader - ); - var psychicRitualRolesSerializer = Serializer.New, (PawnPsychicRitualRoleSelectionWidget roleSelectionWidget, string Key)>( - RitualRoleWriter, - RitualRoleReader - ); - - Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignReplace), - typeof(PawnRitualRoleSelectionWidget), - m => m.TransformArgument(1, ritualRolesSerializer)); - Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignReplace), - typeof(PawnPsychicRitualRoleSelectionWidget), - m => m.TransformArgument(1, psychicRitualRolesSerializer)); - - Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignAnyRole), - typeof(PawnRitualRoleSelectionWidget)); - Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignAnyRole), - typeof(PawnPsychicRitualRoleSelectionWidget)); - - Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssign), - typeof(PawnRitualRoleSelectionWidget), - m => m.TransformArgument(1, ritualRolesSerializer)); - Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssign), - typeof(PawnPsychicRitualRoleSelectionWidget), - m => m.TransformArgument(1, psychicRitualRolesSerializer)); - - Register( - typeof(PawnRoleSelectionWidgetBase<>), - MpMethodUtil.GetLambda( - typeof(PawnRoleSelectionWidgetBase), - nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), - lambdaOrdinal: 7).Name, - typeof(PawnRitualRoleSelectionWidget) - ); // Roles right click delegate (try assign spectate) - Register( - typeof(PawnRoleSelectionWidgetBase<>), - MpMethodUtil.GetLambda( - typeof(PawnRoleSelectionWidgetBase), - nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), - lambdaOrdinal: 7).Name, - typeof(PawnPsychicRitualRoleSelectionWidget) - ); // Roles right click delegate (try assign spectate) - - Register( - typeof(PawnRoleSelectionWidgetBase<>), - MpMethodUtil.GetLambda( - typeof(PawnRoleSelectionWidgetBase), - nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), - lambdaOrdinal: 2).Name, - typeof(PawnRitualRoleSelectionWidget) - ); // Not participating left click delegate (try assign any role or spectate) - Register( - typeof(PawnRoleSelectionWidgetBase<>), - MpMethodUtil.GetLambda( - typeof(PawnRoleSelectionWidgetBase), - nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), - lambdaOrdinal: 2).Name, - typeof(PawnPsychicRitualRoleSelectionWidget) - ); // Not participating left click delegate (try assign any role or spectate) - } - - private static (WidgetType roleSelectionWidget, string Key) RitualRoleWriter(IEnumerable roles, object target, object[] _) - where WidgetType : PawnRoleSelectionWidgetBase - where RoleType : class, ILordJobRole - { - var roleSelectionWidget = (WidgetType)target; - return (roleSelectionWidget, roleSelectionWidget.assignments.RoleGroups().FirstOrDefault(g => g.SequenceEqual(roles))?.Key); - } - - private static IEnumerable RitualRoleReader((WidgetType roleSelectionWidget, string Key) data) - where WidgetType : PawnRoleSelectionWidgetBase - where RoleType : class, ILordJobRole - { - return data.roleSelectionWidget.assignments.RoleGroups().FirstOrDefault(g => g.Key == data.Key); - } -} +#if RIMWORLD_1_7 + // Waylith supports runtime patching; the Mono preloader is not used. + runtimeSyncMethods.Add((derivedType, method), newSyncMethod); + var target = AccessTools.Method(derivedType, method); + if (runtimePatchedMethods.Add(target)) + runtimeHarmony.Patch(target, prefix: new HarmonyMethod(typeof(SyncRituals), nameof(RuntimePrefix))); +#endif + } + } + + // ======= PawnRitualRoleSelectionWidget ======= + + var ritualRolesSerializer = Serializer.New, (PawnRitualRoleSelectionWidget roleSelectionWidget, string Key)>( + RitualRoleWriter, + RitualRoleReader + ); + var psychicRitualRolesSerializer = Serializer.New, (PawnPsychicRitualRoleSelectionWidget roleSelectionWidget, string Key)>( + RitualRoleWriter, + RitualRoleReader + ); + + Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignReplace), + typeof(PawnRitualRoleSelectionWidget), + m => m.TransformArgument(1, ritualRolesSerializer)); + Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignReplace), + typeof(PawnPsychicRitualRoleSelectionWidget), + m => m.TransformArgument(1, psychicRitualRolesSerializer)); + + Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignAnyRole), + typeof(PawnRitualRoleSelectionWidget)); + Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignAnyRole), + typeof(PawnPsychicRitualRoleSelectionWidget)); + + Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssign), + typeof(PawnRitualRoleSelectionWidget), + m => m.TransformArgument(1, ritualRolesSerializer)); + Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssign), + typeof(PawnPsychicRitualRoleSelectionWidget), + m => m.TransformArgument(1, psychicRitualRolesSerializer)); + + Register( + typeof(PawnRoleSelectionWidgetBase<>), + MpMethodUtil.GetLambda( + typeof(PawnRoleSelectionWidgetBase), + nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), + lambdaOrdinal: 7).Name, + typeof(PawnRitualRoleSelectionWidget) + ); // Roles right click delegate (try assign spectate) + Register( + typeof(PawnRoleSelectionWidgetBase<>), + MpMethodUtil.GetLambda( + typeof(PawnRoleSelectionWidgetBase), + nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), + lambdaOrdinal: 7).Name, + typeof(PawnPsychicRitualRoleSelectionWidget) + ); // Roles right click delegate (try assign spectate) + + Register( + typeof(PawnRoleSelectionWidgetBase<>), + MpMethodUtil.GetLambda( + typeof(PawnRoleSelectionWidgetBase), + nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), + lambdaOrdinal: 2).Name, + typeof(PawnRitualRoleSelectionWidget) + ); // Not participating left click delegate (try assign any role or spectate) + Register( + typeof(PawnRoleSelectionWidgetBase<>), + MpMethodUtil.GetLambda( + typeof(PawnRoleSelectionWidgetBase), + nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), + lambdaOrdinal: 2).Name, + typeof(PawnPsychicRitualRoleSelectionWidget) + ); // Not participating left click delegate (try assign any role or spectate) + } + + private static (WidgetType roleSelectionWidget, string Key) RitualRoleWriter(IEnumerable roles, object target, object[] _) + where WidgetType : PawnRoleSelectionWidgetBase + where RoleType : class, ILordJobRole + { + var roleSelectionWidget = (WidgetType)target; + return (roleSelectionWidget, roleSelectionWidget.assignments.RoleGroups().FirstOrDefault(g => g.SequenceEqual(roles))?.Key); + } + + private static IEnumerable RitualRoleReader((WidgetType roleSelectionWidget, string Key) data) + where WidgetType : PawnRoleSelectionWidgetBase + where RoleType : class, ILordJobRole + { + return data.roleSelectionWidget.assignments.RoleGroups().FirstOrDefault(g => g.Key == data.Key); + } +} diff --git a/Source/Tests/CoreClrRuntimeTest.cs b/Source/Tests/CoreClrRuntimeTest.cs new file mode 100644 index 000000000..d790e573f --- /dev/null +++ b/Source/Tests/CoreClrRuntimeTest.cs @@ -0,0 +1,17 @@ +using Multiplayer.Client; + +namespace Tests; + +public class CoreClrRuntimeTest +{ + [Test] + public void MonoInitializationIsSkippedOnCoreClr() + { + Assert.That(Native.IsMonoRuntime, Is.False); + // Both calls used to load Mono libraries or read Mono thread memory. + Native.EarlyInit(Native.NativeOS.Windows); + Native.InitLmfPtr(Native.NativeOS.Windows); + Assert.That(Native.DomainPtr, Is.EqualTo(IntPtr.Zero)); + Assert.That(Native.LmfPtr, Is.Zero); + } +} diff --git a/Source/Tests/Tests.csproj b/Source/Tests/Tests.csproj index 17b0b2fce..ecde80b82 100644 --- a/Source/Tests/Tests.csproj +++ b/Source/Tests/Tests.csproj @@ -1,32 +1,33 @@ - - - - net8.0 - enable - enable - 12 - - false - - - - - - - - - - - - - - - - - - - - - - - + + + + net8.0 + net10.0 + enable + enable + 12 + + false + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt b/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt index b87199a17..08c775816 100644 --- a/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt +++ b/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt @@ -1,3 +1,3 @@ This packet is not byte-stable while serialized, meaning it can be serialized differently due to various factors, but it does deserialize into the same object -05-00-00-00-31-2E-30-2E-30-04-00-00-00-01-00-00-00-02-00-00-00-03-00-00-00-04-00-00-00-01-00-00-00-01-00-00-00-00-00-00-03-02-00-00-00-03-00-00-00-6B-65-79-01-00-00-00-7B-00-00-00-04-00-00-00-6B-65-79-32-00-00-00-00-00-00-00-00-00-24-00-00-00-63-64-60-60-10-00-E2-A2-F2-DC-12-BD-DC-D2-9C-92-CC-82-9C-C4-CA-D4-22-6E-A0-98-2F-82-0B-E4-81-01-33-98-04-00 (117 bytes) +05-00-00-00-31-2E-30-2E-30-04-00-00-00-01-00-00-00-02-00-00-00-03-00-00-00-04-00-00-00-01-00-00-00-01-00-00-00-00-00-00-03-02-00-00-00-03-00-00-00-6B-65-79-01-00-00-00-7B-00-00-00-04-00-00-00-6B-65-79-32-00-00-00-00-00-00-00-00-00-26-00-00-00-63-64-60-60-10-60-60-60-28-2A-CF-2D-D1-CB-2D-CD-29-C9-2C-C8-49-AC-4C-2D-E2-66-60-60-F0-45-70-19-A0-80-19-4C-01-00 (119 bytes) diff --git a/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt b/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt index 98e950920..0e11269af 100644 --- a/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt +++ b/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt @@ -1,3 +1,3 @@ This packet is not byte-stable while serialized, meaning it can be serialized differently due to various factors, but it does deserialize into the same object -08-00-00-00-47-61-6D-65-4E-61-6D-65-01-00-00-00-08-00-00-00-31-2E-36-2E-34-35-36-36-0D-00-00-00-30-2E-31-31-2E-30-2B-31-32-33-34-35-36-05-00-00-00-00-00-02-03-01-00-24-00-00-00-63-64-60-60-10-00-E2-A2-F2-DC-12-BD-DC-D2-9C-92-CC-82-9C-C4-CA-D4-22-6E-A0-98-2F-82-0B-E4-81-01-33-98-04-00 (95 bytes) +08-00-00-00-47-61-6D-65-4E-61-6D-65-01-00-00-00-08-00-00-00-31-2E-36-2E-34-35-36-36-0D-00-00-00-30-2E-31-31-2E-30-2B-31-32-33-34-35-36-05-00-00-00-00-00-02-03-01-00-26-00-00-00-63-64-60-60-10-60-60-60-28-2A-CF-2D-D1-CB-2D-CD-29-C9-2C-C8-49-AC-4C-2D-E2-66-60-60-F0-45-70-19-A0-80-19-4C-01-00 (97 bytes) From c3bfada92d8514dfda341d739c35743064509e8e Mon Sep 17 00:00:00 2001 From: mkiplm-svg Date: Mon, 19 Oct 2026 09:32:21 -0400 Subject: [PATCH 02/19] Normalize line endings in RimWorld 1.7 port --- Source/Client/Desyncs/DeferredStackTracing.cs | 278 +-- Source/Client/Desyncs/JittedMethods.cs | 210 +- Source/Client/Desyncs/SaveableDesyncInfo.cs | 396 ++-- Source/Client/Desyncs/StackTraceLogItem.cs | 170 +- Source/Client/Factions/MultifactionPatches.cs | 1918 ++++++++--------- Source/Client/Multiplayer.cs | 508 ++--- Source/Client/Multiplayer.csproj | 168 +- .../Patches/GravshipTravelSessionPatches.cs | 514 ++--- Source/Client/Patches/VanillaTweaks.cs | 294 +-- Source/Client/Syncing/SyncTemplates.cs | 176 +- Source/Client/UI/MainMenuAnimation.cs | 876 ++++---- Source/Client/Util/MpUI.cs | 546 ++--- Source/Client/Util/MpUtil.cs | 400 ++-- Source/Common/Common.csproj | 84 +- Source/Common/Native.cs | 526 ++--- .../MultiplayerLoader.csproj | 74 +- Source/MultiplayerLoader/Prepatches.cs | 158 +- Source/MultiplayerLoader/SyncRituals.cs | 264 +-- Source/Tests/Tests.csproj | 64 +- .../ClientInitDataPacket.verified.txt | 2 +- .../ServerJoinDataPacket.verified.txt | 2 +- 21 files changed, 3814 insertions(+), 3814 deletions(-) diff --git a/Source/Client/Desyncs/DeferredStackTracing.cs b/Source/Client/Desyncs/DeferredStackTracing.cs index f36c9395f..4b9ad7464 100644 --- a/Source/Client/Desyncs/DeferredStackTracing.cs +++ b/Source/Client/Desyncs/DeferredStackTracing.cs @@ -1,36 +1,36 @@ using System.Collections.Generic; using System.Diagnostics; using System.Text; -using System.Reflection; -using System.Runtime.InteropServices; -using HarmonyLib; -using Multiplayer.Client.Patches; -using Multiplayer.Common; -using RimWorld; -using Verse; -using Verse.AI; - -namespace Multiplayer.Client.Desyncs -{ - [EarlyPatch] - [HarmonyPatch] - public static class DeferredStackTracing - { - public static int ignoreTraces; - public static long maxTraceDepth; - public static int randCalls; - - static IEnumerable TargetMethods() - { - yield return AccessTools.PropertyGetter(typeof(Rand), nameof(Rand.Value)); - yield return AccessTools.PropertyGetter(typeof(Rand), nameof(Rand.Int)); - } - - public static int acc; - - private static bool SupportsDeferredStackTracing => - RuntimeInformation.ProcessArchitecture is Architecture.X64 or Architecture.X86; - +using System.Reflection; +using System.Runtime.InteropServices; +using HarmonyLib; +using Multiplayer.Client.Patches; +using Multiplayer.Common; +using RimWorld; +using Verse; +using Verse.AI; + +namespace Multiplayer.Client.Desyncs +{ + [EarlyPatch] + [HarmonyPatch] + public static class DeferredStackTracing + { + public static int ignoreTraces; + public static long maxTraceDepth; + public static int randCalls; + + static IEnumerable TargetMethods() + { + yield return AccessTools.PropertyGetter(typeof(Rand), nameof(Rand.Value)); + yield return AccessTools.PropertyGetter(typeof(Rand), nameof(Rand.Int)); + } + + public static int acc; + + private static bool SupportsDeferredStackTracing => + RuntimeInformation.ProcessArchitecture is Architecture.X64 or Architecture.X86; + public static void Postfix() { if (Native.IsMonoRuntime && Native.LmfPtr == 0) return; @@ -58,112 +58,112 @@ public static void Postfix() acc++; return; } - var trace = logItem.raw; - int hash = 0; - // Skip this (DeferredStackTracing.Postfix) frame. Keeping it in the trace doesn't provide any value. - int depth = DeferredStackTracingImpl.TraceImpl(trace, ref hash, skipFrames: 1); - - Multiplayer.game.sync.TryAddStackTraceForDesyncLogRaw(logItem, depth, hash); - - acc++; - } - - public static bool ShouldAddStackTraceForDesyncLog() - { - if (!SupportsDeferredStackTracing) return false; - if (Multiplayer.Client == null) return false; - if (Multiplayer.settings.desyncTracingMode == DesyncTracingMode.None) return false; - if (Multiplayer.game == null) return false; - - if (!Multiplayer.game.gameComp.logDesyncTraces) return false; - - if (Rand.stateStack.Count > 1) return false; - if (Multiplayer.IsReplay) return false; - - if (!Multiplayer.Ticking && !Multiplayer.ExecutingCmds) return false; - - return ignoreTraces == 0; - } - } - - [HarmonyPatch(typeof(UniqueIDsManager), nameof(UniqueIDsManager.GetNextID))] - public static class UniqueIdsPatch - { - static void Postfix() - { - DeferredStackTracing.Postfix(); - } - } - - [HarmonyPatch(typeof(Thing), nameof(Thing.SpawnSetup))] - public static class ThingSpawnPatch - { - static void Postfix(Thing __instance) - { - if (__instance.def.HasThingIDNumber) - DeferredStackTracing.Postfix(); - } - } - - [HarmonyPatch(typeof(Thing), nameof(Thing.DeSpawn))] - public static class ThingDeSpawnPatch - { - static void Postfix(Thing __instance) - { - if (__instance.def.HasThingIDNumber) - DeferredStackTracing.Postfix(); - } - } - - [HarmonyPatch(typeof(Pawn_JobTracker), nameof(Pawn_JobTracker.EndCurrentJob))] - public static class EndCurrentJobPatch - { - static void Prefix(Pawn_JobTracker __instance) - { - if (MpVersion.IsDebug && __instance.curJob != null && DeferredStackTracing.ShouldAddStackTraceForDesyncLog()) - Multiplayer.game.sync.TryAddInfoForDesyncLog($"EndCurrentJob for {__instance.pawn}: {__instance.curJob}", ""); - } - } - - [HarmonyPatch(typeof(WildAnimalSpawner), nameof(WildAnimalSpawner.WildAnimalSpawnerTick))] - static class WildAnimalSpawnerTickTraceIgnore - { - static void Prefix() => DeferredStackTracing.ignoreTraces++; - static void Finalizer() => DeferredStackTracing.ignoreTraces--; - } - - [HarmonyPatch(typeof(WildPlantSpawner), nameof(WildPlantSpawner.WildPlantSpawnerTick))] - static class WildPlantSpawnerTickTraceIgnore - { - static void Prefix() => DeferredStackTracing.ignoreTraces++; - static void Finalizer() => DeferredStackTracing.ignoreTraces--; - } - - [HarmonyPatch(typeof(SteadyEnvironmentEffects), nameof(SteadyEnvironmentEffects.SteadyEnvironmentEffectsTick))] - static class SteadyEnvironmentEffectsTickTraceIgnore - { - static void Prefix() => DeferredStackTracing.ignoreTraces++; - static void Finalizer() => DeferredStackTracing.ignoreTraces--; - } - - [HarmonyPatch(typeof(TempTerrainManager), nameof(TempTerrainManager.Tick))] - static class TempTerrainManagerTickTraceIgnore - { - static void Prefix() => DeferredStackTracing.ignoreTraces++; - static void Finalizer() => DeferredStackTracing.ignoreTraces--; - } - - /*[HarmonyPatch(typeof(StoreUtility), nameof(StoreUtility.TryFindBestBetterStoreCellForWorker))] - static class FindBestStorageCellTraceIgnore - { - static void Prefix() => DeferredStackTracing.ignoreTraces++; - static void Finalizer() => DeferredStackTracing.ignoreTraces--; - }*/ - - [HarmonyPatch(typeof(IntermittentSteamSprayer), nameof(IntermittentSteamSprayer.SteamSprayerTick))] - static class SteamSprayerTickTraceIgnore - { - static void Prefix() => DeferredStackTracing.ignoreTraces++; - static void Finalizer() => DeferredStackTracing.ignoreTraces--; - } -} + var trace = logItem.raw; + int hash = 0; + // Skip this (DeferredStackTracing.Postfix) frame. Keeping it in the trace doesn't provide any value. + int depth = DeferredStackTracingImpl.TraceImpl(trace, ref hash, skipFrames: 1); + + Multiplayer.game.sync.TryAddStackTraceForDesyncLogRaw(logItem, depth, hash); + + acc++; + } + + public static bool ShouldAddStackTraceForDesyncLog() + { + if (!SupportsDeferredStackTracing) return false; + if (Multiplayer.Client == null) return false; + if (Multiplayer.settings.desyncTracingMode == DesyncTracingMode.None) return false; + if (Multiplayer.game == null) return false; + + if (!Multiplayer.game.gameComp.logDesyncTraces) return false; + + if (Rand.stateStack.Count > 1) return false; + if (Multiplayer.IsReplay) return false; + + if (!Multiplayer.Ticking && !Multiplayer.ExecutingCmds) return false; + + return ignoreTraces == 0; + } + } + + [HarmonyPatch(typeof(UniqueIDsManager), nameof(UniqueIDsManager.GetNextID))] + public static class UniqueIdsPatch + { + static void Postfix() + { + DeferredStackTracing.Postfix(); + } + } + + [HarmonyPatch(typeof(Thing), nameof(Thing.SpawnSetup))] + public static class ThingSpawnPatch + { + static void Postfix(Thing __instance) + { + if (__instance.def.HasThingIDNumber) + DeferredStackTracing.Postfix(); + } + } + + [HarmonyPatch(typeof(Thing), nameof(Thing.DeSpawn))] + public static class ThingDeSpawnPatch + { + static void Postfix(Thing __instance) + { + if (__instance.def.HasThingIDNumber) + DeferredStackTracing.Postfix(); + } + } + + [HarmonyPatch(typeof(Pawn_JobTracker), nameof(Pawn_JobTracker.EndCurrentJob))] + public static class EndCurrentJobPatch + { + static void Prefix(Pawn_JobTracker __instance) + { + if (MpVersion.IsDebug && __instance.curJob != null && DeferredStackTracing.ShouldAddStackTraceForDesyncLog()) + Multiplayer.game.sync.TryAddInfoForDesyncLog($"EndCurrentJob for {__instance.pawn}: {__instance.curJob}", ""); + } + } + + [HarmonyPatch(typeof(WildAnimalSpawner), nameof(WildAnimalSpawner.WildAnimalSpawnerTick))] + static class WildAnimalSpawnerTickTraceIgnore + { + static void Prefix() => DeferredStackTracing.ignoreTraces++; + static void Finalizer() => DeferredStackTracing.ignoreTraces--; + } + + [HarmonyPatch(typeof(WildPlantSpawner), nameof(WildPlantSpawner.WildPlantSpawnerTick))] + static class WildPlantSpawnerTickTraceIgnore + { + static void Prefix() => DeferredStackTracing.ignoreTraces++; + static void Finalizer() => DeferredStackTracing.ignoreTraces--; + } + + [HarmonyPatch(typeof(SteadyEnvironmentEffects), nameof(SteadyEnvironmentEffects.SteadyEnvironmentEffectsTick))] + static class SteadyEnvironmentEffectsTickTraceIgnore + { + static void Prefix() => DeferredStackTracing.ignoreTraces++; + static void Finalizer() => DeferredStackTracing.ignoreTraces--; + } + + [HarmonyPatch(typeof(TempTerrainManager), nameof(TempTerrainManager.Tick))] + static class TempTerrainManagerTickTraceIgnore + { + static void Prefix() => DeferredStackTracing.ignoreTraces++; + static void Finalizer() => DeferredStackTracing.ignoreTraces--; + } + + /*[HarmonyPatch(typeof(StoreUtility), nameof(StoreUtility.TryFindBestBetterStoreCellForWorker))] + static class FindBestStorageCellTraceIgnore + { + static void Prefix() => DeferredStackTracing.ignoreTraces++; + static void Finalizer() => DeferredStackTracing.ignoreTraces--; + }*/ + + [HarmonyPatch(typeof(IntermittentSteamSprayer), nameof(IntermittentSteamSprayer.SteamSprayerTick))] + static class SteamSprayerTickTraceIgnore + { + static void Prefix() => DeferredStackTracing.ignoreTraces++; + static void Finalizer() => DeferredStackTracing.ignoreTraces--; + } +} diff --git a/Source/Client/Desyncs/JittedMethods.cs b/Source/Client/Desyncs/JittedMethods.cs index 7b966c1a4..17976caf7 100644 --- a/Source/Client/Desyncs/JittedMethods.cs +++ b/Source/Client/Desyncs/JittedMethods.cs @@ -1,116 +1,116 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Reflection; -using System.Text; -using HarmonyLib; -using JetBrains.Annotations; -using Verse; - -namespace Multiplayer.Client.Desyncs; - -public class JittedMethod -{ - [CanBeNull] public MethodBase method; - [CanBeNull] public MethodBase from; - [CanBeNull] public int[] mapTicks; - public int worldTicks; - public int timer; - public bool inInterface; - - public string TimeString() - { - return $"m:{mapTicks?.Join(delimiter: ",") ?? "[]"} w:{worldTicks} t:{timer}"; - } - - public override string ToString() - { - return $"{method?.DeclaringType}.{method?.Name} from {from?.DeclaringType}.{from?.Name} {TimeString()} i:{inInterface}"; - } -} - -public static class JittedMethods -{ - private static Queue methodQueue = new(); - private static IntPtr profiler; - private static bool adding; - +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Text; +using HarmonyLib; +using JetBrains.Annotations; +using Verse; + +namespace Multiplayer.Client.Desyncs; + +public class JittedMethod +{ + [CanBeNull] public MethodBase method; + [CanBeNull] public MethodBase from; + [CanBeNull] public int[] mapTicks; + public int worldTicks; + public int timer; + public bool inInterface; + + public string TimeString() + { + return $"m:{mapTicks?.Join(delimiter: ",") ?? "[]"} w:{worldTicks} t:{timer}"; + } + + public override string ToString() + { + return $"{method?.DeclaringType}.{method?.Name} from {from?.DeclaringType}.{from?.Name} {TimeString()} i:{inInterface}"; + } +} + +public static class JittedMethods +{ + private static Queue methodQueue = new(); + private static IntPtr profiler; + private static bool adding; + public static void Init() { if (!Native.IsMonoRuntime) return; - profiler = Native.mono_profiler_create(IntPtr.Zero); - Native.mono_profiler_set_jit_done_callback(profiler, (_, method, _) => - { - if (adding) return; - - try - { - adding = true; - - if (UnityData.IsInMainThread && Multiplayer.settings != null && Native.mono_method_get_token(method) != 0) - { - var methodBase = Native.GetMethodBaseFromRuntimePointer(method); - methodQueue.Enqueue(new JittedMethod - { - method = methodBase, - from = new StackTrace().GetFrame(1)?.GetMethod(), - mapTicks = Multiplayer.game?.asyncTimeComps.Select(c => c.mapTicks).ToArray(), - worldTicks = Multiplayer.game?.asyncWorldTimeComp?.worldTicks ?? -1, - timer = TickPatch.Timer, - inInterface = Multiplayer.InInterface - }); - - if (methodQueue.Count > Multiplayer.settings.jittedMethodsInDesync) - methodQueue.Dequeue(); - } - } - finally - { - adding = false; - } - }); - } - + profiler = Native.mono_profiler_create(IntPtr.Zero); + Native.mono_profiler_set_jit_done_callback(profiler, (_, method, _) => + { + if (adding) return; + + try + { + adding = true; + + if (UnityData.IsInMainThread && Multiplayer.settings != null && Native.mono_method_get_token(method) != 0) + { + var methodBase = Native.GetMethodBaseFromRuntimePointer(method); + methodQueue.Enqueue(new JittedMethod + { + method = methodBase, + from = new StackTrace().GetFrame(1)?.GetMethod(), + mapTicks = Multiplayer.game?.asyncTimeComps.Select(c => c.mapTicks).ToArray(), + worldTicks = Multiplayer.game?.asyncWorldTimeComp?.worldTicks ?? -1, + timer = TickPatch.Timer, + inInterface = Multiplayer.InInterface + }); + + if (methodQueue.Count > Multiplayer.settings.jittedMethodsInDesync) + methodQueue.Dequeue(); + } + } + finally + { + adding = false; + } + }); + } + public static void OnApplicationQuit() { if (!Native.IsMonoRuntime || profiler == IntPtr.Zero) return; Native.mono_profiler_set_jit_done_callback(profiler, null); - } - + } + public static string GetJittedMethodsString() { if (!Native.IsMonoRuntime) return "Mono JIT history is unavailable on this runtime."; - // Two ToArrays to prevent a compilation in between from causing a "Collection was modified" exception - var jittedMethods = methodQueue.ToArray().Select((j, i) => (j, i)).ToArray(); - - var builder = new StringBuilder(); - builder.Append("In simulation:"); // This will get a newline from the != comparison below - var timeString = ""; - - foreach (var j in jittedMethods.Where(j => !j.j.inInterface)) - { - if (timeString != j.j.TimeString()) - builder.AppendLine(); - builder.AppendLine($"{j.i} {j.j}"); - timeString = j.j.TimeString(); - } - - builder.AppendLine(); - builder.AppendLine(); - - timeString = ""; - - builder.Append("In interface:"); - foreach (var j in jittedMethods.Where(j => j.j.inInterface)) - { - if (timeString != j.j.TimeString()) - builder.AppendLine(); - builder.AppendLine($"{j.i} {j.j}"); - timeString = j.j.TimeString(); - } - - return builder.ToString(); - } -} + // Two ToArrays to prevent a compilation in between from causing a "Collection was modified" exception + var jittedMethods = methodQueue.ToArray().Select((j, i) => (j, i)).ToArray(); + + var builder = new StringBuilder(); + builder.Append("In simulation:"); // This will get a newline from the != comparison below + var timeString = ""; + + foreach (var j in jittedMethods.Where(j => !j.j.inInterface)) + { + if (timeString != j.j.TimeString()) + builder.AppendLine(); + builder.AppendLine($"{j.i} {j.j}"); + timeString = j.j.TimeString(); + } + + builder.AppendLine(); + builder.AppendLine(); + + timeString = ""; + + builder.Append("In interface:"); + foreach (var j in jittedMethods.Where(j => j.j.inInterface)) + { + if (timeString != j.j.TimeString()) + builder.AppendLine(); + builder.AppendLine($"{j.i} {j.j}"); + timeString = j.j.TimeString(); + } + + return builder.ToString(); + } +} diff --git a/Source/Client/Desyncs/SaveableDesyncInfo.cs b/Source/Client/Desyncs/SaveableDesyncInfo.cs index 2bc80eba8..7379d9b65 100644 --- a/Source/Client/Desyncs/SaveableDesyncInfo.cs +++ b/Source/Client/Desyncs/SaveableDesyncInfo.cs @@ -1,201 +1,201 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using HarmonyLib; -using JetBrains.Annotations; -using Multiplayer.Common; -using Multiplayer.Common.Util; -using RimWorld; -using UnityEngine; -using Verse; -using CompressionLevel = System.IO.Compression.CompressionLevel; - -namespace Multiplayer.Client.Desyncs; - -public class SaveableDesyncInfo( - SyncCoordinator coordinator, - ClientSyncOpinion local, - ClientSyncOpinion remote, - int diffAt, - bool diffAtFound) -{ - public readonly ClientSyncOpinion local = local; - public readonly ClientSyncOpinion remote = remote; - public readonly int diffAt = diffAt; - private readonly Task metadata = Task.Run(MetadataGenerator.Generate); - private readonly Task replay = Task.Run(SaveReplayIfApplicable); - - public bool ReadyToSave => metadata.IsCompleted && replay.IsCompleted; - - public void Save([CanBeNull] HostInfo hostInfo) - { - var watch = Stopwatch.StartNew(); - - try - { - var desyncFilePath = FindFileNameForNextDesyncFile(); - using var zip = MpZipFile.Open(Path.Combine(Multiplayer.DesyncsDir, desyncFilePath + ".zip"), ZipArchiveMode.Create); - - zip.AddEntry("desync_info", GetDesyncDetails()); - - zip.AddEntry("local_traces.txt", GetLocalTraces()); - zip.AddEntry("host_traces.txt", hostInfo?.Traces ?? "No host traces"); - - zip.AddEntry("local_jitted_methods.txt", JittedMethods.GetJittedMethodsString()); - zip.AddEntry("host_jitted_methods.txt", hostInfo?.JittedMethods ?? "No host jitted methods"); - - var extraLogs = LogGenerator.PrepareLogData(); - if (extraLogs != null) zip.AddEntry("local_logs.txt", extraLogs); - - zip.AddEntry("local_metadata.txt", metadata.Result); - - try - { - replay.Wait(); - } - catch (AggregateException e) - { - if (e.InnerExceptions.SingleOrDefault(inner => inner is TaskCanceledException) == null) throw; - } - if (replay.IsCompletedSuccessfully) { - var replayFile = replay.Result; - zip.CreateEntryFromFile(replayFile.FullName, "replay.rwmts", CompressionLevel.NoCompression); - DeleteFileSilent(replayFile); - } - } - catch (Exception e) - { - Log.Error($"Exception writing desync info: {e}"); - } - - Log.Message($"Desync info writing took {watch.ElapsedMilliseconds}"); - } - - private string GetLocalTraces() - { - var traceMessage = ""; - var localCount = local.desyncStackTraceHashes.Count; - var remoteCount = remote.desyncStackTraceHashes.Count; - int count = Math.Min(localCount, remoteCount); - - if (diffAt == -1) - { - if (count == 0) - { - return $"No traces (remote: {remoteCount}, local: {localCount})"; - } - - traceMessage = "Note: trace hashes are equal between local and remote\n\n"; - } - else if (!diffAtFound) - { - traceMessage = "Note: traces differ in amount, but the existing ones are equal. This means that a tick" + - " has ended sooner on one of the connection sides\n\n"; - } - - traceMessage += local.GetFormattedStackTracesForRange(diffAt); - return traceMessage; - } - - private string GetDesyncDetails() - { - var desyncInfo = new StringBuilder(); - - desyncInfo - .AppendLine("###Tick Data###") - .AppendLine($"Arbiter Connected And Playing|||{Multiplayer.session.ArbiterPlaying}") - .AppendLine($"Last Valid Tick - Local|||{coordinator.lastValidTick}") - .AppendLine($"Last Valid Tick - Arbiter|||{coordinator.arbiterWasPlayingOnLastValidTick}") - .AppendLine("\n###Version Data###") - .AppendLine($"Multiplayer Mod Version|||{MpVersion.Version}") - .AppendLine($"Rimworld Version and Rev|||{VersionControl.CurrentVersionStringWithRev}") - .AppendLine("\n###Debug Options###") - .AppendLine($"Multiplayer Debug Build - Client|||{MpVersion.IsDebug}") - .AppendLine($"Multiplayer Debug Mode - Host|||{Multiplayer.GameComp.debugMode}") - .AppendLine($"Rimworld Developer Mode - Client|||{Prefs.DevMode}") - .AppendLine("\n###Server Info###") - .AppendLine($"Player Count|||{Multiplayer.session.players.Count}") - .AppendLine($"Async time active|||{Multiplayer.GameComp.asyncTime}") - .AppendLine($"Multifaction active|||{Multiplayer.GameComp.multifaction}") - .AppendLine($"Map Count|||{Find.Maps?.Count.ToStringSafe()}") - .AppendLine("\n###CPU Info###") - .AppendLine($"Processor Name|||{SystemInfo.processorType}") - .AppendLine($"Processor Speed (MHz)|||{SystemInfo.processorFrequency}") - .AppendLine($"Thread Count|||{SystemInfo.processorCount}") - .AppendLine("\n###GPU Info###") +using System; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using HarmonyLib; +using JetBrains.Annotations; +using Multiplayer.Common; +using Multiplayer.Common.Util; +using RimWorld; +using UnityEngine; +using Verse; +using CompressionLevel = System.IO.Compression.CompressionLevel; + +namespace Multiplayer.Client.Desyncs; + +public class SaveableDesyncInfo( + SyncCoordinator coordinator, + ClientSyncOpinion local, + ClientSyncOpinion remote, + int diffAt, + bool diffAtFound) +{ + public readonly ClientSyncOpinion local = local; + public readonly ClientSyncOpinion remote = remote; + public readonly int diffAt = diffAt; + private readonly Task metadata = Task.Run(MetadataGenerator.Generate); + private readonly Task replay = Task.Run(SaveReplayIfApplicable); + + public bool ReadyToSave => metadata.IsCompleted && replay.IsCompleted; + + public void Save([CanBeNull] HostInfo hostInfo) + { + var watch = Stopwatch.StartNew(); + + try + { + var desyncFilePath = FindFileNameForNextDesyncFile(); + using var zip = MpZipFile.Open(Path.Combine(Multiplayer.DesyncsDir, desyncFilePath + ".zip"), ZipArchiveMode.Create); + + zip.AddEntry("desync_info", GetDesyncDetails()); + + zip.AddEntry("local_traces.txt", GetLocalTraces()); + zip.AddEntry("host_traces.txt", hostInfo?.Traces ?? "No host traces"); + + zip.AddEntry("local_jitted_methods.txt", JittedMethods.GetJittedMethodsString()); + zip.AddEntry("host_jitted_methods.txt", hostInfo?.JittedMethods ?? "No host jitted methods"); + + var extraLogs = LogGenerator.PrepareLogData(); + if (extraLogs != null) zip.AddEntry("local_logs.txt", extraLogs); + + zip.AddEntry("local_metadata.txt", metadata.Result); + + try + { + replay.Wait(); + } + catch (AggregateException e) + { + if (e.InnerExceptions.SingleOrDefault(inner => inner is TaskCanceledException) == null) throw; + } + if (replay.IsCompletedSuccessfully) { + var replayFile = replay.Result; + zip.CreateEntryFromFile(replayFile.FullName, "replay.rwmts", CompressionLevel.NoCompression); + DeleteFileSilent(replayFile); + } + } + catch (Exception e) + { + Log.Error($"Exception writing desync info: {e}"); + } + + Log.Message($"Desync info writing took {watch.ElapsedMilliseconds}"); + } + + private string GetLocalTraces() + { + var traceMessage = ""; + var localCount = local.desyncStackTraceHashes.Count; + var remoteCount = remote.desyncStackTraceHashes.Count; + int count = Math.Min(localCount, remoteCount); + + if (diffAt == -1) + { + if (count == 0) + { + return $"No traces (remote: {remoteCount}, local: {localCount})"; + } + + traceMessage = "Note: trace hashes are equal between local and remote\n\n"; + } + else if (!diffAtFound) + { + traceMessage = "Note: traces differ in amount, but the existing ones are equal. This means that a tick" + + " has ended sooner on one of the connection sides\n\n"; + } + + traceMessage += local.GetFormattedStackTracesForRange(diffAt); + return traceMessage; + } + + private string GetDesyncDetails() + { + var desyncInfo = new StringBuilder(); + + desyncInfo + .AppendLine("###Tick Data###") + .AppendLine($"Arbiter Connected And Playing|||{Multiplayer.session.ArbiterPlaying}") + .AppendLine($"Last Valid Tick - Local|||{coordinator.lastValidTick}") + .AppendLine($"Last Valid Tick - Arbiter|||{coordinator.arbiterWasPlayingOnLastValidTick}") + .AppendLine("\n###Version Data###") + .AppendLine($"Multiplayer Mod Version|||{MpVersion.Version}") + .AppendLine($"Rimworld Version and Rev|||{VersionControl.CurrentVersionStringWithRev}") + .AppendLine("\n###Debug Options###") + .AppendLine($"Multiplayer Debug Build - Client|||{MpVersion.IsDebug}") + .AppendLine($"Multiplayer Debug Mode - Host|||{Multiplayer.GameComp.debugMode}") + .AppendLine($"Rimworld Developer Mode - Client|||{Prefs.DevMode}") + .AppendLine("\n###Server Info###") + .AppendLine($"Player Count|||{Multiplayer.session.players.Count}") + .AppendLine($"Async time active|||{Multiplayer.GameComp.asyncTime}") + .AppendLine($"Multifaction active|||{Multiplayer.GameComp.multifaction}") + .AppendLine($"Map Count|||{Find.Maps?.Count.ToStringSafe()}") + .AppendLine("\n###CPU Info###") + .AppendLine($"Processor Name|||{SystemInfo.processorType}") + .AppendLine($"Processor Speed (MHz)|||{SystemInfo.processorFrequency}") + .AppendLine($"Thread Count|||{SystemInfo.processorCount}") + .AppendLine("\n###GPU Info###") #if !RIMWORLD_1_7 .AppendLine($"GPU Family|||{SystemInfo.graphicsDeviceVendor}") #endif - .AppendLine($"GPU Type|||{SystemInfo.graphicsDeviceType}") - .AppendLine($"GPU Name|||{SystemInfo.graphicsDeviceName}") - .AppendLine($"GPU VRAM|||{SystemInfo.graphicsMemorySize}") - .AppendLine("\n###RAM Info###") - .AppendLine($"Physical Memory Present|||{SystemInfo.systemMemorySize}") - .AppendLine("\n###OS Info###") - .AppendLine($"OS Type|||{SystemInfo.operatingSystemFamily}") - .AppendLine($"OS Name and Version|||{SystemInfo.operatingSystem}"); - - return desyncInfo.ToString(); - } - - private static string FindFileNameForNextDesyncFile() - { - const string FilePrefix = "Desync-"; - const string FileExtension = ".zip"; - - //Find all current existing desync zips - var files = new DirectoryInfo(Multiplayer.DesyncsDir).GetFiles($"{FilePrefix}*{FileExtension}"); - - const int MaxFiles = 10; - - //Delete any pushing us over the limit, and reserve room for one more - if (files.Length > MaxFiles - 1) - files.OrderByDescending(f => f.LastWriteTime).Skip(MaxFiles - 1).Do(DeleteFileSilent); - - //Find the current max desync number - int max = 0; - foreach (var f in files) - if (int.TryParse( - f.Name.Substring(FilePrefix.Length, f.Name.Length - FilePrefix.Length - FileExtension.Length), - out int result) && result > max) - max = result; - - return $"{FilePrefix}{max + 1:00}"; - } - - private static Task SaveReplayIfApplicable() - { - if (!Multiplayer.settings.includeReplayInDesync) return Task.FromCanceled(new CancellationToken(true)); - - try - { - var tmp = new FileInfo(Path.Combine(Multiplayer.DesyncsDir, "desync-replay.tmp.zip")); - if (tmp.Exists) tmp.Delete(); - Replay.ForSaving(tmp).WriteData(Multiplayer.session.dataSnapshot); - return Task.FromResult(tmp); - } - catch (Exception e) - { - Log.Error($"Failed to save replay for desync: {e}"); - return Task.FromException(e); - } - } - - private static void DeleteFileSilent(FileInfo file) - { - try - { - file.Delete(); - } - catch (IOException) - { - } - } - - public record HostInfo([CanBeNull] string Traces, [CanBeNull] string JittedMethods); -} + .AppendLine($"GPU Type|||{SystemInfo.graphicsDeviceType}") + .AppendLine($"GPU Name|||{SystemInfo.graphicsDeviceName}") + .AppendLine($"GPU VRAM|||{SystemInfo.graphicsMemorySize}") + .AppendLine("\n###RAM Info###") + .AppendLine($"Physical Memory Present|||{SystemInfo.systemMemorySize}") + .AppendLine("\n###OS Info###") + .AppendLine($"OS Type|||{SystemInfo.operatingSystemFamily}") + .AppendLine($"OS Name and Version|||{SystemInfo.operatingSystem}"); + + return desyncInfo.ToString(); + } + + private static string FindFileNameForNextDesyncFile() + { + const string FilePrefix = "Desync-"; + const string FileExtension = ".zip"; + + //Find all current existing desync zips + var files = new DirectoryInfo(Multiplayer.DesyncsDir).GetFiles($"{FilePrefix}*{FileExtension}"); + + const int MaxFiles = 10; + + //Delete any pushing us over the limit, and reserve room for one more + if (files.Length > MaxFiles - 1) + files.OrderByDescending(f => f.LastWriteTime).Skip(MaxFiles - 1).Do(DeleteFileSilent); + + //Find the current max desync number + int max = 0; + foreach (var f in files) + if (int.TryParse( + f.Name.Substring(FilePrefix.Length, f.Name.Length - FilePrefix.Length - FileExtension.Length), + out int result) && result > max) + max = result; + + return $"{FilePrefix}{max + 1:00}"; + } + + private static Task SaveReplayIfApplicable() + { + if (!Multiplayer.settings.includeReplayInDesync) return Task.FromCanceled(new CancellationToken(true)); + + try + { + var tmp = new FileInfo(Path.Combine(Multiplayer.DesyncsDir, "desync-replay.tmp.zip")); + if (tmp.Exists) tmp.Delete(); + Replay.ForSaving(tmp).WriteData(Multiplayer.session.dataSnapshot); + return Task.FromResult(tmp); + } + catch (Exception e) + { + Log.Error($"Failed to save replay for desync: {e}"); + return Task.FromException(e); + } + } + + private static void DeleteFileSilent(FileInfo file) + { + try + { + file.Delete(); + } + catch (IOException) + { + } + } + + public record HostInfo([CanBeNull] string Traces, [CanBeNull] string JittedMethods); +} diff --git a/Source/Client/Desyncs/StackTraceLogItem.cs b/Source/Client/Desyncs/StackTraceLogItem.cs index a57d6c69d..43d60ef0f 100644 --- a/Source/Client/Desyncs/StackTraceLogItem.cs +++ b/Source/Client/Desyncs/StackTraceLogItem.cs @@ -1,95 +1,95 @@ -using System; -using System.Collections.Generic; -using System.Text; -using Multiplayer.Client.Desyncs; -using Verse; - -namespace Multiplayer.Client -{ - public abstract class StackTraceLogItem : IDisposable - { - public int tick; - public int hash; - - public abstract string AdditionalInfo { get; } - - public abstract string StackTraceString { get; } - - public override string ToString() - { - return $"Tick:{tick} Hash:{hash} '{AdditionalInfo}'\n{StackTraceString}"; - } - - public virtual void Dispose() - { - } - } - - public class StackTraceLogItemObj : StackTraceLogItem - { - public string info1; - public string info2; - - public override string AdditionalInfo => $"{info1} {info2}"; - - public override string StackTraceString => ""; - } - - public class StackTraceLogItemRaw : StackTraceLogItem - { - public const int MaxDepth = 32; - public long[] raw = new long[MaxDepth]; +using System; +using System.Collections.Generic; +using System.Text; +using Multiplayer.Client.Desyncs; +using Verse; + +namespace Multiplayer.Client +{ + public abstract class StackTraceLogItem : IDisposable + { + public int tick; + public int hash; + + public abstract string AdditionalInfo { get; } + + public abstract string StackTraceString { get; } + + public override string ToString() + { + return $"Tick:{tick} Hash:{hash} '{AdditionalInfo}'\n{StackTraceString}"; + } + + public virtual void Dispose() + { + } + } + + public class StackTraceLogItemObj : StackTraceLogItem + { + public string info1; + public string info2; + + public override string AdditionalInfo => $"{info1} {info2}"; + + public override string StackTraceString => ""; + } + + public class StackTraceLogItemRaw : StackTraceLogItem + { + public const int MaxDepth = 32; + public long[] raw = new long[MaxDepth]; public int depth; public string managedTrace; - - public int ticksGame; - public ulong rngState; - public ThingDef thingDef; - public int thingId; - public string factionName; - public string moreInfo; - - public override string AdditionalInfo => $"{ticksGame} {thingDef}{thingId} {factionName} {depth} {rngState} {moreInfo}"; - - private static Dictionary methodNameCache = new(); - - public override string StackTraceString - { + + public int ticksGame; + public ulong rngState; + public ThingDef thingDef; + public int thingId; + public string factionName; + public string moreInfo; + + public override string AdditionalInfo => $"{ticksGame} {thingDef}{thingId} {factionName} {depth} {rngState} {moreInfo}"; + + private static Dictionary methodNameCache = new(); + + public override string StackTraceString + { get { if (managedTrace != null) return managedTrace; var builder = new StringBuilder(); - for (int i = 0; i < depth; i++) - { - var addr = raw[i]; - - if (!methodNameCache.TryGetValue(addr, out string method)) - methodNameCache[addr] = method = Native.MethodNameFromAddr(raw[i], false); - - builder.AppendLine(method != null ? SyncCoordinator.MethodNameWithIL(method) : "Null"); - } - - return builder.ToString(); - } - } - - public static StackTraceLogItemRaw GetFromPool() - { - return SimplePool.Get(); - } - + for (int i = 0; i < depth; i++) + { + var addr = raw[i]; + + if (!methodNameCache.TryGetValue(addr, out string method)) + methodNameCache[addr] = method = Native.MethodNameFromAddr(raw[i], false); + + builder.AppendLine(method != null ? SyncCoordinator.MethodNameWithIL(method) : "Null"); + } + + return builder.ToString(); + } + } + + public static StackTraceLogItemRaw GetFromPool() + { + return SimplePool.Get(); + } + public override void Dispose() { managedTrace = null; depth = 0; - ticksGame = 0; - rngState = 0; - thingId = 0; - thingDef = null; - factionName = null; - moreInfo = null; - - SimplePool.Return(this); - } - } -} + ticksGame = 0; + rngState = 0; + thingId = 0; + thingDef = null; + factionName = null; + moreInfo = null; + + SimplePool.Return(this); + } + } +} diff --git a/Source/Client/Factions/MultifactionPatches.cs b/Source/Client/Factions/MultifactionPatches.cs index 51e3f143e..2d5ef474b 100644 --- a/Source/Client/Factions/MultifactionPatches.cs +++ b/Source/Client/Factions/MultifactionPatches.cs @@ -1,959 +1,959 @@ -using HarmonyLib; -using KTrie; -using Multiplayer.API; -using Multiplayer.Client.Factions; -using RimWorld; -using RimWorld.Planet; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Reflection.Emit; - -using UnityEngine; -using Verse; -using Verse.AI; -using Verse.Sound; -using static HarmonyLib.AccessTools; - -namespace Multiplayer.Client.Patches; - -[HarmonyPatch(typeof(MainTabWindow_Quests), nameof(MainTabWindow_Quests.DoRow))] -public static class MainTabWindow_QuestsDoRowPatch -{ - public static void Prefix(ref Rect rect, Quest quest) - { - if (Multiplayer.Client != null && !Multiplayer.settings.hideOtherPlayersQuests) - { - Faction playerFaction; - if (quest.TryGetPlayerFaction(out playerFaction)) - { - Rect iconRect = new Rect(rect.x + 2f, rect.y + 2f, 4f, rect.height - 4f); - Widgets.DrawBoxSolid(iconRect, playerFaction.Color); - rect.xMin += 8f; - TooltipHandler.TipRegion(rect, "MpQuestDesc".Translate(playerFaction.Name, playerFaction == Faction.OfPlayer ? ". (you)" : ".")); - } - else - { - TooltipHandler.TipRegion(rect, "MpPublicQuest".Translate()); - } - } - } -} - -[HarmonyPatch(typeof(MainTabWindow_Quests), nameof(MainTabWindow_Quests.ShouldListNow))] -public static class MainTabWindow_QuestsShouldListNowPatch -{ - static bool Prefix(Quest quest, ref bool __result) - { - if (Multiplayer.Client != null && Multiplayer.settings.hideOtherPlayersQuests) - { - Faction playerFaction; - if (quest.TryGetPlayerFaction(out playerFaction) && playerFaction != Faction.OfPlayer) - { - __result = false; - return false; - } - } - return true; - } -} - -[HarmonyPatch(typeof(WorldObject), nameof(WorldObject.ShowRelatedQuests), MethodType.Getter)] -static class WorldObjectShowRelatedQuestsPatch -{ - static void Postfix(ref bool __result) - { - if (Multiplayer.Client != null && Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction) - __result = false; - } -} - -[HarmonyPatch(typeof(Settlement), nameof(Settlement.ShowRelatedQuests), MethodType.Getter)] -static class SettlementShowRelatedQuestsPatch -{ - static void Postfix(ref bool __result) - { - if (Multiplayer.Client != null && Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction) - __result = false; - } -} - -[HarmonyPatch(typeof(WorldInspectPane), nameof(WorldInspectPane.PaneTopY), MethodType.Getter)] -static class WorldInspectPanePaneTopYPatch -{ - static void Postfix(ref float __result) - { - if (Multiplayer.Client != null && Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction) - __result += 35f; - } -} - -[HarmonyPatch(typeof(MainTabWindow_Quests), nameof(MainTabWindow_Quests.DoRewardsPrefsButton))] -public static class MainTabWindow_QuestsDoRewardsPrefsButtonPatch -{ - public static bool Prefix(MainTabWindow_Quests __instance, Rect rect, out Rect __result) - { - if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) - { - __result = rect; - return true; - } - rect.yMin = rect.yMax - 40f; - - float buttonHeight = 40f; - - float rewardButtonWidth = rect.width * 0.5f - 5f; - float toggleButtonWidth = rect.width - rewardButtonWidth - 10f; - - Rect rewardRect = new Rect(rect.x, rect.yMax - buttonHeight, rewardButtonWidth, buttonHeight); - Text.Font = GameFont.Small; - if (Widgets.ButtonText(rewardRect, "ChooseRewards".Translate(), true, true, true)) - { - Find.WindowStack.Add(new Dialog_RewardPrefsConfig()); - } - - Rect toggleRect = new Rect(rewardRect.xMax + 10f, rewardRect.y, toggleButtonWidth, buttonHeight); - bool value = Multiplayer.settings.hideOtherPlayersQuests; - string label = value ? "MpHideQuestsOn".Translate() : "MpHideQuestsOff".Translate(); - if (Widgets.ButtonText(toggleRect, label, true, true)) - { - Multiplayer.settings.hideOtherPlayersQuests = !value; - SoundDefOf.Tick_High.PlayOneShotOnCamera(); - } - TooltipHandler.TipRegion(toggleRect, "MpHideQuestsDesc".Translate()); - - __result = rect; - return false; - } -} - -[HarmonyPatch(typeof(ColonistBar), nameof(ColonistBar.CheckRecacheEntries))] -public static class ColonistBarCheckRecacheEntriesPatch -{ - static IEnumerable Transpiler(IEnumerable insts) - { - var findMapsGetter = AccessTools.PropertyGetter(typeof(Find), nameof(Find.Maps)); - foreach (var inst in insts) - { - if (inst.Calls(findMapsGetter)) - { - yield return new CodeInstruction(OpCodes.Call, - AccessTools.Method(typeof(ColonistBarCheckRecacheEntriesPatch), nameof(GetFilteredMaps))); - } - else - { - yield return inst; - } - } - } - - public static IEnumerable GetFilteredMaps() - { - if (Multiplayer.Client == null || !Multiplayer.settings.hideOtherPlayersInColonistBar) - return Find.Maps; - - return Find.Maps.Where(map => - map.mapPawns.FreeColonistsSpawned.Any() || - map.Parent?.Faction == Faction.OfPlayer); - } -} - -[HarmonyPatch(typeof(WorldRoutePlanner), nameof(WorldRoutePlanner.DoRoutePlannerButton))] -public static class WorldRoutePlannerPatch -{ - static bool Prefix() - { - return Multiplayer.Client == null || Multiplayer.RealPlayerFaction != Multiplayer.WorldComp.spectatorFaction; - } -} - -[HarmonyPatch(typeof(WorldGizmoUtility), nameof(WorldGizmoUtility.TryGetCaravanGizmo))] -public static class WorldGizmoUtilityTryGetCaravanGizmoPatch -{ - static bool Prefix(ref Gizmo gizmo, ref bool __result) - { - if (Multiplayer.Client != null && Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction) - { - gizmo = null; - __result = false; - return false; - } - return true; - } -} - -[HarmonyPatch(typeof(MainButtonsRoot), nameof(MainButtonsRoot.DoButtons))] -static class MainButtonsRootDoButtonsPatch -{ - private static bool IsSpectator => - Multiplayer.Client != null && - Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction; - - private static readonly string[] SpectatorButtons = { "Menu", "World" }; - - private static readonly FieldRef> AllButtonsRef = AccessTools.FieldRefAccess>("allButtonsInOrder"); - - private static readonly Dictionary SpectatorButtonOrder = SpectatorButtons.Select((name, idx) => new { name, idx }).ToDictionary(x => x.name, x => x.idx); - - [HarmonyPrefix] - private static bool ReplaceOriginalDrawing(MainButtonsRoot __instance) - { - if (!IsSpectator) - return true; - - try - { - var allButtons = AllButtonsRef(__instance); - if (allButtons == null) - return true; - - var toDraw = SpectatorButtons - .Select(name => allButtons.Find(b => b.defName == name)) - .Where(b => b?.Worker.Visible ?? false) - .ToList(); - - DrawCornerButtons(toDraw); - return false; - } - catch (Exception ex) - { - Log.Error($"[MainButtonsRootPatch] ReplaceOriginalDrawing failed: {ex}"); - return true; - } - } - - [HarmonyPostfix] - private static void FilterButtons(MainButtonsRoot __instance) - { - try - { - var allButtons = AllButtonsRef(__instance); - if (allButtons == null) - return; - - if (IsSpectator) - { - // Keep only SpectatorButtons, sorted by our map - var filtered = allButtons - .Where(b => SpectatorButtonOrder.ContainsKey(b.defName)) - .OrderBy(b => SpectatorButtonOrder[b.defName]) - .ToList(); - - AllButtonsRef(__instance) = filtered; - } - else - { - // Restore original full list in normal mode - var original = DefDatabase.AllDefsListForReading - .Where(b => b.order >= 0) - .OrderBy(b => b.order) - .ToList(); - - AllButtonsRef(__instance) = original; - } - } - catch (Exception ex) - { - Log.Error($"[MainButtonsRootPatch] FilterButtons failed: {ex}"); - } - } - - private static void DrawCornerButtons(List buttons) - { - const float buttonHeight = 35f; - const float defaultPadding = 20f; - const float spacing = 0f; - - float x = UI.screenWidth; - float y = UI.screenHeight - buttonHeight; - - foreach (var btn in buttons) - { - float padding = btn.defName == "World" ? 120f : defaultPadding; - float width = btn.ShortenedLabelCapWidth + padding; - - x -= width; - btn.Worker.DoButton(new Rect(x, y, width, buttonHeight)); - x -= spacing; - } - } -} - - -[HarmonyPatch(typeof(Pawn_DraftController), nameof(Pawn_DraftController.GetGizmos))] -static class DisableDraftGizmo -{ - static IEnumerable Postfix(IEnumerable gizmos, Pawn_DraftController __instance) - { - return __instance.pawn.Faction == Faction.OfPlayer ? gizmos : Enumerable.Empty(); - } -} - -[HarmonyPatch(typeof(Pawn), nameof(Pawn.GetGizmos))] -static class PawnChangeRelationGizmo -{ - static IEnumerable Postfix(IEnumerable gizmos, Pawn __instance) - { - foreach (var gizmo in gizmos) - yield return gizmo; - - if (Multiplayer.Client == null || Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction) - yield break; - - if (__instance.Faction is { IsPlayer: true } && __instance.Faction != Faction.OfPlayer) - { - var otherFaction = __instance.Faction; - - yield return new Command_Action - { - defaultLabel = "Change faction relation", - icon = MultiplayerStatic.ChangeRelationIcon, - action = () => - { - List list = new List - { - new(FactionRelationKind.Hostile.ToString(), () => - { - SetRelation(otherFaction, FactionRelationKind.Hostile); - }), - new(FactionRelationKind.Neutral.ToString(), () => - { - SetRelation(otherFaction, FactionRelationKind.Neutral); - }) - }; - - Find.WindowStack.Add(new FloatMenu(list)); - } - }; - } - } - - [SyncMethod] - static void SetRelation(Faction other, FactionRelationKind kind) - { - Faction.OfPlayer.SetRelation(new FactionRelation(other, kind)); - - foreach (Map map in Find.Maps) - map.attackTargetsCache.Notify_FactionHostilityChanged(Faction.OfPlayer, other); - } -} - -[HarmonyPatch(typeof(SettlementDefeatUtility), nameof(SettlementDefeatUtility.CheckDefeated))] -static class CheckDefeatedPatch -{ - static bool Prefix(Settlement factionBase) - { - return factionBase.Faction is not { IsPlayer: true }; - } -} - -[HarmonyPatch(typeof(MapParent), nameof(MapParent.CheckRemoveMapNow))] -static class CheckRemoveMapNowPatch -{ - static bool Prefix(MapParent __instance) - { - if (Multiplayer.Client == null) return true; - if (__instance is Camp) return true; - - bool parentBelongsToNonPlayerFaction = __instance.Faction is not { IsPlayer: true }; - - return parentBelongsToNonPlayerFaction; - } -} - -[HarmonyPatch(typeof(Settlement), nameof(Settlement.Attackable), MethodType.Getter)] -static class SettlementAttackablePatch -{ - static bool Prefix(Settlement __instance) => __instance.Faction is not { IsPlayer: true }; -} - -[HarmonyPatch(typeof(Settlement), nameof(Settlement.Material), MethodType.Getter)] -static class SettlementNullFactionPatch1 -{ - static bool Prefix(Settlement __instance, ref Material __result) - { - if (__instance.factionInt == null) - { - __result = BaseContent.BadMat; - return false; - } - - return true; - } -} - -[HarmonyPatch(typeof(Settlement), nameof(Settlement.ExpandingIcon), MethodType.Getter)] -static class SettlementNullFactionPatch2 -{ - static bool Prefix(Settlement __instance, ref Texture2D __result) - { - if (__instance.factionInt == null) - { - __result = BaseContent.BadTex; - return false; - } - - return true; - } -} - -[HarmonyPatch(typeof(GoodwillSituationManager), nameof(GoodwillSituationManager.GetNaturalGoodwill))] -static class GetNaturalGoodwillPatch -{ - static bool Prefix(Faction other) - { - return other is not { IsPlayer: true }; - } -} - -[HarmonyPatch(typeof(GoodwillSituationManager), nameof(GoodwillSituationManager.GetMaxGoodwill))] -static class GetMaxGoodwillPatch -{ - static bool Prefix(Faction other) - { - return other is not { IsPlayer: true }; - } -} - -[HarmonyPatch(typeof(ScenPart_StartingAnimal), nameof(ScenPart_StartingAnimal.PetWeight))] -static class StartingAnimalPatch -{ - static IEnumerable Transpiler(IEnumerable insts) - { - var playerFactionField = AccessTools.Field(typeof(GameInitData), nameof(GameInitData.playerFaction)); - var factionOfPlayer = FactionOfPlayer; - - foreach (var inst in insts) - { - yield return inst; - - if (inst.operand as FieldInfo == playerFactionField) - yield return new CodeInstruction(OpCodes.Call, factionOfPlayer.Method); - } - } - - static Faction FactionOfPlayer(Faction f) - { - return Faction.OfPlayer; - } -} - -[HarmonyPatch(typeof(Pawn), nameof(Pawn.IsColonist), MethodType.Getter)] -static class PawnIsColonistPatch -{ - static IEnumerable Transpiler(IEnumerable insts) - { - var isPlayerMethodGetter = AccessTools.PropertyGetter(typeof(Faction), nameof(Faction.IsPlayer)); - var factionIsPlayer = FactionIsPlayer; - - foreach (var inst in insts) - { - if (inst.operand as MethodInfo == isPlayerMethodGetter) - inst.operand = factionIsPlayer.Method; - - yield return inst; - } - } - - static bool FactionIsPlayer(Faction f) - { - return f == Find.FactionManager.OfPlayer; - } -} - -[HarmonyPatch(typeof(Ideo), nameof(Ideo.RecacheColonistBelieverCount))] -static class RecacheColonistBelieverCountPatch -{ - private static MethodInfo allColonists = AccessTools.PropertyGetter(typeof(PawnsFinder), nameof(PawnsFinder.AllMapsCaravansAndTravellingTransporters_Alive_FreeColonists_NoCryptosleep)); - - static IEnumerable Transpiler(IEnumerable insts) - { - foreach (var inst in insts) - { - if (inst.operand as MethodInfo == allColonists) - inst.operand = AccessTools.Method(typeof(RecacheColonistBelieverCountPatch), nameof(ColonistsAllPlayerFactions)); - yield return inst; - } - } - - private static List colonistsAllFactions = new(); - - private static List ColonistsAllPlayerFactions() - { - colonistsAllFactions.Clear(); - - foreach (var p in PawnsFinder.AllMapsCaravansAndTravellingTransporters_Alive) - { - if (IsColonistAnyFaction(p) && p.HostFaction == null && !p.InCryptosleep) - colonistsAllFactions.Add(p); - } - - return colonistsAllFactions; - } - - // Note: IsColonist is patched above to compare to Faction.OfPlayer - public static bool IsColonistAnyFaction(Pawn p) - { - if (p.Faction is { IsPlayer: true } && p.RaceProps.Humanlike) - return !p.IsSlave || p.guest.SlaveIsSecure; - return false; - } - - public static bool IsColonyMechAnyFaction(Pawn p) - { - if (ModsConfig.BiotechActive && p.RaceProps.IsMechanoid && p.Faction is { IsPlayer: true } && p.MentalStateDef == null) - return p.HostFaction == null || p.IsSlave; - return false; - } - - public static bool IsColonySubhumanAnyFaction(Pawn p) - { - return p.IsSubhuman && p.Faction is { IsPlayer: true }; - } -} - -[HarmonyPatch(typeof(MapPawns), nameof(MapPawns.AnyPawnBlockingMapRemoval), MethodType.Getter)] -static class AnyPawnBlockingMapRemovalPatch -{ - private static MethodInfo isColonist = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsColonist)); - private static MethodInfo isColonyMech = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsColonyMech)); - - static IEnumerable Transpiler(IEnumerable insts) - { - foreach (var inst in insts) - { - if (inst.operand as MethodInfo == isColonyMech) - inst.operand = AccessTools.Method(typeof(RecacheColonistBelieverCountPatch), nameof(RecacheColonistBelieverCountPatch.IsColonyMechAnyFaction)); - - yield return inst; - } - } - - static void Postfix(MapPawns __instance, ref bool __result) - { - for (int i = 0; i < __instance.pawnsSpawned.Count; i++) - { - var p = __instance.pawnsSpawned[i]; - if (p.Faction is { IsPlayer: true } || p.HostFaction is { IsPlayer: true }) - { - Job curJob = p.CurJob; - if (curJob is { exitMapOnArrival: true }) - { - __result = true; - break; - } - - if (p.health.hediffSet.InLabor()) - { - __result = true; - break; - } - } - } - } -} - -[HarmonyPatch(typeof(MapPawns), nameof(MapPawns.IsValidColonyPawn))] -static class IsValidColonyPawnPatch -{ - private static MethodInfo isColonist = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsColonist)); - private static MethodInfo isColonySubhuman = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsColonySubhuman)); - - static IEnumerable Transpiler(IEnumerable insts) - { - foreach (var inst in insts) - { - if (inst.operand as MethodInfo == isColonist) - inst.operand = AccessTools.Method(typeof(RecacheColonistBelieverCountPatch), nameof(RecacheColonistBelieverCountPatch.IsColonistAnyFaction)); - - if (inst.operand as MethodInfo == isColonySubhuman) - inst.operand = AccessTools.Method(typeof(RecacheColonistBelieverCountPatch), nameof(RecacheColonistBelieverCountPatch.IsColonySubhumanAnyFaction)); - - yield return inst; - } - } -} - -[HarmonyPatch(typeof(Precept_Role), nameof(Precept_Role.ValidatePawn))] -static class ValidatePawnPatch -{ - private static MethodInfo isFreeNonSlaveColonist = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsFreeNonSlaveColonist)); - - static IEnumerable Transpiler(IEnumerable insts) - { - foreach (var inst in insts) - { - if (inst.operand as MethodInfo == isFreeNonSlaveColonist) - inst.operand = AccessTools.Method(typeof(ValidatePawnPatch), nameof(IsFreeNonSlaveColonistAnyFaction)); - - yield return inst; - } - } - - public static bool IsFreeNonSlaveColonistAnyFaction(Pawn p) - { - return p.Faction is { IsPlayer: true } && p.RaceProps.Humanlike && p.HostFaction == null && !p.IsSlave; - } -} - -[HarmonyPatch(typeof(Faction), nameof(Faction.HasGoodwill), MethodType.Getter)] -static class PlayerFactionsHaveGoodwill -{ - static void Postfix(Faction __instance, ref bool __result) - { - if (__instance.IsPlayer) - __result = true; - } -} - -[HarmonyPatch(typeof(GenHostility), nameof(GenHostility.IsActiveThreatToPlayer))] -static class IsActiveThreatToAnyPlayer -{ - static void Postfix(IAttackTarget target, ref bool __result) - { - foreach (var f in Find.FactionManager.AllFactions) - if (f.IsPlayer) - __result |= GenHostility.IsActiveThreatTo(target, f); - } -} - -[HarmonyPatch] -static class TakeToBedGuestFactionPatch -{ - static IEnumerable TargetMethods() - { - yield return AccessTools.DeclaredMethod(typeof(JobDriver_TakeToBed), nameof(JobDriver_TakeToBed.CheckMakeTakeeGuest)); - yield return AccessTools.DeclaredMethod(typeof(JobDriver_CarryDownedPawn), nameof(JobDriver_CarryDownedPawn.CheckMakeTakeeGuest)); - } - static bool Prefix(JobDriver __instance) - { - var takee = __instance.job.GetTarget(TargetIndex.A).Pawn; - return takee?.Faction?.IsPlayer != true && takee?.HostFaction?.IsPlayer != true; - } -} - - -[HarmonyPatch(typeof(LetterStack), nameof(LetterStack.ReceiveLetter), typeof(Letter), typeof(string), typeof(int), typeof(bool))] -static class LetterStackReceiveOnlyMyFaction -{ - // todo the letter might get culled from the archive if it isn't in the stack and Sync depends on the archive - static void Postfix(LetterStack __instance, Letter let) - { - if (Multiplayer.RealPlayerFaction != Faction.OfPlayer) - __instance.letters.Remove(let); - } -} - -[HarmonyPatch(typeof(LetterStack), nameof(LetterStack.ReceiveLetter), typeof(Letter), typeof(string), typeof(int), typeof(bool))] -static class LetterStackReceiveSoundOnlyMyFaction -{ - private static MethodInfo PlayOneShotOnCamera = - typeof(SoundStarter).GetMethod(nameof(SoundStarter.PlayOneShotOnCamera)); - - static IEnumerable Transpiler(IEnumerable insts) - { - foreach (var inst in insts) - { - if (inst.operand as MethodInfo == PlayOneShotOnCamera) - yield return new CodeInstruction( - OpCodes.Call, - SymbolExtensions.GetMethodInfo((SoundDef s, Map m) => PlaySoundReplacement(s, m))); - else - yield return inst; - } - } - - static void PlaySoundReplacement(SoundDef sound, Map map) - { - if (Multiplayer.RealPlayerFaction == Faction.OfPlayer) - sound.PlayOneShotOnCamera(map); - } -} - -[HarmonyPatch(typeof(Apparel), nameof(Apparel.WornGraphicPath), MethodType.Getter)] -static class ApparelWornGraphicPathGetterPatch -{ - private static FieldInfo thingIDNumberField = AccessTools.Field(typeof(Thing), nameof(Thing.thingIDNumber)); - - static IEnumerable Transpiler(IEnumerable insts) - { - foreach (var inst in insts) - { - yield return inst; - - // This instruction is part of wornGraphicPaths[thingIDNumber % wornGraphicPaths.Count] - // The function makes sure the id is positive - if (inst.operand as FieldInfo == thingIDNumberField) - yield return new CodeInstruction(OpCodes.Call, - AccessTools.Method(typeof(ApparelWornGraphicPathGetterPatch), nameof(MakeIdPositive))); - } - } - - private static int MakeIdPositive(int id) - { - return id < 0 ? -id : id; - } -} - -#region Map generation - -[HarmonyPatch(typeof(GenStep_Monolith), nameof(GenStep_Monolith.GenerateMonolith))] -public static class GenStepMonolithPatch -{ - static bool Prefix() - { - if (Multiplayer.Client == null) - return true; - - var anomalyComp = Current.Game.components.OfType().FirstOrDefault(); - - return anomalyComp?.monolith == null; - } -} - -[HarmonyPatch(typeof(ScenPart_ScatterThings), nameof(ScenPart_ScatterThings.GenerateIntoMap))] -static class ScenPartScatterThingsPatch -{ - static bool Prefix() - { - return Multiplayer.Client == null || FactionCreator.generatingMap; - } -} - -[HarmonyPatch(typeof(ScenPart_PlayerPawnsArriveMethod), nameof(ScenPart_PlayerPawnsArriveMethod.GenerateIntoMap))] -static class ScenPartPlayerPawnsArriveMethodPatch -{ - static bool Prefix() - { - return Multiplayer.Client == null || FactionCreator.generatingMap; - } -} - -#endregion - -[HarmonyPatch(typeof(CharacterCardUtility), nameof(CharacterCardUtility.DoTopStack))] -static class CharacterCardUtilityDontDrawIdeoPlate -{ - private static FieldInfo classicModeField = AccessTools.Field(typeof(IdeoManager), nameof(IdeoManager.classicMode)); - - static IEnumerable Transpiler(IEnumerable insts) - { - foreach (var inst in insts) - { - yield return inst; - - // Don't draw the ideo plate while choosing starting pawns in multifaction - if (inst.operand as FieldInfo == classicModeField) - { - yield return new CodeInstruction(OpCodes.Ldarg_2); - yield return new CodeInstruction(OpCodes.Call, - AccessTools.Method(typeof(CharacterCardUtilityDontDrawIdeoPlate), nameof(DontDrawIdeoPlate))); - yield return new CodeInstruction(OpCodes.Or); - } - } - } - - private static bool DontDrawIdeoPlate(bool generating) - { - return Multiplayer.Client != null && generating; - } -} - -[HarmonyPatch(typeof(CompShuttle), nameof(CompShuttle.ContainedColonistCount), MethodType.Getter)] -static class CompShuttle_ContainedColonistCount_Patch -{ - static IEnumerable Transpiler(IEnumerable insts) - { - var isFreeColonist = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsFreeColonist)); - var replacement = AccessTools.Method(typeof(CompShuttle_ContainedColonistCount_Patch), - nameof(IsFreeColonistAnyPlayerFaction)); - - foreach (var ci in insts) - { - if (ci.Calls(isFreeColonist)) - { - ci.opcode = OpCodes.Call; - ci.operand = replacement; - } - yield return ci; - } - } - public static bool IsFreeColonistAnyPlayerFaction(Pawn pawn) - { - if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) - return pawn.IsFreeColonist; - - return pawn.Faction != null && - pawn.Faction.IsPlayer && - pawn.RaceProps.Humanlike && - (!pawn.IsSlave || pawn.guest.SlaveIsSecure) && - !pawn.IsSubhuman && - pawn.HostFaction == null; - } - -} - -[HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.RemoveGravshipFromMap))] -static class RemoveGravshipFromMap_Patch -{ - static void Prefix(Building_GravEngine engine, out bool __state) - { - __state = false; - if (Multiplayer.Client == null) return; - if (!Multiplayer.GameComp.multifaction) return; - if (engine == null) return; - - engine.Map.PushFaction(engine.Faction); - - __state = true; - } - - static void Finalizer(Building_GravEngine engine, bool __state) - { - if (!__state) return; - engine.Map.PopFaction(); - } -} - -[HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.PlaceGravship))] -static class PlaceGravship_FactionContext_Patch -{ - static void Prefix(Gravship gravship, Map map, out bool __state) - { - __state = false; - if (Multiplayer.Client == null) return; - if (!Multiplayer.GameComp.multifaction) return; - - map.PushFaction(gravship.Faction); - - __state = true; - } - - static void Finalizer(Gravship gravship, Map map, bool __state) - { - if (!__state) return; - map.PopFaction(); - } -} - -[HarmonyPatch(typeof(QuestPart_LendColonistsToFaction), nameof(QuestPart_LendColonistsToFaction.Enable))] -static class QuestPart_LendColonistsToFaction_Enable_Patch -{ - static IEnumerable Transpiler(IEnumerable insts) - { - var isFreeColonist = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsFreeColonist)); - var replacement = AccessTools.Method(typeof(CompShuttle_ContainedColonistCount_Patch), - nameof(CompShuttle_ContainedColonistCount_Patch.IsFreeColonistAnyPlayerFaction)); - foreach (var ci in insts) - { - if (ci.Calls(isFreeColonist)) - { - ci.opcode = OpCodes.Call; - ci.operand = replacement; - } - yield return ci; - } - } -} - -[HarmonyPatch(typeof(Map), nameof(Map.IsPlayerHome), MethodType.Getter)] -static class Map_IsPlayerHome_Spectator_Patch -{ - static bool Prefix(Map __instance, ref bool __result) - { - if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction || - Faction.OfPlayer != Multiplayer.WorldComp.spectatorFaction) - return true; - - { - // Is repeat through all player faction a better idea? - if (!__instance.wasSpawnedViaGravShipLanding) - { - MapInfo mapInfo = __instance.info; - if (((mapInfo != null) ? mapInfo.parent : null) == null || __instance.info.parent.Faction?.IsPlayer != true || !__instance.info.parent.def.canBePlayerHome) - { - __result = GravshipUtility.PlayerHasGravEngine(__instance); - return false; - } - } - __result = true; - return false; - } - } -} - -[HarmonyPatch] -static class Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch -{ - static MethodInfo TargetMethod() - { - return AccessTools.EnumeratorMoveNext(AccessTools.Method(typeof(Pawn_TraderTracker), nameof(Pawn_TraderTracker.ColonyThingsWillingToBuy))); - } - public static List AllBuildingsColonistOfDefOfPlayer(this ListerBuildings lister, ThingDef def) - { - if (def.CanHaveFaction) - return lister.AllBuildingsColonistOfDef(def).FindAll(building => building.Faction == Faction.OfPlayer); - else - return lister.AllBuildingsColonistOfDef(def); - } - public static IEnumerable AllColonistBuildingsOfTypeOfPlayer(this ListerBuildings lister) - { - return lister.AllColonistBuildingsOfType().Where(t => - { - if (t is Building building && (!building.def.CanHaveFaction || building.Faction == Faction.OfPlayer)) - return true; - return false; - }); - } - static IEnumerable Transpiler(IEnumerable insts) - { - var allBuildingsColonistOfDef = AccessTools.Method(typeof(ListerBuildings), nameof(ListerBuildings.AllBuildingsColonistOfDef)); - var allBuildingsColonistOfDefOfPlayer = AccessTools.Method(typeof(Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch), nameof(Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch.AllBuildingsColonistOfDefOfPlayer)); - var allColonistBuildingsOfType = AccessTools.Method(typeof(ListerBuildings), nameof(ListerBuildings.AllColonistBuildingsOfType)).MakeGenericMethod([typeof(IHaulSource)]); - var allColonistBuildingsOfTypeOfPlayer = AccessTools.Method(typeof(Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch), nameof(Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch.AllColonistBuildingsOfTypeOfPlayer)).MakeGenericMethod([typeof(IHaulSource)]); - foreach (var ci in insts) - { - if (ci.Calls(allBuildingsColonistOfDef)) - { - ci.opcode = OpCodes.Call; - ci.operand = allBuildingsColonistOfDefOfPlayer; - } - else if (ci.Calls(allColonistBuildingsOfType)) - { - ci.opcode = OpCodes.Call; - ci.operand = allColonistBuildingsOfTypeOfPlayer; - } - yield return ci; - } - } -} - -[HarmonyPatch] -static class TradeUtility_AllLaunchableThingsForTrade_Patch -{ - static MethodInfo TargetMethod() - { - return AccessTools.EnumeratorMoveNext(AccessTools.Method(typeof(TradeUtility), nameof(TradeUtility.AllLaunchableThingsForTrade))); - } - - public static IEnumerable AllPoweredOfPlayer(Map map) - { - return Building_OrbitalTradeBeacon.AllPowered(map).Where(beacon => beacon.Faction == Faction.OfPlayer); - } - static IEnumerable Transpiler(IEnumerable insts) - { - var allPowered = AccessTools.Method(typeof(Building_OrbitalTradeBeacon), nameof(Building_OrbitalTradeBeacon.AllPowered)); - var allPoweredOfPlayer = AccessTools.Method(typeof(TradeUtility_AllLaunchableThingsForTrade_Patch), nameof(TradeUtility_AllLaunchableThingsForTrade_Patch.AllPoweredOfPlayer)); - foreach (var ci in insts) - { - if (ci.Calls(allPowered)) - { - ci.opcode = OpCodes.Call; - ci.operand = allPoweredOfPlayer; - } - - yield return ci; - } - } -} +using HarmonyLib; +using KTrie; +using Multiplayer.API; +using Multiplayer.Client.Factions; +using RimWorld; +using RimWorld.Planet; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; + +using UnityEngine; +using Verse; +using Verse.AI; +using Verse.Sound; +using static HarmonyLib.AccessTools; + +namespace Multiplayer.Client.Patches; + +[HarmonyPatch(typeof(MainTabWindow_Quests), nameof(MainTabWindow_Quests.DoRow))] +public static class MainTabWindow_QuestsDoRowPatch +{ + public static void Prefix(ref Rect rect, Quest quest) + { + if (Multiplayer.Client != null && !Multiplayer.settings.hideOtherPlayersQuests) + { + Faction playerFaction; + if (quest.TryGetPlayerFaction(out playerFaction)) + { + Rect iconRect = new Rect(rect.x + 2f, rect.y + 2f, 4f, rect.height - 4f); + Widgets.DrawBoxSolid(iconRect, playerFaction.Color); + rect.xMin += 8f; + TooltipHandler.TipRegion(rect, "MpQuestDesc".Translate(playerFaction.Name, playerFaction == Faction.OfPlayer ? ". (you)" : ".")); + } + else + { + TooltipHandler.TipRegion(rect, "MpPublicQuest".Translate()); + } + } + } +} + +[HarmonyPatch(typeof(MainTabWindow_Quests), nameof(MainTabWindow_Quests.ShouldListNow))] +public static class MainTabWindow_QuestsShouldListNowPatch +{ + static bool Prefix(Quest quest, ref bool __result) + { + if (Multiplayer.Client != null && Multiplayer.settings.hideOtherPlayersQuests) + { + Faction playerFaction; + if (quest.TryGetPlayerFaction(out playerFaction) && playerFaction != Faction.OfPlayer) + { + __result = false; + return false; + } + } + return true; + } +} + +[HarmonyPatch(typeof(WorldObject), nameof(WorldObject.ShowRelatedQuests), MethodType.Getter)] +static class WorldObjectShowRelatedQuestsPatch +{ + static void Postfix(ref bool __result) + { + if (Multiplayer.Client != null && Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction) + __result = false; + } +} + +[HarmonyPatch(typeof(Settlement), nameof(Settlement.ShowRelatedQuests), MethodType.Getter)] +static class SettlementShowRelatedQuestsPatch +{ + static void Postfix(ref bool __result) + { + if (Multiplayer.Client != null && Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction) + __result = false; + } +} + +[HarmonyPatch(typeof(WorldInspectPane), nameof(WorldInspectPane.PaneTopY), MethodType.Getter)] +static class WorldInspectPanePaneTopYPatch +{ + static void Postfix(ref float __result) + { + if (Multiplayer.Client != null && Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction) + __result += 35f; + } +} + +[HarmonyPatch(typeof(MainTabWindow_Quests), nameof(MainTabWindow_Quests.DoRewardsPrefsButton))] +public static class MainTabWindow_QuestsDoRewardsPrefsButtonPatch +{ + public static bool Prefix(MainTabWindow_Quests __instance, Rect rect, out Rect __result) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + { + __result = rect; + return true; + } + rect.yMin = rect.yMax - 40f; + + float buttonHeight = 40f; + + float rewardButtonWidth = rect.width * 0.5f - 5f; + float toggleButtonWidth = rect.width - rewardButtonWidth - 10f; + + Rect rewardRect = new Rect(rect.x, rect.yMax - buttonHeight, rewardButtonWidth, buttonHeight); + Text.Font = GameFont.Small; + if (Widgets.ButtonText(rewardRect, "ChooseRewards".Translate(), true, true, true)) + { + Find.WindowStack.Add(new Dialog_RewardPrefsConfig()); + } + + Rect toggleRect = new Rect(rewardRect.xMax + 10f, rewardRect.y, toggleButtonWidth, buttonHeight); + bool value = Multiplayer.settings.hideOtherPlayersQuests; + string label = value ? "MpHideQuestsOn".Translate() : "MpHideQuestsOff".Translate(); + if (Widgets.ButtonText(toggleRect, label, true, true)) + { + Multiplayer.settings.hideOtherPlayersQuests = !value; + SoundDefOf.Tick_High.PlayOneShotOnCamera(); + } + TooltipHandler.TipRegion(toggleRect, "MpHideQuestsDesc".Translate()); + + __result = rect; + return false; + } +} + +[HarmonyPatch(typeof(ColonistBar), nameof(ColonistBar.CheckRecacheEntries))] +public static class ColonistBarCheckRecacheEntriesPatch +{ + static IEnumerable Transpiler(IEnumerable insts) + { + var findMapsGetter = AccessTools.PropertyGetter(typeof(Find), nameof(Find.Maps)); + foreach (var inst in insts) + { + if (inst.Calls(findMapsGetter)) + { + yield return new CodeInstruction(OpCodes.Call, + AccessTools.Method(typeof(ColonistBarCheckRecacheEntriesPatch), nameof(GetFilteredMaps))); + } + else + { + yield return inst; + } + } + } + + public static IEnumerable GetFilteredMaps() + { + if (Multiplayer.Client == null || !Multiplayer.settings.hideOtherPlayersInColonistBar) + return Find.Maps; + + return Find.Maps.Where(map => + map.mapPawns.FreeColonistsSpawned.Any() || + map.Parent?.Faction == Faction.OfPlayer); + } +} + +[HarmonyPatch(typeof(WorldRoutePlanner), nameof(WorldRoutePlanner.DoRoutePlannerButton))] +public static class WorldRoutePlannerPatch +{ + static bool Prefix() + { + return Multiplayer.Client == null || Multiplayer.RealPlayerFaction != Multiplayer.WorldComp.spectatorFaction; + } +} + +[HarmonyPatch(typeof(WorldGizmoUtility), nameof(WorldGizmoUtility.TryGetCaravanGizmo))] +public static class WorldGizmoUtilityTryGetCaravanGizmoPatch +{ + static bool Prefix(ref Gizmo gizmo, ref bool __result) + { + if (Multiplayer.Client != null && Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction) + { + gizmo = null; + __result = false; + return false; + } + return true; + } +} + +[HarmonyPatch(typeof(MainButtonsRoot), nameof(MainButtonsRoot.DoButtons))] +static class MainButtonsRootDoButtonsPatch +{ + private static bool IsSpectator => + Multiplayer.Client != null && + Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction; + + private static readonly string[] SpectatorButtons = { "Menu", "World" }; + + private static readonly FieldRef> AllButtonsRef = AccessTools.FieldRefAccess>("allButtonsInOrder"); + + private static readonly Dictionary SpectatorButtonOrder = SpectatorButtons.Select((name, idx) => new { name, idx }).ToDictionary(x => x.name, x => x.idx); + + [HarmonyPrefix] + private static bool ReplaceOriginalDrawing(MainButtonsRoot __instance) + { + if (!IsSpectator) + return true; + + try + { + var allButtons = AllButtonsRef(__instance); + if (allButtons == null) + return true; + + var toDraw = SpectatorButtons + .Select(name => allButtons.Find(b => b.defName == name)) + .Where(b => b?.Worker.Visible ?? false) + .ToList(); + + DrawCornerButtons(toDraw); + return false; + } + catch (Exception ex) + { + Log.Error($"[MainButtonsRootPatch] ReplaceOriginalDrawing failed: {ex}"); + return true; + } + } + + [HarmonyPostfix] + private static void FilterButtons(MainButtonsRoot __instance) + { + try + { + var allButtons = AllButtonsRef(__instance); + if (allButtons == null) + return; + + if (IsSpectator) + { + // Keep only SpectatorButtons, sorted by our map + var filtered = allButtons + .Where(b => SpectatorButtonOrder.ContainsKey(b.defName)) + .OrderBy(b => SpectatorButtonOrder[b.defName]) + .ToList(); + + AllButtonsRef(__instance) = filtered; + } + else + { + // Restore original full list in normal mode + var original = DefDatabase.AllDefsListForReading + .Where(b => b.order >= 0) + .OrderBy(b => b.order) + .ToList(); + + AllButtonsRef(__instance) = original; + } + } + catch (Exception ex) + { + Log.Error($"[MainButtonsRootPatch] FilterButtons failed: {ex}"); + } + } + + private static void DrawCornerButtons(List buttons) + { + const float buttonHeight = 35f; + const float defaultPadding = 20f; + const float spacing = 0f; + + float x = UI.screenWidth; + float y = UI.screenHeight - buttonHeight; + + foreach (var btn in buttons) + { + float padding = btn.defName == "World" ? 120f : defaultPadding; + float width = btn.ShortenedLabelCapWidth + padding; + + x -= width; + btn.Worker.DoButton(new Rect(x, y, width, buttonHeight)); + x -= spacing; + } + } +} + + +[HarmonyPatch(typeof(Pawn_DraftController), nameof(Pawn_DraftController.GetGizmos))] +static class DisableDraftGizmo +{ + static IEnumerable Postfix(IEnumerable gizmos, Pawn_DraftController __instance) + { + return __instance.pawn.Faction == Faction.OfPlayer ? gizmos : Enumerable.Empty(); + } +} + +[HarmonyPatch(typeof(Pawn), nameof(Pawn.GetGizmos))] +static class PawnChangeRelationGizmo +{ + static IEnumerable Postfix(IEnumerable gizmos, Pawn __instance) + { + foreach (var gizmo in gizmos) + yield return gizmo; + + if (Multiplayer.Client == null || Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction) + yield break; + + if (__instance.Faction is { IsPlayer: true } && __instance.Faction != Faction.OfPlayer) + { + var otherFaction = __instance.Faction; + + yield return new Command_Action + { + defaultLabel = "Change faction relation", + icon = MultiplayerStatic.ChangeRelationIcon, + action = () => + { + List list = new List + { + new(FactionRelationKind.Hostile.ToString(), () => + { + SetRelation(otherFaction, FactionRelationKind.Hostile); + }), + new(FactionRelationKind.Neutral.ToString(), () => + { + SetRelation(otherFaction, FactionRelationKind.Neutral); + }) + }; + + Find.WindowStack.Add(new FloatMenu(list)); + } + }; + } + } + + [SyncMethod] + static void SetRelation(Faction other, FactionRelationKind kind) + { + Faction.OfPlayer.SetRelation(new FactionRelation(other, kind)); + + foreach (Map map in Find.Maps) + map.attackTargetsCache.Notify_FactionHostilityChanged(Faction.OfPlayer, other); + } +} + +[HarmonyPatch(typeof(SettlementDefeatUtility), nameof(SettlementDefeatUtility.CheckDefeated))] +static class CheckDefeatedPatch +{ + static bool Prefix(Settlement factionBase) + { + return factionBase.Faction is not { IsPlayer: true }; + } +} + +[HarmonyPatch(typeof(MapParent), nameof(MapParent.CheckRemoveMapNow))] +static class CheckRemoveMapNowPatch +{ + static bool Prefix(MapParent __instance) + { + if (Multiplayer.Client == null) return true; + if (__instance is Camp) return true; + + bool parentBelongsToNonPlayerFaction = __instance.Faction is not { IsPlayer: true }; + + return parentBelongsToNonPlayerFaction; + } +} + +[HarmonyPatch(typeof(Settlement), nameof(Settlement.Attackable), MethodType.Getter)] +static class SettlementAttackablePatch +{ + static bool Prefix(Settlement __instance) => __instance.Faction is not { IsPlayer: true }; +} + +[HarmonyPatch(typeof(Settlement), nameof(Settlement.Material), MethodType.Getter)] +static class SettlementNullFactionPatch1 +{ + static bool Prefix(Settlement __instance, ref Material __result) + { + if (__instance.factionInt == null) + { + __result = BaseContent.BadMat; + return false; + } + + return true; + } +} + +[HarmonyPatch(typeof(Settlement), nameof(Settlement.ExpandingIcon), MethodType.Getter)] +static class SettlementNullFactionPatch2 +{ + static bool Prefix(Settlement __instance, ref Texture2D __result) + { + if (__instance.factionInt == null) + { + __result = BaseContent.BadTex; + return false; + } + + return true; + } +} + +[HarmonyPatch(typeof(GoodwillSituationManager), nameof(GoodwillSituationManager.GetNaturalGoodwill))] +static class GetNaturalGoodwillPatch +{ + static bool Prefix(Faction other) + { + return other is not { IsPlayer: true }; + } +} + +[HarmonyPatch(typeof(GoodwillSituationManager), nameof(GoodwillSituationManager.GetMaxGoodwill))] +static class GetMaxGoodwillPatch +{ + static bool Prefix(Faction other) + { + return other is not { IsPlayer: true }; + } +} + +[HarmonyPatch(typeof(ScenPart_StartingAnimal), nameof(ScenPart_StartingAnimal.PetWeight))] +static class StartingAnimalPatch +{ + static IEnumerable Transpiler(IEnumerable insts) + { + var playerFactionField = AccessTools.Field(typeof(GameInitData), nameof(GameInitData.playerFaction)); + var factionOfPlayer = FactionOfPlayer; + + foreach (var inst in insts) + { + yield return inst; + + if (inst.operand as FieldInfo == playerFactionField) + yield return new CodeInstruction(OpCodes.Call, factionOfPlayer.Method); + } + } + + static Faction FactionOfPlayer(Faction f) + { + return Faction.OfPlayer; + } +} + +[HarmonyPatch(typeof(Pawn), nameof(Pawn.IsColonist), MethodType.Getter)] +static class PawnIsColonistPatch +{ + static IEnumerable Transpiler(IEnumerable insts) + { + var isPlayerMethodGetter = AccessTools.PropertyGetter(typeof(Faction), nameof(Faction.IsPlayer)); + var factionIsPlayer = FactionIsPlayer; + + foreach (var inst in insts) + { + if (inst.operand as MethodInfo == isPlayerMethodGetter) + inst.operand = factionIsPlayer.Method; + + yield return inst; + } + } + + static bool FactionIsPlayer(Faction f) + { + return f == Find.FactionManager.OfPlayer; + } +} + +[HarmonyPatch(typeof(Ideo), nameof(Ideo.RecacheColonistBelieverCount))] +static class RecacheColonistBelieverCountPatch +{ + private static MethodInfo allColonists = AccessTools.PropertyGetter(typeof(PawnsFinder), nameof(PawnsFinder.AllMapsCaravansAndTravellingTransporters_Alive_FreeColonists_NoCryptosleep)); + + static IEnumerable Transpiler(IEnumerable insts) + { + foreach (var inst in insts) + { + if (inst.operand as MethodInfo == allColonists) + inst.operand = AccessTools.Method(typeof(RecacheColonistBelieverCountPatch), nameof(ColonistsAllPlayerFactions)); + yield return inst; + } + } + + private static List colonistsAllFactions = new(); + + private static List ColonistsAllPlayerFactions() + { + colonistsAllFactions.Clear(); + + foreach (var p in PawnsFinder.AllMapsCaravansAndTravellingTransporters_Alive) + { + if (IsColonistAnyFaction(p) && p.HostFaction == null && !p.InCryptosleep) + colonistsAllFactions.Add(p); + } + + return colonistsAllFactions; + } + + // Note: IsColonist is patched above to compare to Faction.OfPlayer + public static bool IsColonistAnyFaction(Pawn p) + { + if (p.Faction is { IsPlayer: true } && p.RaceProps.Humanlike) + return !p.IsSlave || p.guest.SlaveIsSecure; + return false; + } + + public static bool IsColonyMechAnyFaction(Pawn p) + { + if (ModsConfig.BiotechActive && p.RaceProps.IsMechanoid && p.Faction is { IsPlayer: true } && p.MentalStateDef == null) + return p.HostFaction == null || p.IsSlave; + return false; + } + + public static bool IsColonySubhumanAnyFaction(Pawn p) + { + return p.IsSubhuman && p.Faction is { IsPlayer: true }; + } +} + +[HarmonyPatch(typeof(MapPawns), nameof(MapPawns.AnyPawnBlockingMapRemoval), MethodType.Getter)] +static class AnyPawnBlockingMapRemovalPatch +{ + private static MethodInfo isColonist = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsColonist)); + private static MethodInfo isColonyMech = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsColonyMech)); + + static IEnumerable Transpiler(IEnumerable insts) + { + foreach (var inst in insts) + { + if (inst.operand as MethodInfo == isColonyMech) + inst.operand = AccessTools.Method(typeof(RecacheColonistBelieverCountPatch), nameof(RecacheColonistBelieverCountPatch.IsColonyMechAnyFaction)); + + yield return inst; + } + } + + static void Postfix(MapPawns __instance, ref bool __result) + { + for (int i = 0; i < __instance.pawnsSpawned.Count; i++) + { + var p = __instance.pawnsSpawned[i]; + if (p.Faction is { IsPlayer: true } || p.HostFaction is { IsPlayer: true }) + { + Job curJob = p.CurJob; + if (curJob is { exitMapOnArrival: true }) + { + __result = true; + break; + } + + if (p.health.hediffSet.InLabor()) + { + __result = true; + break; + } + } + } + } +} + +[HarmonyPatch(typeof(MapPawns), nameof(MapPawns.IsValidColonyPawn))] +static class IsValidColonyPawnPatch +{ + private static MethodInfo isColonist = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsColonist)); + private static MethodInfo isColonySubhuman = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsColonySubhuman)); + + static IEnumerable Transpiler(IEnumerable insts) + { + foreach (var inst in insts) + { + if (inst.operand as MethodInfo == isColonist) + inst.operand = AccessTools.Method(typeof(RecacheColonistBelieverCountPatch), nameof(RecacheColonistBelieverCountPatch.IsColonistAnyFaction)); + + if (inst.operand as MethodInfo == isColonySubhuman) + inst.operand = AccessTools.Method(typeof(RecacheColonistBelieverCountPatch), nameof(RecacheColonistBelieverCountPatch.IsColonySubhumanAnyFaction)); + + yield return inst; + } + } +} + +[HarmonyPatch(typeof(Precept_Role), nameof(Precept_Role.ValidatePawn))] +static class ValidatePawnPatch +{ + private static MethodInfo isFreeNonSlaveColonist = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsFreeNonSlaveColonist)); + + static IEnumerable Transpiler(IEnumerable insts) + { + foreach (var inst in insts) + { + if (inst.operand as MethodInfo == isFreeNonSlaveColonist) + inst.operand = AccessTools.Method(typeof(ValidatePawnPatch), nameof(IsFreeNonSlaveColonistAnyFaction)); + + yield return inst; + } + } + + public static bool IsFreeNonSlaveColonistAnyFaction(Pawn p) + { + return p.Faction is { IsPlayer: true } && p.RaceProps.Humanlike && p.HostFaction == null && !p.IsSlave; + } +} + +[HarmonyPatch(typeof(Faction), nameof(Faction.HasGoodwill), MethodType.Getter)] +static class PlayerFactionsHaveGoodwill +{ + static void Postfix(Faction __instance, ref bool __result) + { + if (__instance.IsPlayer) + __result = true; + } +} + +[HarmonyPatch(typeof(GenHostility), nameof(GenHostility.IsActiveThreatToPlayer))] +static class IsActiveThreatToAnyPlayer +{ + static void Postfix(IAttackTarget target, ref bool __result) + { + foreach (var f in Find.FactionManager.AllFactions) + if (f.IsPlayer) + __result |= GenHostility.IsActiveThreatTo(target, f); + } +} + +[HarmonyPatch] +static class TakeToBedGuestFactionPatch +{ + static IEnumerable TargetMethods() + { + yield return AccessTools.DeclaredMethod(typeof(JobDriver_TakeToBed), nameof(JobDriver_TakeToBed.CheckMakeTakeeGuest)); + yield return AccessTools.DeclaredMethod(typeof(JobDriver_CarryDownedPawn), nameof(JobDriver_CarryDownedPawn.CheckMakeTakeeGuest)); + } + static bool Prefix(JobDriver __instance) + { + var takee = __instance.job.GetTarget(TargetIndex.A).Pawn; + return takee?.Faction?.IsPlayer != true && takee?.HostFaction?.IsPlayer != true; + } +} + + +[HarmonyPatch(typeof(LetterStack), nameof(LetterStack.ReceiveLetter), typeof(Letter), typeof(string), typeof(int), typeof(bool))] +static class LetterStackReceiveOnlyMyFaction +{ + // todo the letter might get culled from the archive if it isn't in the stack and Sync depends on the archive + static void Postfix(LetterStack __instance, Letter let) + { + if (Multiplayer.RealPlayerFaction != Faction.OfPlayer) + __instance.letters.Remove(let); + } +} + +[HarmonyPatch(typeof(LetterStack), nameof(LetterStack.ReceiveLetter), typeof(Letter), typeof(string), typeof(int), typeof(bool))] +static class LetterStackReceiveSoundOnlyMyFaction +{ + private static MethodInfo PlayOneShotOnCamera = + typeof(SoundStarter).GetMethod(nameof(SoundStarter.PlayOneShotOnCamera)); + + static IEnumerable Transpiler(IEnumerable insts) + { + foreach (var inst in insts) + { + if (inst.operand as MethodInfo == PlayOneShotOnCamera) + yield return new CodeInstruction( + OpCodes.Call, + SymbolExtensions.GetMethodInfo((SoundDef s, Map m) => PlaySoundReplacement(s, m))); + else + yield return inst; + } + } + + static void PlaySoundReplacement(SoundDef sound, Map map) + { + if (Multiplayer.RealPlayerFaction == Faction.OfPlayer) + sound.PlayOneShotOnCamera(map); + } +} + +[HarmonyPatch(typeof(Apparel), nameof(Apparel.WornGraphicPath), MethodType.Getter)] +static class ApparelWornGraphicPathGetterPatch +{ + private static FieldInfo thingIDNumberField = AccessTools.Field(typeof(Thing), nameof(Thing.thingIDNumber)); + + static IEnumerable Transpiler(IEnumerable insts) + { + foreach (var inst in insts) + { + yield return inst; + + // This instruction is part of wornGraphicPaths[thingIDNumber % wornGraphicPaths.Count] + // The function makes sure the id is positive + if (inst.operand as FieldInfo == thingIDNumberField) + yield return new CodeInstruction(OpCodes.Call, + AccessTools.Method(typeof(ApparelWornGraphicPathGetterPatch), nameof(MakeIdPositive))); + } + } + + private static int MakeIdPositive(int id) + { + return id < 0 ? -id : id; + } +} + +#region Map generation + +[HarmonyPatch(typeof(GenStep_Monolith), nameof(GenStep_Monolith.GenerateMonolith))] +public static class GenStepMonolithPatch +{ + static bool Prefix() + { + if (Multiplayer.Client == null) + return true; + + var anomalyComp = Current.Game.components.OfType().FirstOrDefault(); + + return anomalyComp?.monolith == null; + } +} + +[HarmonyPatch(typeof(ScenPart_ScatterThings), nameof(ScenPart_ScatterThings.GenerateIntoMap))] +static class ScenPartScatterThingsPatch +{ + static bool Prefix() + { + return Multiplayer.Client == null || FactionCreator.generatingMap; + } +} + +[HarmonyPatch(typeof(ScenPart_PlayerPawnsArriveMethod), nameof(ScenPart_PlayerPawnsArriveMethod.GenerateIntoMap))] +static class ScenPartPlayerPawnsArriveMethodPatch +{ + static bool Prefix() + { + return Multiplayer.Client == null || FactionCreator.generatingMap; + } +} + +#endregion + +[HarmonyPatch(typeof(CharacterCardUtility), nameof(CharacterCardUtility.DoTopStack))] +static class CharacterCardUtilityDontDrawIdeoPlate +{ + private static FieldInfo classicModeField = AccessTools.Field(typeof(IdeoManager), nameof(IdeoManager.classicMode)); + + static IEnumerable Transpiler(IEnumerable insts) + { + foreach (var inst in insts) + { + yield return inst; + + // Don't draw the ideo plate while choosing starting pawns in multifaction + if (inst.operand as FieldInfo == classicModeField) + { + yield return new CodeInstruction(OpCodes.Ldarg_2); + yield return new CodeInstruction(OpCodes.Call, + AccessTools.Method(typeof(CharacterCardUtilityDontDrawIdeoPlate), nameof(DontDrawIdeoPlate))); + yield return new CodeInstruction(OpCodes.Or); + } + } + } + + private static bool DontDrawIdeoPlate(bool generating) + { + return Multiplayer.Client != null && generating; + } +} + +[HarmonyPatch(typeof(CompShuttle), nameof(CompShuttle.ContainedColonistCount), MethodType.Getter)] +static class CompShuttle_ContainedColonistCount_Patch +{ + static IEnumerable Transpiler(IEnumerable insts) + { + var isFreeColonist = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsFreeColonist)); + var replacement = AccessTools.Method(typeof(CompShuttle_ContainedColonistCount_Patch), + nameof(IsFreeColonistAnyPlayerFaction)); + + foreach (var ci in insts) + { + if (ci.Calls(isFreeColonist)) + { + ci.opcode = OpCodes.Call; + ci.operand = replacement; + } + yield return ci; + } + } + public static bool IsFreeColonistAnyPlayerFaction(Pawn pawn) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return pawn.IsFreeColonist; + + return pawn.Faction != null && + pawn.Faction.IsPlayer && + pawn.RaceProps.Humanlike && + (!pawn.IsSlave || pawn.guest.SlaveIsSecure) && + !pawn.IsSubhuman && + pawn.HostFaction == null; + } + +} + +[HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.RemoveGravshipFromMap))] +static class RemoveGravshipFromMap_Patch +{ + static void Prefix(Building_GravEngine engine, out bool __state) + { + __state = false; + if (Multiplayer.Client == null) return; + if (!Multiplayer.GameComp.multifaction) return; + if (engine == null) return; + + engine.Map.PushFaction(engine.Faction); + + __state = true; + } + + static void Finalizer(Building_GravEngine engine, bool __state) + { + if (!__state) return; + engine.Map.PopFaction(); + } +} + +[HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.PlaceGravship))] +static class PlaceGravship_FactionContext_Patch +{ + static void Prefix(Gravship gravship, Map map, out bool __state) + { + __state = false; + if (Multiplayer.Client == null) return; + if (!Multiplayer.GameComp.multifaction) return; + + map.PushFaction(gravship.Faction); + + __state = true; + } + + static void Finalizer(Gravship gravship, Map map, bool __state) + { + if (!__state) return; + map.PopFaction(); + } +} + +[HarmonyPatch(typeof(QuestPart_LendColonistsToFaction), nameof(QuestPart_LendColonistsToFaction.Enable))] +static class QuestPart_LendColonistsToFaction_Enable_Patch +{ + static IEnumerable Transpiler(IEnumerable insts) + { + var isFreeColonist = AccessTools.PropertyGetter(typeof(Pawn), nameof(Pawn.IsFreeColonist)); + var replacement = AccessTools.Method(typeof(CompShuttle_ContainedColonistCount_Patch), + nameof(CompShuttle_ContainedColonistCount_Patch.IsFreeColonistAnyPlayerFaction)); + foreach (var ci in insts) + { + if (ci.Calls(isFreeColonist)) + { + ci.opcode = OpCodes.Call; + ci.operand = replacement; + } + yield return ci; + } + } +} + +[HarmonyPatch(typeof(Map), nameof(Map.IsPlayerHome), MethodType.Getter)] +static class Map_IsPlayerHome_Spectator_Patch +{ + static bool Prefix(Map __instance, ref bool __result) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction || + Faction.OfPlayer != Multiplayer.WorldComp.spectatorFaction) + return true; + + { + // Is repeat through all player faction a better idea? + if (!__instance.wasSpawnedViaGravShipLanding) + { + MapInfo mapInfo = __instance.info; + if (((mapInfo != null) ? mapInfo.parent : null) == null || __instance.info.parent.Faction?.IsPlayer != true || !__instance.info.parent.def.canBePlayerHome) + { + __result = GravshipUtility.PlayerHasGravEngine(__instance); + return false; + } + } + __result = true; + return false; + } + } +} + +[HarmonyPatch] +static class Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch +{ + static MethodInfo TargetMethod() + { + return AccessTools.EnumeratorMoveNext(AccessTools.Method(typeof(Pawn_TraderTracker), nameof(Pawn_TraderTracker.ColonyThingsWillingToBuy))); + } + public static List AllBuildingsColonistOfDefOfPlayer(this ListerBuildings lister, ThingDef def) + { + if (def.CanHaveFaction) + return lister.AllBuildingsColonistOfDef(def).FindAll(building => building.Faction == Faction.OfPlayer); + else + return lister.AllBuildingsColonistOfDef(def); + } + public static IEnumerable AllColonistBuildingsOfTypeOfPlayer(this ListerBuildings lister) + { + return lister.AllColonistBuildingsOfType().Where(t => + { + if (t is Building building && (!building.def.CanHaveFaction || building.Faction == Faction.OfPlayer)) + return true; + return false; + }); + } + static IEnumerable Transpiler(IEnumerable insts) + { + var allBuildingsColonistOfDef = AccessTools.Method(typeof(ListerBuildings), nameof(ListerBuildings.AllBuildingsColonistOfDef)); + var allBuildingsColonistOfDefOfPlayer = AccessTools.Method(typeof(Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch), nameof(Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch.AllBuildingsColonistOfDefOfPlayer)); + var allColonistBuildingsOfType = AccessTools.Method(typeof(ListerBuildings), nameof(ListerBuildings.AllColonistBuildingsOfType)).MakeGenericMethod([typeof(IHaulSource)]); + var allColonistBuildingsOfTypeOfPlayer = AccessTools.Method(typeof(Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch), nameof(Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch.AllColonistBuildingsOfTypeOfPlayer)).MakeGenericMethod([typeof(IHaulSource)]); + foreach (var ci in insts) + { + if (ci.Calls(allBuildingsColonistOfDef)) + { + ci.opcode = OpCodes.Call; + ci.operand = allBuildingsColonistOfDefOfPlayer; + } + else if (ci.Calls(allColonistBuildingsOfType)) + { + ci.opcode = OpCodes.Call; + ci.operand = allColonistBuildingsOfTypeOfPlayer; + } + yield return ci; + } + } +} + +[HarmonyPatch] +static class TradeUtility_AllLaunchableThingsForTrade_Patch +{ + static MethodInfo TargetMethod() + { + return AccessTools.EnumeratorMoveNext(AccessTools.Method(typeof(TradeUtility), nameof(TradeUtility.AllLaunchableThingsForTrade))); + } + + public static IEnumerable AllPoweredOfPlayer(Map map) + { + return Building_OrbitalTradeBeacon.AllPowered(map).Where(beacon => beacon.Faction == Faction.OfPlayer); + } + static IEnumerable Transpiler(IEnumerable insts) + { + var allPowered = AccessTools.Method(typeof(Building_OrbitalTradeBeacon), nameof(Building_OrbitalTradeBeacon.AllPowered)); + var allPoweredOfPlayer = AccessTools.Method(typeof(TradeUtility_AllLaunchableThingsForTrade_Patch), nameof(TradeUtility_AllLaunchableThingsForTrade_Patch.AllPoweredOfPlayer)); + foreach (var ci in insts) + { + if (ci.Calls(allPowered)) + { + ci.opcode = OpCodes.Call; + ci.operand = allPoweredOfPlayer; + } + + yield return ci; + } + } +} diff --git a/Source/Client/Multiplayer.cs b/Source/Client/Multiplayer.cs index d770b9c36..c4ccd2794 100644 --- a/Source/Client/Multiplayer.cs +++ b/Source/Client/Multiplayer.cs @@ -1,258 +1,258 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Threading; -using HarmonyLib; -using Multiplayer.Client.AsyncTime; -using Multiplayer.Client.Comp; -using Multiplayer.Client.Desyncs; -using Multiplayer.Client.Patches; -using Multiplayer.Client.Util; -using Multiplayer.Common; -using RimWorld; -using UnityEngine; -using Verse; -#if EDIT_COMPILE_RELOAD -using HotSwap; -#endif - -namespace Multiplayer.Client -{ - public static class Multiplayer - { - public const string WebsiteLink = "https://rimworldmultiplayer.com"; - // There is also a link in About.xml. Remember to update both - public const string DiscordLink = "https://discord.gg/S4bxXpv"; - - public static Harmony harmony = new("multiplayer"); - public static MpSettings settings; - - public static MultiplayerGame game; - public static MultiplayerSession session; - - public static Common.SyncSerialization serialization; - - public static MultiplayerServer LocalServer { get; set; } - public static Thread localServerThread; - - public static ConnectionBase Client => session?.client; - public static PacketLogWindow WriterLog => session?.writerLog; - public static PacketLogWindow ReaderLog => session?.readerLog; - public static bool IsReplay => session?.replay ?? false; - - public static string username; - - public static bool reloading; - - public static MultiplayerGameComp GameComp => game.gameComp; - public static MultiplayerWorldComp WorldComp => game.worldComp; - public static AsyncWorldTimeComp AsyncWorldTime => game.asyncWorldTimeComp; - public static ThingsById ThingsById => game.thingsById; - - public static bool ShowDevInfo => Prefs.DevMode && settings.showDevInfo; - public static bool GhostMode => session is { ghostModeCheckbox: true }; - - public static Faction RealPlayerFaction => Client != null ? game.RealPlayerFaction : Faction.OfPlayer; - - public static bool ExecutingCmds => TickPatch.currentExecutingCmdType != null; - public static bool ExecutingCmdDebugTool => TickPatch.currentExecutingCmdType == CommandType.DebugTools; - public static bool Ticking => AsyncWorldTimeComp.tickingWorld || AsyncTimeComp.tickingMap != null || ConstantTicker.ticking; - public static Map MapContext => AsyncTimeComp.tickingMap ?? AsyncTimeComp.executingCmdMap; - - public static bool dontSync; - public static bool ShouldSync => InInterface && !dontSync; - public static bool InInterface => - Client != null - && !Ticking - && !ExecutingCmds - && !reloading - && Current.ProgramState == ProgramState.Playing - && LongEventHandler.currentEvent == null; - - public static string ReplaysDir => GenFilePaths.FolderUnderSaveData("MpReplays"); - public static string DesyncsDir => GenFilePaths.FolderUnderSaveData("MpDesyncs"); - public static string LogsDir => GenFilePaths.FolderUnderSaveData("MpLogs"); - public static string CacheDir => GenFilePaths.FolderUnderSaveData("MpCache"); - - public static Stopwatch clock = Stopwatch.StartNew(); - - public static bool arbiterInstance; - public static bool loadingErrors; - public static Stopwatch harmonyWatch = new(); - - public static ModContentPack modContentPack; - - public static void InitMultiplayer(ModContentPack content) - { -#if EDIT_COMPILE_RELOAD - EcrLog.messageCallback = Log.Message; - EcrLog.errorCallback = Log.Error; - // EditCompileReload.RegisterAssemblyWatcher("Mods/Multiplayer/Source/Client/bin", "MultiplayerCommon.dll_orig"); - EditCompileReload.RegisterAssemblyWatcher("Mods/Multiplayer/Source/Client/bin", "Multiplayer.dll_orig"); -#endif - - modContentPack = content; - Native.EarlyInit( - Application.platform switch - { - RuntimePlatform.LinuxEditor => Native.NativeOS.Linux, - RuntimePlatform.LinuxPlayer => Native.NativeOS.Linux, - RuntimePlatform.OSXEditor => Native.NativeOS.OSX, - RuntimePlatform.OSXPlayer => Native.NativeOS.OSX, - _ => Native.NativeOS.Windows - }); - - DisableOmitFramePointer(); - JittedMethods.Init(); - - MultiplayerLoader.Multiplayer.settingsWindowDrawer = - rect => MpSettingsUI.DoSettingsWindowContents(settings, rect); - - using (DeepProfilerWrapper.Section("Multiplayer CacheTypeHierarchy")) - TypeCache.CacheTypeHierarchy(); - - using (DeepProfilerWrapper.Section("Multiplayer CacheTypeByName")) - TypeCache.CacheTypeByName(); - - if (GenCommandLine.CommandLineArgPassed("profiler")) - { - SimpleProfiler.CheckAvailable(); - Log.Message($"Profiler: {SimpleProfiler.available}"); - SimpleProfiler.Init("prof"); - } - - if (GenCommandLine.CommandLineArgPassed("arbiter")) - { - ArbiterWindowFix.Run(); - arbiterInstance = true; - } - - ScribeLike.provider = new ScribeProvider(); - settings = MultiplayerLoader.Multiplayer.instance!.GetSettings(); - - EarlyInit.ProcessEnvironment(); - EarlyInit.EarlyPatches(harmony); - MpReflection.allAssembliesHook = RwAllAssemblies; - EarlyInit.InitSync(); - CheckInterfaceVersions(); - - LongEventHandler.ExecuteWhenFinished(() => { - // Double Execute ensures it'll run last. - LongEventHandler.ExecuteWhenFinished(EarlyInit.LatePatches); - }); - - MultiplayerData.modCtorRoundMode = RoundMode.GetCurrentRoundMode(); - VersionChecker.Init(); - } - - [MethodImpl(MethodImplOptions.NoInlining)] +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading; +using HarmonyLib; +using Multiplayer.Client.AsyncTime; +using Multiplayer.Client.Comp; +using Multiplayer.Client.Desyncs; +using Multiplayer.Client.Patches; +using Multiplayer.Client.Util; +using Multiplayer.Common; +using RimWorld; +using UnityEngine; +using Verse; +#if EDIT_COMPILE_RELOAD +using HotSwap; +#endif + +namespace Multiplayer.Client +{ + public static class Multiplayer + { + public const string WebsiteLink = "https://rimworldmultiplayer.com"; + // There is also a link in About.xml. Remember to update both + public const string DiscordLink = "https://discord.gg/S4bxXpv"; + + public static Harmony harmony = new("multiplayer"); + public static MpSettings settings; + + public static MultiplayerGame game; + public static MultiplayerSession session; + + public static Common.SyncSerialization serialization; + + public static MultiplayerServer LocalServer { get; set; } + public static Thread localServerThread; + + public static ConnectionBase Client => session?.client; + public static PacketLogWindow WriterLog => session?.writerLog; + public static PacketLogWindow ReaderLog => session?.readerLog; + public static bool IsReplay => session?.replay ?? false; + + public static string username; + + public static bool reloading; + + public static MultiplayerGameComp GameComp => game.gameComp; + public static MultiplayerWorldComp WorldComp => game.worldComp; + public static AsyncWorldTimeComp AsyncWorldTime => game.asyncWorldTimeComp; + public static ThingsById ThingsById => game.thingsById; + + public static bool ShowDevInfo => Prefs.DevMode && settings.showDevInfo; + public static bool GhostMode => session is { ghostModeCheckbox: true }; + + public static Faction RealPlayerFaction => Client != null ? game.RealPlayerFaction : Faction.OfPlayer; + + public static bool ExecutingCmds => TickPatch.currentExecutingCmdType != null; + public static bool ExecutingCmdDebugTool => TickPatch.currentExecutingCmdType == CommandType.DebugTools; + public static bool Ticking => AsyncWorldTimeComp.tickingWorld || AsyncTimeComp.tickingMap != null || ConstantTicker.ticking; + public static Map MapContext => AsyncTimeComp.tickingMap ?? AsyncTimeComp.executingCmdMap; + + public static bool dontSync; + public static bool ShouldSync => InInterface && !dontSync; + public static bool InInterface => + Client != null + && !Ticking + && !ExecutingCmds + && !reloading + && Current.ProgramState == ProgramState.Playing + && LongEventHandler.currentEvent == null; + + public static string ReplaysDir => GenFilePaths.FolderUnderSaveData("MpReplays"); + public static string DesyncsDir => GenFilePaths.FolderUnderSaveData("MpDesyncs"); + public static string LogsDir => GenFilePaths.FolderUnderSaveData("MpLogs"); + public static string CacheDir => GenFilePaths.FolderUnderSaveData("MpCache"); + + public static Stopwatch clock = Stopwatch.StartNew(); + + public static bool arbiterInstance; + public static bool loadingErrors; + public static Stopwatch harmonyWatch = new(); + + public static ModContentPack modContentPack; + + public static void InitMultiplayer(ModContentPack content) + { +#if EDIT_COMPILE_RELOAD + EcrLog.messageCallback = Log.Message; + EcrLog.errorCallback = Log.Error; + // EditCompileReload.RegisterAssemblyWatcher("Mods/Multiplayer/Source/Client/bin", "MultiplayerCommon.dll_orig"); + EditCompileReload.RegisterAssemblyWatcher("Mods/Multiplayer/Source/Client/bin", "Multiplayer.dll_orig"); +#endif + + modContentPack = content; + Native.EarlyInit( + Application.platform switch + { + RuntimePlatform.LinuxEditor => Native.NativeOS.Linux, + RuntimePlatform.LinuxPlayer => Native.NativeOS.Linux, + RuntimePlatform.OSXEditor => Native.NativeOS.OSX, + RuntimePlatform.OSXPlayer => Native.NativeOS.OSX, + _ => Native.NativeOS.Windows + }); + + DisableOmitFramePointer(); + JittedMethods.Init(); + + MultiplayerLoader.Multiplayer.settingsWindowDrawer = + rect => MpSettingsUI.DoSettingsWindowContents(settings, rect); + + using (DeepProfilerWrapper.Section("Multiplayer CacheTypeHierarchy")) + TypeCache.CacheTypeHierarchy(); + + using (DeepProfilerWrapper.Section("Multiplayer CacheTypeByName")) + TypeCache.CacheTypeByName(); + + if (GenCommandLine.CommandLineArgPassed("profiler")) + { + SimpleProfiler.CheckAvailable(); + Log.Message($"Profiler: {SimpleProfiler.available}"); + SimpleProfiler.Init("prof"); + } + + if (GenCommandLine.CommandLineArgPassed("arbiter")) + { + ArbiterWindowFix.Run(); + arbiterInstance = true; + } + + ScribeLike.provider = new ScribeProvider(); + settings = MultiplayerLoader.Multiplayer.instance!.GetSettings(); + + EarlyInit.ProcessEnvironment(); + EarlyInit.EarlyPatches(harmony); + MpReflection.allAssembliesHook = RwAllAssemblies; + EarlyInit.InitSync(); + CheckInterfaceVersions(); + + LongEventHandler.ExecuteWhenFinished(() => { + // Double Execute ensures it'll run last. + LongEventHandler.ExecuteWhenFinished(EarlyInit.LatePatches); + }); + + MultiplayerData.modCtorRoundMode = RoundMode.GetCurrentRoundMode(); + VersionChecker.Init(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] private static void DisableOmitFramePointer() { if (!Native.IsMonoRuntime) return; Native.mini_parse_debug_option("disable_omit_fp"); - } - - private static void CheckInterfaceVersions() - { - var mpAssembly = AppDomain.CurrentDomain.GetAssemblies().First(a => a.GetName().Name == "Multiplayer"); - var curVersion = new Version( - (mpAssembly.GetCustomAttributes(typeof(AssemblyFileVersionAttribute), false)[0] as AssemblyFileVersionAttribute).Version - ); - - Log.Message($"Current MultiplayerAPI version: {curVersion}"); - - foreach (var mod in LoadedModManager.RunningMods) { - if (mod.assemblies.loadedAssemblies.NullOrEmpty()) - continue; - - if (mod == modContentPack) - continue; - - // Test if mod is using multiplayer api - if (!mod.assemblies.loadedAssemblies.Any(a => a.GetName().Name == MpVersion.ApiAssemblyName)) { - continue; - } - - // Retrieve the original dll - var info = MultiplayerData.GetModAssemblies(mod) - .Select(f => FileVersionInfo.GetVersionInfo(f.FullName)) - .FirstOrDefault(v => v.ProductName == "Multiplayer"); - - if (info == null) { - // There are certain mods that don't include the API, namely compat - // Can we test them? - continue; - } - - var version = new Version(info.FileVersion); - - Log.Message($"Mod {mod.Name} has MultiplayerAPI client ({version})"); - - if (curVersion > version) - Log.Warning($"Mod {mod.Name} uses an older API version (mod: {version}, current: {curVersion})"); - else if (curVersion < version) - Log.Error($"Mod {mod.Name} uses a newer API version! (mod: {version}, current: {curVersion})\nMake sure the Multiplayer mod is up to date"); - } - } - - public static void StopMultiplayerAndClearAllWindows() - { - StopMultiplayer(); - MpUI.ClearWindowStack(); - } - - public static void StopMultiplayer() - { - Log.Message($"Stopping multiplayer session from {new StackTrace().GetFrame(1).GetMethod().FullDescription()}"); - - OnMainThread.ClearScheduled(); - LongEventHandler.ClearQueuedEvents(); - - if (session != null) - { - session.Stop(); - session = null; - - Prefs.Apply(); - } - - if (LocalServer != null) - { - LocalServer.running = false; - localServerThread?.Join(); - LocalServer.TryStop(); - LocalServer = null; - } - - game?.OnDestroy(); - game = null; - - TickPatch.Reset(); - VTRSync.Reset(); - - Find.WindowStack?.WindowOfType()?.Cleanup(true); - SyncFieldUtil.ClearAllBufferedChanges(); - - if (arbiterInstance) - { - arbiterInstance = false; - Application.Quit(); - } - } - - private static IEnumerable RwAllAssemblies() - { - yield return Assembly.GetAssembly(typeof(Game)); - - foreach (ModContentPack mod in LoadedModManager.RunningMods) - foreach (Assembly assembly in mod.assemblies.loadedAssemblies) - yield return assembly; - - if (Assembly.GetEntryAssembly() != null) - yield return Assembly.GetEntryAssembly(); - } - } -} + } + + private static void CheckInterfaceVersions() + { + var mpAssembly = AppDomain.CurrentDomain.GetAssemblies().First(a => a.GetName().Name == "Multiplayer"); + var curVersion = new Version( + (mpAssembly.GetCustomAttributes(typeof(AssemblyFileVersionAttribute), false)[0] as AssemblyFileVersionAttribute).Version + ); + + Log.Message($"Current MultiplayerAPI version: {curVersion}"); + + foreach (var mod in LoadedModManager.RunningMods) { + if (mod.assemblies.loadedAssemblies.NullOrEmpty()) + continue; + + if (mod == modContentPack) + continue; + + // Test if mod is using multiplayer api + if (!mod.assemblies.loadedAssemblies.Any(a => a.GetName().Name == MpVersion.ApiAssemblyName)) { + continue; + } + + // Retrieve the original dll + var info = MultiplayerData.GetModAssemblies(mod) + .Select(f => FileVersionInfo.GetVersionInfo(f.FullName)) + .FirstOrDefault(v => v.ProductName == "Multiplayer"); + + if (info == null) { + // There are certain mods that don't include the API, namely compat + // Can we test them? + continue; + } + + var version = new Version(info.FileVersion); + + Log.Message($"Mod {mod.Name} has MultiplayerAPI client ({version})"); + + if (curVersion > version) + Log.Warning($"Mod {mod.Name} uses an older API version (mod: {version}, current: {curVersion})"); + else if (curVersion < version) + Log.Error($"Mod {mod.Name} uses a newer API version! (mod: {version}, current: {curVersion})\nMake sure the Multiplayer mod is up to date"); + } + } + + public static void StopMultiplayerAndClearAllWindows() + { + StopMultiplayer(); + MpUI.ClearWindowStack(); + } + + public static void StopMultiplayer() + { + Log.Message($"Stopping multiplayer session from {new StackTrace().GetFrame(1).GetMethod().FullDescription()}"); + + OnMainThread.ClearScheduled(); + LongEventHandler.ClearQueuedEvents(); + + if (session != null) + { + session.Stop(); + session = null; + + Prefs.Apply(); + } + + if (LocalServer != null) + { + LocalServer.running = false; + localServerThread?.Join(); + LocalServer.TryStop(); + LocalServer = null; + } + + game?.OnDestroy(); + game = null; + + TickPatch.Reset(); + VTRSync.Reset(); + + Find.WindowStack?.WindowOfType()?.Cleanup(true); + SyncFieldUtil.ClearAllBufferedChanges(); + + if (arbiterInstance) + { + arbiterInstance = false; + Application.Quit(); + } + } + + private static IEnumerable RwAllAssemblies() + { + yield return Assembly.GetAssembly(typeof(Game)); + + foreach (ModContentPack mod in LoadedModManager.RunningMods) + foreach (Assembly assembly in mod.assemblies.loadedAssemblies) + yield return assembly; + + if (Assembly.GetEntryAssembly() != null) + yield return Assembly.GetEntryAssembly(); + } + } +} diff --git a/Source/Client/Multiplayer.csproj b/Source/Client/Multiplayer.csproj index b021d98dd..8f38f6a00 100644 --- a/Source/Client/Multiplayer.csproj +++ b/Source/Client/Multiplayer.csproj @@ -1,85 +1,85 @@ - - - + + + net48 - net10.0 - true - 12 - false - bin - false - false - 0.6.2 - false - Multiplayer.Client - Multiplayer - - - - False - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ..\..\ - - - - $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)'))=$(AssemblyName)/ - - - - - - - - - - - - - - - - - - + net10.0 + true + 12 + false + bin + false + false + 0.6.2 + false + Multiplayer.Client + Multiplayer + + + + False + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ..\..\ + + + + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)'))=$(AssemblyName)/ + + + + + + + + + + + + + + + + + + diff --git a/Source/Client/Patches/GravshipTravelSessionPatches.cs b/Source/Client/Patches/GravshipTravelSessionPatches.cs index 8b5e84b4f..82cabdb10 100644 --- a/Source/Client/Patches/GravshipTravelSessionPatches.cs +++ b/Source/Client/Patches/GravshipTravelSessionPatches.cs @@ -1,258 +1,258 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using HarmonyLib; -using Multiplayer.API; -using Multiplayer.Client.Persistent; -using Multiplayer.Client.Util; -using RimWorld; -using RimWorld.Planet; -using Verse; -using Verse.Sound; - -namespace Multiplayer.Client.Patches -{ - #region Input - - [HarmonyPatch(typeof(GravshipUtility), nameof(GravshipUtility.PreLaunchConfirmation))] - public static class PatchGravshipPreLaunchConfirmation - { - static void Prefix(Building_GravEngine engine, ref Action launchAction) - { - if (Multiplayer.Client == null) return; - - GravshipTravelUtils.OpenSessionAt(engine.Map.Tile); - } - } - - [HarmonyPatch] - public static class PatchGravshipPreLaunchCancel - { - static MethodBase TargetMethod() - { - return MpMethodUtil.GetLambda(typeof(GravshipUtility), nameof(GravshipUtility.PreLaunchConfirmation), lambdaOrdinal: 4); - } - +using System; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using Multiplayer.API; +using Multiplayer.Client.Persistent; +using Multiplayer.Client.Util; +using RimWorld; +using RimWorld.Planet; +using Verse; +using Verse.Sound; + +namespace Multiplayer.Client.Patches +{ + #region Input + + [HarmonyPatch(typeof(GravshipUtility), nameof(GravshipUtility.PreLaunchConfirmation))] + public static class PatchGravshipPreLaunchConfirmation + { + static void Prefix(Building_GravEngine engine, ref Action launchAction) + { + if (Multiplayer.Client == null) return; + + GravshipTravelUtils.OpenSessionAt(engine.Map.Tile); + } + } + + [HarmonyPatch] + public static class PatchGravshipPreLaunchCancel + { + static MethodBase TargetMethod() + { + return MpMethodUtil.GetLambda(typeof(GravshipUtility), nameof(GravshipUtility.PreLaunchConfirmation), lambdaOrdinal: 4); + } + static void Postfix() - { - if (Multiplayer.Client == null) return; - if (!Multiplayer.ExecutingCmds) return; - - GravshipTravelUtils.CloseSessionAt(Find.CurrentMap.Tile); - GravshipTravelUtils.CloseGravshipPrelaunchDialog(); - } - } - - [HarmonyPatch(typeof(CompPilotConsole), nameof(CompPilotConsole.StartChoosingDestination_NewTemp))] - public static class PatchPilotConsoleStartChoosingDestination - { - static void Postfix(CompPilotConsole __instance, bool launching) - { - if (Multiplayer.Client == null) return; - if (!launching) return; - - GravshipTravelUtils.CloseGravshipPrelaunchDialog(); - GravshipTravelUtils.OpenSessionAt(__instance.engine.Map.Tile); - } - } - - [HarmonyPatch] - static class PatchTilePickerCancelLambda - { - static MethodBase TargetMethod() - { - return MpMethodUtil.GetLambda(typeof(CompPilotConsole), nameof(CompPilotConsole.StartChoosingDestination_NewTemp), lambdaOrdinal: 4); - } - - static void Prefix(bool ___launching, ref bool __state) - { - if (Multiplayer.Client == null || ___launching || Multiplayer.dontSync) - return; - - Multiplayer.dontSync = true; - __state = true; - } - - // TODO: Something in Feedback.cs seems to block the wantedMode switch. - // For now, it's set manually - consider keeping it this way permanently. - static void Finalizer(bool ___launching, PlanetTile ___curTile, bool __state) - { - if (Multiplayer.Client == null) return; - if (__state) Multiplayer.dontSync = false; - if (!Multiplayer.ExecutingCmds || !___launching) return; - - Find.World.renderer.wantedMode = WorldRenderMode.None; - Find.TilePicker.StopTargetingInt(); - GravshipTravelUtils.CloseSessionAt(___curTile); - } - } - - [HarmonyPatch] - public static class PatchGravshipMapArriveMethods - { - static IEnumerable TargetMethods() - { - yield return AccessTools.Method(typeof(GravshipUtility), nameof(GravshipUtility.ArriveExistingMap)); - yield return AccessTools.Method(typeof(GravshipUtility), nameof(GravshipUtility.ArriveNewMap)); - } - static void Postfix(Gravship gravship) - { - if (Multiplayer.Client == null) return; - - GravshipTravelUtils.OpenSessionAt(gravship.destinationTile); - } - } - - [HarmonyPatch(typeof(Designator_MoveGravship), nameof(Designator_MoveGravship.DesignateSingleCell))] - public static class PatchGravshipDesignatorDeselectForAllClients - { - static void Prefix() => CancelDesignatorDeselection.EnableCanceling(); - - static void Finalizer() => CancelDesignatorDeselection.DisableCanceling(); - } - - // TODO: Is there a better way to synchronize this method? - [HarmonyPatch(typeof(GravshipLandingMarker), nameof(GravshipLandingMarker.BeginLanding))] - public static class PatchBeginLandingToSyncWithClients - { - static bool Prefix(GravshipLandingMarker __instance) - { - if (Multiplayer.Client == null) return true; - if (Multiplayer.ExecutingCmds) return true; - - SyncBeginLanding(__instance); - - return false; - } - - [SyncMethod] - public static void SyncBeginLanding(GravshipLandingMarker landingMarker) - { - var gravshipController = Find.GravshipController; - - if (landingMarker == null || landingMarker.Tile == null) - { - MpLog.Error($"[MP] SyncConfirmGravshipLanding: Marker [{landingMarker != null}] Tile [{landingMarker?.Tile != null}]."); - return; - } - - gravshipController.landingMarker = landingMarker; - landingMarker.BeginLanding(gravshipController); - gravshipController.landingMarker = null; - - if (!TickPatch.currentExecutingCmdIssuedBySelf) - SoundDefOf.Gravship_Land.PlayOneShotOnCamera(); - } - } - - [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.AbortLanding))] - public static class PatchAbortLandingToCloseSession - { - static void Prefix(WorldComponent_GravshipController __instance, ref bool __state) - { - if (Multiplayer.Client == null) return; - if (!Multiplayer.ExecutingCmds) return; - - GravshipTravelUtils.CloseSessionAt(__instance.landingTile); - } - } - - [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.PlaceGravship))] - public static class PatchPlaceGravshipToUpdateSystemsAfterTheySpawn - { - static void Postfix(Map map) - { - if (Multiplayer.Client == null) return; - - map.glowGrid.GlowGridUpdate_First(); - } - } - - #endregion - - #region Landing/Takeoff freeze - - [HarmonyPatch] - public static class PatchGravshipCutsceneToFreeze - { - static IEnumerable TargetMethods() - { - yield return AccessTools.Method(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.InitiateTakeoff)); - yield return AccessTools.Method(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.InitiateLanding)); - } - - static void Postfix() - { - if (Multiplayer.Client == null) return; - - GravshipTravelUtils.StartFreeze(); - } - } - - [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.TakeoffEnded))] - public static class PatchGravshipTakeoffEnded - { - static void Prefix(WorldComponent_GravshipController __instance) - { - if (Multiplayer.Client == null) return; - - GravshipTravelUtils.StopFreeze(); - GravshipTravelUtils.CloseSessionAt(__instance.takeoffTile); - } - } - - // TODO: Is the random pushing here necessary? This might be related to issue #638. - [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.LandingEnded))] - public static class PatchGravshipLandingEnded - { - static void Prefix(WorldComponent_GravshipController __instance) - { - if (Multiplayer.Client == null) return; - - Rand.PushState(); - Rand.StateCompressed = __instance.map.AsyncTime().randState; - - GravshipTravelUtils.StopFreeze(); - GravshipTravelUtils.CloseSessionAt(__instance.gravship.destinationTile); - - // LandingEnded calls Find.TickManager.CurTimeSpeed = TimeSpeed.Normal, but that doesn't work in MP as we - // have our own way of changing the map speed. - __instance.map.AsyncTime().DesiredTimeSpeed = TimeSpeed.Normal; - } - - static void Finalizer() - { - if (Multiplayer.Client == null) return; - Rand.PopState(); - } - } - #endregion - - // TODO: Check what this actually does and whether it’s still necessary - // Stop the landing co message from showing every game tick - [HarmonyPatch(typeof(TickManager), nameof(TickManager.PlayerCanControl), MethodType.Getter)] - public static class PatchTickmanagerPlayerCanControlGetter - { - private static bool shownLandingMessage = false; - - static bool Prefix(ref AcceptanceReport __result) - { - if (Multiplayer.Client == null) return true; - - WorldComponent_GravshipController gravshipController = Find.GravshipController; - if (gravshipController != null && gravshipController.LandingAreaConfirmationInProgress) - { - __result = shownLandingMessage ? false : "MessageConfirmLandingAreaFirst".Translate(); - shownLandingMessage = true; - return false; - } - - // Not in a landing session, use vanilla logic for player control - __result = Current.Game.PlayerHasControl; - return false; - } - - // Call this when the landing session ends (e.g., in your session cleanup) - public static void ResetLandingMessageFlag() => shownLandingMessage = false; - } -} + { + if (Multiplayer.Client == null) return; + if (!Multiplayer.ExecutingCmds) return; + + GravshipTravelUtils.CloseSessionAt(Find.CurrentMap.Tile); + GravshipTravelUtils.CloseGravshipPrelaunchDialog(); + } + } + + [HarmonyPatch(typeof(CompPilotConsole), nameof(CompPilotConsole.StartChoosingDestination_NewTemp))] + public static class PatchPilotConsoleStartChoosingDestination + { + static void Postfix(CompPilotConsole __instance, bool launching) + { + if (Multiplayer.Client == null) return; + if (!launching) return; + + GravshipTravelUtils.CloseGravshipPrelaunchDialog(); + GravshipTravelUtils.OpenSessionAt(__instance.engine.Map.Tile); + } + } + + [HarmonyPatch] + static class PatchTilePickerCancelLambda + { + static MethodBase TargetMethod() + { + return MpMethodUtil.GetLambda(typeof(CompPilotConsole), nameof(CompPilotConsole.StartChoosingDestination_NewTemp), lambdaOrdinal: 4); + } + + static void Prefix(bool ___launching, ref bool __state) + { + if (Multiplayer.Client == null || ___launching || Multiplayer.dontSync) + return; + + Multiplayer.dontSync = true; + __state = true; + } + + // TODO: Something in Feedback.cs seems to block the wantedMode switch. + // For now, it's set manually - consider keeping it this way permanently. + static void Finalizer(bool ___launching, PlanetTile ___curTile, bool __state) + { + if (Multiplayer.Client == null) return; + if (__state) Multiplayer.dontSync = false; + if (!Multiplayer.ExecutingCmds || !___launching) return; + + Find.World.renderer.wantedMode = WorldRenderMode.None; + Find.TilePicker.StopTargetingInt(); + GravshipTravelUtils.CloseSessionAt(___curTile); + } + } + + [HarmonyPatch] + public static class PatchGravshipMapArriveMethods + { + static IEnumerable TargetMethods() + { + yield return AccessTools.Method(typeof(GravshipUtility), nameof(GravshipUtility.ArriveExistingMap)); + yield return AccessTools.Method(typeof(GravshipUtility), nameof(GravshipUtility.ArriveNewMap)); + } + static void Postfix(Gravship gravship) + { + if (Multiplayer.Client == null) return; + + GravshipTravelUtils.OpenSessionAt(gravship.destinationTile); + } + } + + [HarmonyPatch(typeof(Designator_MoveGravship), nameof(Designator_MoveGravship.DesignateSingleCell))] + public static class PatchGravshipDesignatorDeselectForAllClients + { + static void Prefix() => CancelDesignatorDeselection.EnableCanceling(); + + static void Finalizer() => CancelDesignatorDeselection.DisableCanceling(); + } + + // TODO: Is there a better way to synchronize this method? + [HarmonyPatch(typeof(GravshipLandingMarker), nameof(GravshipLandingMarker.BeginLanding))] + public static class PatchBeginLandingToSyncWithClients + { + static bool Prefix(GravshipLandingMarker __instance) + { + if (Multiplayer.Client == null) return true; + if (Multiplayer.ExecutingCmds) return true; + + SyncBeginLanding(__instance); + + return false; + } + + [SyncMethod] + public static void SyncBeginLanding(GravshipLandingMarker landingMarker) + { + var gravshipController = Find.GravshipController; + + if (landingMarker == null || landingMarker.Tile == null) + { + MpLog.Error($"[MP] SyncConfirmGravshipLanding: Marker [{landingMarker != null}] Tile [{landingMarker?.Tile != null}]."); + return; + } + + gravshipController.landingMarker = landingMarker; + landingMarker.BeginLanding(gravshipController); + gravshipController.landingMarker = null; + + if (!TickPatch.currentExecutingCmdIssuedBySelf) + SoundDefOf.Gravship_Land.PlayOneShotOnCamera(); + } + } + + [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.AbortLanding))] + public static class PatchAbortLandingToCloseSession + { + static void Prefix(WorldComponent_GravshipController __instance, ref bool __state) + { + if (Multiplayer.Client == null) return; + if (!Multiplayer.ExecutingCmds) return; + + GravshipTravelUtils.CloseSessionAt(__instance.landingTile); + } + } + + [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.PlaceGravship))] + public static class PatchPlaceGravshipToUpdateSystemsAfterTheySpawn + { + static void Postfix(Map map) + { + if (Multiplayer.Client == null) return; + + map.glowGrid.GlowGridUpdate_First(); + } + } + + #endregion + + #region Landing/Takeoff freeze + + [HarmonyPatch] + public static class PatchGravshipCutsceneToFreeze + { + static IEnumerable TargetMethods() + { + yield return AccessTools.Method(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.InitiateTakeoff)); + yield return AccessTools.Method(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.InitiateLanding)); + } + + static void Postfix() + { + if (Multiplayer.Client == null) return; + + GravshipTravelUtils.StartFreeze(); + } + } + + [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.TakeoffEnded))] + public static class PatchGravshipTakeoffEnded + { + static void Prefix(WorldComponent_GravshipController __instance) + { + if (Multiplayer.Client == null) return; + + GravshipTravelUtils.StopFreeze(); + GravshipTravelUtils.CloseSessionAt(__instance.takeoffTile); + } + } + + // TODO: Is the random pushing here necessary? This might be related to issue #638. + [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.LandingEnded))] + public static class PatchGravshipLandingEnded + { + static void Prefix(WorldComponent_GravshipController __instance) + { + if (Multiplayer.Client == null) return; + + Rand.PushState(); + Rand.StateCompressed = __instance.map.AsyncTime().randState; + + GravshipTravelUtils.StopFreeze(); + GravshipTravelUtils.CloseSessionAt(__instance.gravship.destinationTile); + + // LandingEnded calls Find.TickManager.CurTimeSpeed = TimeSpeed.Normal, but that doesn't work in MP as we + // have our own way of changing the map speed. + __instance.map.AsyncTime().DesiredTimeSpeed = TimeSpeed.Normal; + } + + static void Finalizer() + { + if (Multiplayer.Client == null) return; + Rand.PopState(); + } + } + #endregion + + // TODO: Check what this actually does and whether it’s still necessary + // Stop the landing co message from showing every game tick + [HarmonyPatch(typeof(TickManager), nameof(TickManager.PlayerCanControl), MethodType.Getter)] + public static class PatchTickmanagerPlayerCanControlGetter + { + private static bool shownLandingMessage = false; + + static bool Prefix(ref AcceptanceReport __result) + { + if (Multiplayer.Client == null) return true; + + WorldComponent_GravshipController gravshipController = Find.GravshipController; + if (gravshipController != null && gravshipController.LandingAreaConfirmationInProgress) + { + __result = shownLandingMessage ? false : "MessageConfirmLandingAreaFirst".Translate(); + shownLandingMessage = true; + return false; + } + + // Not in a landing session, use vanilla logic for player control + __result = Current.Game.PlayerHasControl; + return false; + } + + // Call this when the landing session ends (e.g., in your session cleanup) + public static void ResetLandingMessageFlag() => shownLandingMessage = false; + } +} diff --git a/Source/Client/Patches/VanillaTweaks.cs b/Source/Client/Patches/VanillaTweaks.cs index 25292b70e..d8716df64 100644 --- a/Source/Client/Patches/VanillaTweaks.cs +++ b/Source/Client/Patches/VanillaTweaks.cs @@ -1,151 +1,151 @@ -using HarmonyLib; -using RimWorld; -using System.Collections.Generic; -using System.Reflection; -using System.Reflection.Emit; -using UnityEngine; -using Verse; - -namespace Multiplayer.Client -{ - // Fixes a lag spike when opening debug tools - [HarmonyPatch(typeof(UIRoot))] - [HarmonyPatch(nameof(UIRoot.UIRootOnGUI))] - static class UIRootPatch - { - static bool done; - - static void Prefix() - { - if (done) return; +using HarmonyLib; +using RimWorld; +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Emit; +using UnityEngine; +using Verse; + +namespace Multiplayer.Client +{ + // Fixes a lag spike when opening debug tools + [HarmonyPatch(typeof(UIRoot))] + [HarmonyPatch(nameof(UIRoot.UIRootOnGUI))] + static class UIRootPatch + { + static bool done; + + static void Prefix() + { + if (done) return; GUI.skin.font = Text.fontStyles[1].font; #if !RIMWORLD_1_7 Text.fontStyles[1].font.fontNames = new[] { "arial", "arialbd", "ariali", "arialbi" }; #endif - done = true; - } - } - - // Fix window focus handling - [HarmonyPatch(typeof(WindowStack))] - [HarmonyPatch(nameof(WindowStack.CloseWindowsBecauseClicked))] - public static class WindowFocusPatch - { - static void Prefix(WindowStack __instance, Window clickedWindow) - { - for (int i = Find.WindowStack.Windows.Count - 1; i >= 0; i--) - { - Window window = Find.WindowStack.Windows[i]; - __instance.focusedWindow = window; - - if (window == clickedWindow || window.closeOnClickedOutside) return; - UI.UnfocusCurrentControl(); - } - - __instance.focusedWindow = null; - } - } - - [HarmonyPatch(typeof(IncidentWorker_PawnsArrive), nameof(IncidentWorker_PawnsArrive.FactionCanBeGroupSource))] - static class FactionCanBeGroupSourcePatch - { - static void Postfix(Faction f, ref bool __result) - { - __result &= f.def.pawnGroupMakers?.Count > 0; - } - } - - [HarmonyPatch(typeof(WindowStack), nameof(WindowStack.AddNewImmediateWindow))] - static class LongEventWindowPreventCameraMotion - { - public const int LongEventWindowId = 62893994; - - // ReSharper disable once InconsistentNaming - static void Postfix(int ID) - { - if (ID == -LongEventWindowId || ID == -IngameModal.ModalWindowId) - { - var window = Find.WindowStack.windows.Find(w => w.ID == ID); - - window.absorbInputAroundWindow = true; - window.preventCameraMotion = true; - } - } - } - - [HarmonyPatch(typeof(Window), nameof(Window.WindowOnGUI))] - static class WindowDrawDarkBackground - { - static void Prefix(Window __instance) - { - if (Current.ProgramState == ProgramState.Entry) return; - - if (__instance.ID == -LongEventWindowPreventCameraMotion.LongEventWindowId || - __instance.ID == -IngameModal.ModalWindowId || - __instance is DisconnectedWindow || - __instance is CaravanFormingProxy - ) - Widgets.DrawBoxSolid(new Rect(0, 0, UI.screenWidth, UI.screenHeight), new Color(0, 0, 0, 0.5f)); - } - } - - // Fixes a bug with long event handler's immediate window draw order - [HarmonyPatch(typeof(WindowStack), nameof(WindowStack.ImmediateWindow))] - static class AddImmediateWindowsDuringLayouting - { - static IEnumerable Transpiler(IEnumerable insts) - { - bool found = false; - - foreach (var inst in insts) - { - if (!found && inst.opcode == OpCodes.Ldc_I4_7) - { - yield return new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(AddImmediateWindowsDuringLayouting), nameof(Process))); - found = true; - } - - yield return inst; - } - } - - static EventType Process(EventType type) => type == EventType.Layout ? EventType.Repaint : type; - } - - [HarmonyPatch(typeof(Widgets), nameof(Widgets.DrawLine))] - static class DrawLineOnlyOnRepaint - { - static bool Prefix() => Event.current.type == EventType.Repaint; - } - - [HarmonyPatch] - public static class WidgetsResolveParsePatch - { - static MethodBase TargetMethod() - { - return AccessTools.Method(typeof(Widgets), nameof(Widgets.ResolveParseNow)).MakeGenericMethod(typeof(int)); - } - - // Fix input field handling - static void Prefix(bool force, ref int val, ref string buffer, ref string edited) - { - if (force) - edited = Widgets.ToStringTypedIn(val); - } - } - - // Fix a lag spike when spawning/destroying things which affect the exit grid - [HarmonyPatch(typeof(ExitMapGrid), nameof(ExitMapGrid.Grid), MethodType.Getter)] - static class ExitMapGridPatch - { - static bool Prefix(ExitMapGrid __instance, ref BoolGrid __result) - { - // A copy of the getter without a costly call to MapUsesExitGrid - if (__instance.dirty) - __instance.Rebuild(); - __result = __instance.exitMapGrid; - return false; - } - } - -} + done = true; + } + } + + // Fix window focus handling + [HarmonyPatch(typeof(WindowStack))] + [HarmonyPatch(nameof(WindowStack.CloseWindowsBecauseClicked))] + public static class WindowFocusPatch + { + static void Prefix(WindowStack __instance, Window clickedWindow) + { + for (int i = Find.WindowStack.Windows.Count - 1; i >= 0; i--) + { + Window window = Find.WindowStack.Windows[i]; + __instance.focusedWindow = window; + + if (window == clickedWindow || window.closeOnClickedOutside) return; + UI.UnfocusCurrentControl(); + } + + __instance.focusedWindow = null; + } + } + + [HarmonyPatch(typeof(IncidentWorker_PawnsArrive), nameof(IncidentWorker_PawnsArrive.FactionCanBeGroupSource))] + static class FactionCanBeGroupSourcePatch + { + static void Postfix(Faction f, ref bool __result) + { + __result &= f.def.pawnGroupMakers?.Count > 0; + } + } + + [HarmonyPatch(typeof(WindowStack), nameof(WindowStack.AddNewImmediateWindow))] + static class LongEventWindowPreventCameraMotion + { + public const int LongEventWindowId = 62893994; + + // ReSharper disable once InconsistentNaming + static void Postfix(int ID) + { + if (ID == -LongEventWindowId || ID == -IngameModal.ModalWindowId) + { + var window = Find.WindowStack.windows.Find(w => w.ID == ID); + + window.absorbInputAroundWindow = true; + window.preventCameraMotion = true; + } + } + } + + [HarmonyPatch(typeof(Window), nameof(Window.WindowOnGUI))] + static class WindowDrawDarkBackground + { + static void Prefix(Window __instance) + { + if (Current.ProgramState == ProgramState.Entry) return; + + if (__instance.ID == -LongEventWindowPreventCameraMotion.LongEventWindowId || + __instance.ID == -IngameModal.ModalWindowId || + __instance is DisconnectedWindow || + __instance is CaravanFormingProxy + ) + Widgets.DrawBoxSolid(new Rect(0, 0, UI.screenWidth, UI.screenHeight), new Color(0, 0, 0, 0.5f)); + } + } + + // Fixes a bug with long event handler's immediate window draw order + [HarmonyPatch(typeof(WindowStack), nameof(WindowStack.ImmediateWindow))] + static class AddImmediateWindowsDuringLayouting + { + static IEnumerable Transpiler(IEnumerable insts) + { + bool found = false; + + foreach (var inst in insts) + { + if (!found && inst.opcode == OpCodes.Ldc_I4_7) + { + yield return new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(AddImmediateWindowsDuringLayouting), nameof(Process))); + found = true; + } + + yield return inst; + } + } + + static EventType Process(EventType type) => type == EventType.Layout ? EventType.Repaint : type; + } + + [HarmonyPatch(typeof(Widgets), nameof(Widgets.DrawLine))] + static class DrawLineOnlyOnRepaint + { + static bool Prefix() => Event.current.type == EventType.Repaint; + } + + [HarmonyPatch] + public static class WidgetsResolveParsePatch + { + static MethodBase TargetMethod() + { + return AccessTools.Method(typeof(Widgets), nameof(Widgets.ResolveParseNow)).MakeGenericMethod(typeof(int)); + } + + // Fix input field handling + static void Prefix(bool force, ref int val, ref string buffer, ref string edited) + { + if (force) + edited = Widgets.ToStringTypedIn(val); + } + } + + // Fix a lag spike when spawning/destroying things which affect the exit grid + [HarmonyPatch(typeof(ExitMapGrid), nameof(ExitMapGrid.Grid), MethodType.Getter)] + static class ExitMapGridPatch + { + static bool Prefix(ExitMapGrid __instance, ref BoolGrid __result) + { + // A copy of the getter without a costly call to MapUsesExitGrid + if (__instance.dirty) + __instance.Rebuild(); + __result = __instance.exitMapGrid; + return false; + } + } + +} diff --git a/Source/Client/Syncing/SyncTemplates.cs b/Source/Client/Syncing/SyncTemplates.cs index c01410980..079ae13fc 100644 --- a/Source/Client/Syncing/SyncTemplates.cs +++ b/Source/Client/Syncing/SyncTemplates.cs @@ -1,85 +1,85 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Reflection.Emit; -using HarmonyLib; - -namespace Multiplayer.Client -{ - public static class SyncTemplates - { - public static HarmonyMethod CreateTranspiler() => new HarmonyMethod(m_Transpiler) { - priority = Priority.First - }; - - static bool General(int internalId, object instance, object[] args) - { - if (Multiplayer.ShouldSync) { - Sync.internalIdToSyncMethod[internalId].DoSync(instance, args); - return false; - } - - return true; - } - - static readonly MethodInfo m_General = SymbolExtensions.GetMethodInfo(() => General(0, null, Array.Empty())); - static readonly MethodInfo m_Transpiler = SymbolExtensions.GetMethodInfo(() => Transpiler(null, null, null)); - - static IEnumerable Transpiler(MethodBase original, IEnumerable instructions, ILGenerator gen) - { - int idx; - var label = gen.DefineLabel(); - var parameters = original.GetParameters(); - - idx = 0; - foreach (var pInfo in parameters) { - var argIndex = idx++ + (original.IsStatic ? 0 : 1); - var pType = pInfo.ParameterType; - if (pInfo.IsOut) { +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; +using HarmonyLib; + +namespace Multiplayer.Client +{ + public static class SyncTemplates + { + public static HarmonyMethod CreateTranspiler() => new HarmonyMethod(m_Transpiler) { + priority = Priority.First + }; + + static bool General(int internalId, object instance, object[] args) + { + if (Multiplayer.ShouldSync) { + Sync.internalIdToSyncMethod[internalId].DoSync(instance, args); + return false; + } + + return true; + } + + static readonly MethodInfo m_General = SymbolExtensions.GetMethodInfo(() => General(0, null, Array.Empty())); + static readonly MethodInfo m_Transpiler = SymbolExtensions.GetMethodInfo(() => Transpiler(null, null, null)); + + static IEnumerable Transpiler(MethodBase original, IEnumerable instructions, ILGenerator gen) + { + int idx; + var label = gen.DefineLabel(); + var parameters = original.GetParameters(); + + idx = 0; + foreach (var pInfo in parameters) { + var argIndex = idx++ + (original.IsStatic ? 0 : 1); + var pType = pInfo.ParameterType; + if (pInfo.IsOut) { pType = pType.GetElementType(); yield return new CodeInstruction(OpCodes.Ldarg, argIndex); yield return new CodeInstruction(OpCodes.Initobj, pType); - } - } - - yield return new CodeInstruction(OpCodes.Ldc_I4, Sync.methodBaseToInternalId[original]); - yield return new CodeInstruction(original.IsStatic ? OpCodes.Ldnull : OpCodes.Ldarg_0); - - yield return new CodeInstruction(OpCodes.Ldc_I4, parameters.Length); - yield return new CodeInstruction(OpCodes.Newarr, typeof(object)); - - idx = 0; - var arrayIdx = 0; - foreach (var pInfo in parameters) { - var argIndex = idx++ + (original.IsStatic ? 0 : 1); - var pType = pInfo.ParameterType; - yield return new CodeInstruction(OpCodes.Dup); - yield return new CodeInstruction(OpCodes.Ldc_I4, arrayIdx++); - yield return new CodeInstruction(OpCodes.Ldarg, argIndex); - if (pType.IsByRef) { - pType = pType.GetElementType(); - if (pType.IsValueType) { - yield return new CodeInstruction(OpCodes.Ldobj, pType); - yield return new CodeInstruction(OpCodes.Box, pType); - } else - yield return new CodeInstruction(OpCodes.Ldind_Ref, pType); - } else if (pType.IsValueType) - yield return new CodeInstruction(OpCodes.Box, pType); - yield return new CodeInstruction(OpCodes.Stelem_Ref); - } - yield return new CodeInstruction(OpCodes.Call, m_General); - yield return new CodeInstruction(OpCodes.Brtrue, label); - foreach (var code in CreateDefaultCodes(gen, AccessTools.GetReturnedType(original))) - yield return code; - yield return new CodeInstruction(OpCodes.Ret); - - var list = instructions.ToList(); - list.First().labels.Add(label); - foreach (var instruction in list) - yield return instruction; - } - + } + } + + yield return new CodeInstruction(OpCodes.Ldc_I4, Sync.methodBaseToInternalId[original]); + yield return new CodeInstruction(original.IsStatic ? OpCodes.Ldnull : OpCodes.Ldarg_0); + + yield return new CodeInstruction(OpCodes.Ldc_I4, parameters.Length); + yield return new CodeInstruction(OpCodes.Newarr, typeof(object)); + + idx = 0; + var arrayIdx = 0; + foreach (var pInfo in parameters) { + var argIndex = idx++ + (original.IsStatic ? 0 : 1); + var pType = pInfo.ParameterType; + yield return new CodeInstruction(OpCodes.Dup); + yield return new CodeInstruction(OpCodes.Ldc_I4, arrayIdx++); + yield return new CodeInstruction(OpCodes.Ldarg, argIndex); + if (pType.IsByRef) { + pType = pType.GetElementType(); + if (pType.IsValueType) { + yield return new CodeInstruction(OpCodes.Ldobj, pType); + yield return new CodeInstruction(OpCodes.Box, pType); + } else + yield return new CodeInstruction(OpCodes.Ldind_Ref, pType); + } else if (pType.IsValueType) + yield return new CodeInstruction(OpCodes.Box, pType); + yield return new CodeInstruction(OpCodes.Stelem_Ref); + } + yield return new CodeInstruction(OpCodes.Call, m_General); + yield return new CodeInstruction(OpCodes.Brtrue, label); + foreach (var code in CreateDefaultCodes(gen, AccessTools.GetReturnedType(original))) + yield return code; + yield return new CodeInstruction(OpCodes.Ret); + + var list = instructions.ToList(); + list.First().labels.Add(label); + foreach (var instruction in list) + yield return instruction; + } + static IEnumerable CreateDefaultCodes(ILGenerator generator, Type type) { if (type == typeof(void)) yield break; @@ -87,17 +87,17 @@ static IEnumerable CreateDefaultCodes(ILGenerator generator, Ty type = type.GetElementType(); if (!type.IsValueType) { - yield return new CodeInstruction(OpCodes.Ldnull); - yield break; - } + yield return new CodeInstruction(OpCodes.Ldnull); + yield break; + } if (type.IsValueType) { - var v = generator.DeclareLocal(type); - yield return new CodeInstruction(OpCodes.Ldloca, v); + var v = generator.DeclareLocal(type); + yield return new CodeInstruction(OpCodes.Ldloca, v); yield return new CodeInstruction(OpCodes.Initobj, type); yield return new CodeInstruction(OpCodes.Ldloc, v); yield break; } - } - - } -} + } + + } +} diff --git a/Source/Client/UI/MainMenuAnimation.cs b/Source/Client/UI/MainMenuAnimation.cs index 73f7ad27b..c5c14873f 100644 --- a/Source/Client/UI/MainMenuAnimation.cs +++ b/Source/Client/UI/MainMenuAnimation.cs @@ -1,440 +1,440 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using HarmonyLib; -using Multiplayer.Client.Util; -using RimWorld; -using UnityEngine; -using Verse; -using Random = System.Random; - -namespace Multiplayer.Client -{ - [HarmonyPatch(typeof(UI_BackgroundMain), nameof(UI_BackgroundMain.DoOverlay))] - [StaticConstructorOnStartup] - static class MainMenuAnimation - { - static Texture2D texture; - - const float r = 2282; // radius of planet - const float cx = r + 80, cy = r + 384; // center coords - const float origWidth = 8000, origHeight = 5000; - const int texWidth = (int)cx + 1871; // max extent in the x direction - const int texHeight = 5000; - - // The background might be drawn at the same time as the loading of a map on a different thread calls Verse.Rand - // Verse.Rand can't be used here because it isn't thread-safe +using System; +using System.Collections.Generic; +using System.Linq; +using HarmonyLib; +using Multiplayer.Client.Util; +using RimWorld; +using UnityEngine; +using Verse; +using Random = System.Random; + +namespace Multiplayer.Client +{ + [HarmonyPatch(typeof(UI_BackgroundMain), nameof(UI_BackgroundMain.DoOverlay))] + [StaticConstructorOnStartup] + static class MainMenuAnimation + { + static Texture2D texture; + + const float r = 2282; // radius of planet + const float cx = r + 80, cy = r + 384; // center coords + const float origWidth = 8000, origHeight = 5000; + const int texWidth = (int)cx + 1871; // max extent in the x direction + const int texHeight = 5000; + + // The background might be drawn at the same time as the loading of a map on a different thread calls Verse.Rand + // Verse.Rand can't be used here because it isn't thread-safe // Cosmetic only: no dependency on Unity's hardware identifier. private static Random rand = new(); - - private static float newPulseTimer; - - static MainMenuAnimation() - { - using (DeepProfilerWrapper.Section("Multiplayer main menu animation texture gen")) - { - texture = new(texWidth, texHeight, TextureFormat.RGBA32, false); - RegenTexture(); - } - } - - static void Prefix(UI_BackgroundMain __instance, Rect bgRect) - { - if (Input.GetKeyDown(KeyCode.KeypadPeriod)) - DoMainMenuPatch.hideMainMenu = !DoMainMenuPatch.hideMainMenu; - - if (!Multiplayer.settings.showMainMenuAnim - || __instance.overrideBGImage != null && __instance.overrideBGImage != UI_BackgroundMain.BGPlanet) - return; - - if (Input.GetKeyDown(KeyCode.End)) - RegenTexture(); - - GUI.DrawTexture(bgRect with { width = texWidth * bgRect.width / origWidth }, texture, ScaleMode.ScaleToFit); - -#if DEBUG - if (Input.GetKey(KeyCode.LeftShift)) - DrawEdges(bgRect); -#endif - - if (Event.current.type != EventType.Repaint) return; - - newPulseTimer += Time.deltaTime; - if (newPulseTimer > 1 / 12f) - { - var edge = edges[rand.Next(edges.Count)]; - var switchEndpoints = rand.NextDouble() < 0.5; - pulses.Add(new Pulse(switchEndpoints ? edge.v2 : edge.v1, switchEndpoints ? edge.v1 : edge.v2) { starting = true }); - newPulseTimer = 0; - } - - DrawPulses(bgRect); - } - - static void DrawPulses(Rect bgRect) - { - for (int i = pulses.Count - 1; i >= 0; i--) - { - var pulse = pulses[i]; - pulse.progress += Math.Min(Time.deltaTime, 0.05f) * 0.25f / pulse.dist; - - if (pulse.progress > 1f) - { - pulses.RemoveAt(i); - - if (!pulse.EaseOut) - pulses.Add(new Pulse(pulse.end, pulse.nextEnd) - { - ending = rand.NextDouble() < 0.4f - }); - } - - var start = Quaternion.Euler(pulse.start.theta, pulse.start.phi, 0); - var end = Quaternion.Euler(pulse.end.theta, pulse.end.phi, 0); - - var x = pulse.progress; - - var progress = - pulse.EaseIn && pulse.EaseOut ? x < 0.5 ? 2 * x * x : 1 - (float)Math.Pow(-2 * x + 2, 2) / 2 : - pulse.EaseIn ? x * x : - pulse.EaseOut ? 1 - (1 - x) * (1 - x) : - x; - - float theta = Quaternion.Lerp(start, end, progress).eulerAngles.x; - float phi = Quaternion.Lerp(start, end, progress).eulerAngles.y; - - float posX = cx + r * Mathf.Cos(theta * Mathf.Deg2Rad) * Mathf.Sin(phi * Mathf.Deg2Rad); - float posY = cy + r * Mathf.Sin(theta * Mathf.Deg2Rad); - - const float alphaEase = 0.3f; - var alpha = - pulse.EaseIn && x < alphaEase ? x / alphaEase : - pulse.EaseOut && x > 1f - alphaEase ? (1 - x) / alphaEase : - 1; - - using (MpStyle.Set(new Color(1, 1, 1, alpha))) - GUI.DrawTexture( - new Rect( - GetPos(bgRect, posX, posY), - new(6, 6) - ), - // After changing the game language, RimWorld destroys and reloads all assets but doesn't - // rerun static constructors, so resource fields reference destroyed resources - // The pulses disappear after changing the language - MultiplayerStatic.Pulse - ); - } - } - - static Vert GetNewEnd(Pulse pulse) - { - Vert newEnd = null; - var attempts = 0; - - while (newEnd == null && attempts < 20) - { - var randEdge = edges[rand.Next(edges.Count)]; - var otherVert = randEdge.OtherVert(pulse.end); - if (otherVert != null && otherVert != pulse.start && otherVert != pulse.end) - newEnd = otherVert; - attempts++; - } - - return newEnd; - } - - const float rotate = 35f; - const float maskTheta = 10; - - static void DrawEdges(Rect bgRect) - { - { - var proj1 = r * Project(maskTheta, -90).RotatedBy(rotate) + new Vector2(cx, cy); - var proj2 = r * Project(maskTheta, 90).RotatedBy(rotate) + new Vector2(cx, cy); - Widgets.DrawLine(GetPos(bgRect, proj1.x, proj1.y), GetPos(bgRect, proj2.x, proj2.y), Color.blue, 2f); - } - - foreach (var e in edges) - { - var start = Quaternion.Euler(e.v1.theta, e.v1.phi, 0); - var end = Quaternion.Euler(e.v2.theta, e.v2.phi, 0); - - float prevX = 0; - float prevY = 0; - - for (float d = 0; d <= 1; d += 0.25f) - { - float theta = Quaternion.Lerp(start, end, d).eulerAngles.x; - float phi = Quaternion.Lerp(start, end, d).eulerAngles.y; - - var proj = Project(theta, phi); - float x = cx + r * proj.x; - float y = cy + r * proj.y; - - if (prevX != 0 && prevY != 0) - { - var lineStart = GetPos(bgRect, prevX, prevY); - var lineEnd = GetPos(bgRect, x, y); - - if (d == 0.5f) - MpUI.Label(new Rect(lineStart, new(100, 100)), $"{e.dist}", GameFont.Tiny); - - Widgets.DrawLine(lineStart, lineEnd, Color.red, 2f); - } - - prevX = x; - prevY = y; - } - } - } - - static float SphereDist(float t1, float p1, float t2, float p2) - { - return Mathf.Acos( - Mathf.Sin(t1 * Mathf.Deg2Rad) * Mathf.Sin(t2 * Mathf.Deg2Rad) - + Mathf.Cos(t1 * Mathf.Deg2Rad) * Mathf.Cos(t2 * Mathf.Deg2Rad) * Mathf.Cos((p2 - p1) * Mathf.Deg2Rad) - ); - } - - private static List verts = new(); - private static List edges = new(); - private static List pulses = new(); - - static void Mst() - { - foreach (var v in verts) - { - v.cost = float.PositiveInfinity; - v.costVert = null; - } - - var q = verts.ToList(); - - while (q.Count > 0) - { - var v = q.MinBy(a => a.cost); - q.Remove(v); - - if (v.costVert != null) - edges.Add(new Edge(v, v.costVert)); - - foreach (var w in q) - { - var dist = SphereDist(v.theta, v.phi, w.theta, w.phi); - if (dist < w.cost) - { - w.cost = dist; - w.costVert = v; - } - } - } - } - - static Vector2 InverseSpherical(float x, float y, float r) - { - var ro = new Vector2(x, y).magnitude; - var c = Mathf.Asin(ro / r); - return new Vector2( - Mathf.Asin(y * Mathf.Sin(c) / ro) * Mathf.Rad2Deg, - Mathf.Atan2(x * Mathf.Sin(c), ro * Mathf.Cos(c)) * Mathf.Rad2Deg - ); - } - - static Vector2 GetPos(Rect rect, float x, float y) - { - return rect.min + new Vector2(rect.width * x / origWidth, rect.height * y / origHeight); - } - - static Vector2 Project(float theta, float phi) - { - return new Vector2( - Mathf.Cos(theta * Mathf.Deg2Rad) * Mathf.Sin(phi * Mathf.Deg2Rad), - Mathf.Sin(theta * Mathf.Deg2Rad) - ); - } - - static void RegenTexture() - { - using var _ = DeepProfilerWrapper.Section("RegenTexture"); - - verts.Clear(); - - // vaniller cities - verts.Add(new Vert { theta = 60, phi = 34 }); - verts.Add(new Vert { theta = -4, phi = -68 }); - verts.Add(new Vert { theta = -2, phi = -68 }); - verts.Add(new Vert { theta = 4, phi = -50 }); - verts.Add(new Vert { theta = 18, phi = -61 }); - - edges.Clear(); - pulses.Clear(); - - var colors = new Color[texWidth * texHeight]; - - // Cities - for (int i = 0; i < 20;) - { - float cityTheta = rand.Range(maskTheta, 75f); - float cityPhi = rand.Range(-75f, 75f); - - // ugh - var pr = Project(cityTheta, cityPhi).RotatedBy(rotate); - var inv = InverseSpherical(pr.x, pr.y, 1f); - cityTheta = inv.x; - cityPhi = inv.y; - - // Avoid the light side - var cityXY = r * Project(cityTheta, cityPhi) + new Vector2(cx, cy); - const float maskR = 2160; - if ((new Vector2(520 + maskR, 64 + maskR) - cityXY).magnitude < maskR) - continue; - - var blobs = rand.Next(2, 5); - if (rand.NextDouble() < 0.15) blobs += rand.Next(2, 4); - - Vector2? lit = null; - - // Blobs - for (int k = 0; k < blobs; k++) - { - var blobSpan = 2.5f + Math.Max(0, (blobs - 3) * 0.25f); - float theta = cityTheta + rand.Range(-blobSpan, blobSpan); - float phi = cityPhi + rand.Range(-blobSpan, blobSpan); - - // Dots - for (int j = 0; j < 7; j++) - { - var blobSize = j == 0 || rand.NextDouble() < 0.3f ? 15 : 10; - - float dotTheta = rand.Range(-1f, 1f); - float dotPhi = rand.Range(-1f, 1f); - - for (int y = -blobSize; y <= blobSize; y++) - for (int x = -blobSize; x <= blobSize; x++) - if (x * x + y * y < blobSize * blobSize) - { - var proj = Project(theta + dotTheta, phi + dotPhi); - float centerX = cx + r * proj.x; - float centerY = cy + r * proj.y; - - var c = 1 - new Vector2(x,y).magnitude / blobSize; - var fromCenter = 1 - new Vector2(dotTheta, dotPhi).magnitude / 2; - var fromCenter2 = 1 - new Vector2(theta - cityTheta, phi - cityPhi).magnitude / 5; - var a = c * fromCenter * fromCenter2; - - if (j == 0) a *= 3f; - else if (a > 0.4) a *= 2f; - - var pos = new Vector2(centerX, centerY); - var color = new Color(0.93f, 0.75f, 0.55f, a * a * 2); - var pixelX = (int)pos.x + x; - var pixelY = (int)origHeight - (int)pos.y + y; - colors[pixelX + pixelY * texWidth] = color; - - if (color.a >= 1 && lit == null) - { - lit = new Vector2(theta + dotTheta, phi + dotPhi); - } - } - } - } - - if (lit != null) - verts.Add(new Vert { theta = lit.Value.x, phi = lit.Value.y }); - - i++; - } - - using (DeepProfilerWrapper.Section("SetPixels and Apply")) - { - texture.SetPixels(colors); - texture.Apply(); - } - - Mst(); - } - - class Vert - { - public float theta; - public float phi; - - public float cost; - public Vert costVert; - } - - class Edge - { - public readonly Vert v1, v2; - public readonly float dist; - - public Edge(Vert v1, Vert v2) - { - this.v1 = v1; - this.v2 = v2; - - dist = SphereDist(v1.theta, v1.phi, v2.theta, v2.phi); - } - - protected bool Equals(Edge other) - { - return - Equals(v1, other.v1) && Equals(v2, other.v2) - || Equals(v1, other.v2) && Equals(v2, other.v1); - } - - public override bool Equals(object obj) - { - if (ReferenceEquals(null, obj)) return false; - if (ReferenceEquals(this, obj)) return true; - return obj.GetType() == GetType() && Equals((Edge)obj); - } - - public override int GetHashCode() - { - return v1.GetHashCode() * v2.GetHashCode(); - } - - public Vert OtherVert(Vert v) - { - return v1 == v ? v2 : v2 == v ? v1 : null; - } - } - - class Pulse - { - public float progress; - public Vert start; - public Vert end; - public float dist; - - public bool starting; - public bool ending; - public Vert nextEnd; - - public bool EaseIn => starting; - public bool EaseOut => ending || nextEnd == null; - - public Pulse(Vert start, Vert end) - { - this.start = start; - this.end = end; - - dist = SphereDist(start.theta, start.phi, end.theta, end.phi); - nextEnd = GetNewEnd(this); - } - } - } - - [HarmonyPatch(typeof(UIRoot_Entry), nameof(UIRoot_Entry.ShouldDoMainMenu), MethodType.Getter)] - static class DoMainMenuPatch - { - public static bool hideMainMenu; - - static void Postfix(ref bool __result) - { - if (hideMainMenu) - __result = false; - } - } -} + + private static float newPulseTimer; + + static MainMenuAnimation() + { + using (DeepProfilerWrapper.Section("Multiplayer main menu animation texture gen")) + { + texture = new(texWidth, texHeight, TextureFormat.RGBA32, false); + RegenTexture(); + } + } + + static void Prefix(UI_BackgroundMain __instance, Rect bgRect) + { + if (Input.GetKeyDown(KeyCode.KeypadPeriod)) + DoMainMenuPatch.hideMainMenu = !DoMainMenuPatch.hideMainMenu; + + if (!Multiplayer.settings.showMainMenuAnim + || __instance.overrideBGImage != null && __instance.overrideBGImage != UI_BackgroundMain.BGPlanet) + return; + + if (Input.GetKeyDown(KeyCode.End)) + RegenTexture(); + + GUI.DrawTexture(bgRect with { width = texWidth * bgRect.width / origWidth }, texture, ScaleMode.ScaleToFit); + +#if DEBUG + if (Input.GetKey(KeyCode.LeftShift)) + DrawEdges(bgRect); +#endif + + if (Event.current.type != EventType.Repaint) return; + + newPulseTimer += Time.deltaTime; + if (newPulseTimer > 1 / 12f) + { + var edge = edges[rand.Next(edges.Count)]; + var switchEndpoints = rand.NextDouble() < 0.5; + pulses.Add(new Pulse(switchEndpoints ? edge.v2 : edge.v1, switchEndpoints ? edge.v1 : edge.v2) { starting = true }); + newPulseTimer = 0; + } + + DrawPulses(bgRect); + } + + static void DrawPulses(Rect bgRect) + { + for (int i = pulses.Count - 1; i >= 0; i--) + { + var pulse = pulses[i]; + pulse.progress += Math.Min(Time.deltaTime, 0.05f) * 0.25f / pulse.dist; + + if (pulse.progress > 1f) + { + pulses.RemoveAt(i); + + if (!pulse.EaseOut) + pulses.Add(new Pulse(pulse.end, pulse.nextEnd) + { + ending = rand.NextDouble() < 0.4f + }); + } + + var start = Quaternion.Euler(pulse.start.theta, pulse.start.phi, 0); + var end = Quaternion.Euler(pulse.end.theta, pulse.end.phi, 0); + + var x = pulse.progress; + + var progress = + pulse.EaseIn && pulse.EaseOut ? x < 0.5 ? 2 * x * x : 1 - (float)Math.Pow(-2 * x + 2, 2) / 2 : + pulse.EaseIn ? x * x : + pulse.EaseOut ? 1 - (1 - x) * (1 - x) : + x; + + float theta = Quaternion.Lerp(start, end, progress).eulerAngles.x; + float phi = Quaternion.Lerp(start, end, progress).eulerAngles.y; + + float posX = cx + r * Mathf.Cos(theta * Mathf.Deg2Rad) * Mathf.Sin(phi * Mathf.Deg2Rad); + float posY = cy + r * Mathf.Sin(theta * Mathf.Deg2Rad); + + const float alphaEase = 0.3f; + var alpha = + pulse.EaseIn && x < alphaEase ? x / alphaEase : + pulse.EaseOut && x > 1f - alphaEase ? (1 - x) / alphaEase : + 1; + + using (MpStyle.Set(new Color(1, 1, 1, alpha))) + GUI.DrawTexture( + new Rect( + GetPos(bgRect, posX, posY), + new(6, 6) + ), + // After changing the game language, RimWorld destroys and reloads all assets but doesn't + // rerun static constructors, so resource fields reference destroyed resources + // The pulses disappear after changing the language + MultiplayerStatic.Pulse + ); + } + } + + static Vert GetNewEnd(Pulse pulse) + { + Vert newEnd = null; + var attempts = 0; + + while (newEnd == null && attempts < 20) + { + var randEdge = edges[rand.Next(edges.Count)]; + var otherVert = randEdge.OtherVert(pulse.end); + if (otherVert != null && otherVert != pulse.start && otherVert != pulse.end) + newEnd = otherVert; + attempts++; + } + + return newEnd; + } + + const float rotate = 35f; + const float maskTheta = 10; + + static void DrawEdges(Rect bgRect) + { + { + var proj1 = r * Project(maskTheta, -90).RotatedBy(rotate) + new Vector2(cx, cy); + var proj2 = r * Project(maskTheta, 90).RotatedBy(rotate) + new Vector2(cx, cy); + Widgets.DrawLine(GetPos(bgRect, proj1.x, proj1.y), GetPos(bgRect, proj2.x, proj2.y), Color.blue, 2f); + } + + foreach (var e in edges) + { + var start = Quaternion.Euler(e.v1.theta, e.v1.phi, 0); + var end = Quaternion.Euler(e.v2.theta, e.v2.phi, 0); + + float prevX = 0; + float prevY = 0; + + for (float d = 0; d <= 1; d += 0.25f) + { + float theta = Quaternion.Lerp(start, end, d).eulerAngles.x; + float phi = Quaternion.Lerp(start, end, d).eulerAngles.y; + + var proj = Project(theta, phi); + float x = cx + r * proj.x; + float y = cy + r * proj.y; + + if (prevX != 0 && prevY != 0) + { + var lineStart = GetPos(bgRect, prevX, prevY); + var lineEnd = GetPos(bgRect, x, y); + + if (d == 0.5f) + MpUI.Label(new Rect(lineStart, new(100, 100)), $"{e.dist}", GameFont.Tiny); + + Widgets.DrawLine(lineStart, lineEnd, Color.red, 2f); + } + + prevX = x; + prevY = y; + } + } + } + + static float SphereDist(float t1, float p1, float t2, float p2) + { + return Mathf.Acos( + Mathf.Sin(t1 * Mathf.Deg2Rad) * Mathf.Sin(t2 * Mathf.Deg2Rad) + + Mathf.Cos(t1 * Mathf.Deg2Rad) * Mathf.Cos(t2 * Mathf.Deg2Rad) * Mathf.Cos((p2 - p1) * Mathf.Deg2Rad) + ); + } + + private static List verts = new(); + private static List edges = new(); + private static List pulses = new(); + + static void Mst() + { + foreach (var v in verts) + { + v.cost = float.PositiveInfinity; + v.costVert = null; + } + + var q = verts.ToList(); + + while (q.Count > 0) + { + var v = q.MinBy(a => a.cost); + q.Remove(v); + + if (v.costVert != null) + edges.Add(new Edge(v, v.costVert)); + + foreach (var w in q) + { + var dist = SphereDist(v.theta, v.phi, w.theta, w.phi); + if (dist < w.cost) + { + w.cost = dist; + w.costVert = v; + } + } + } + } + + static Vector2 InverseSpherical(float x, float y, float r) + { + var ro = new Vector2(x, y).magnitude; + var c = Mathf.Asin(ro / r); + return new Vector2( + Mathf.Asin(y * Mathf.Sin(c) / ro) * Mathf.Rad2Deg, + Mathf.Atan2(x * Mathf.Sin(c), ro * Mathf.Cos(c)) * Mathf.Rad2Deg + ); + } + + static Vector2 GetPos(Rect rect, float x, float y) + { + return rect.min + new Vector2(rect.width * x / origWidth, rect.height * y / origHeight); + } + + static Vector2 Project(float theta, float phi) + { + return new Vector2( + Mathf.Cos(theta * Mathf.Deg2Rad) * Mathf.Sin(phi * Mathf.Deg2Rad), + Mathf.Sin(theta * Mathf.Deg2Rad) + ); + } + + static void RegenTexture() + { + using var _ = DeepProfilerWrapper.Section("RegenTexture"); + + verts.Clear(); + + // vaniller cities + verts.Add(new Vert { theta = 60, phi = 34 }); + verts.Add(new Vert { theta = -4, phi = -68 }); + verts.Add(new Vert { theta = -2, phi = -68 }); + verts.Add(new Vert { theta = 4, phi = -50 }); + verts.Add(new Vert { theta = 18, phi = -61 }); + + edges.Clear(); + pulses.Clear(); + + var colors = new Color[texWidth * texHeight]; + + // Cities + for (int i = 0; i < 20;) + { + float cityTheta = rand.Range(maskTheta, 75f); + float cityPhi = rand.Range(-75f, 75f); + + // ugh + var pr = Project(cityTheta, cityPhi).RotatedBy(rotate); + var inv = InverseSpherical(pr.x, pr.y, 1f); + cityTheta = inv.x; + cityPhi = inv.y; + + // Avoid the light side + var cityXY = r * Project(cityTheta, cityPhi) + new Vector2(cx, cy); + const float maskR = 2160; + if ((new Vector2(520 + maskR, 64 + maskR) - cityXY).magnitude < maskR) + continue; + + var blobs = rand.Next(2, 5); + if (rand.NextDouble() < 0.15) blobs += rand.Next(2, 4); + + Vector2? lit = null; + + // Blobs + for (int k = 0; k < blobs; k++) + { + var blobSpan = 2.5f + Math.Max(0, (blobs - 3) * 0.25f); + float theta = cityTheta + rand.Range(-blobSpan, blobSpan); + float phi = cityPhi + rand.Range(-blobSpan, blobSpan); + + // Dots + for (int j = 0; j < 7; j++) + { + var blobSize = j == 0 || rand.NextDouble() < 0.3f ? 15 : 10; + + float dotTheta = rand.Range(-1f, 1f); + float dotPhi = rand.Range(-1f, 1f); + + for (int y = -blobSize; y <= blobSize; y++) + for (int x = -blobSize; x <= blobSize; x++) + if (x * x + y * y < blobSize * blobSize) + { + var proj = Project(theta + dotTheta, phi + dotPhi); + float centerX = cx + r * proj.x; + float centerY = cy + r * proj.y; + + var c = 1 - new Vector2(x,y).magnitude / blobSize; + var fromCenter = 1 - new Vector2(dotTheta, dotPhi).magnitude / 2; + var fromCenter2 = 1 - new Vector2(theta - cityTheta, phi - cityPhi).magnitude / 5; + var a = c * fromCenter * fromCenter2; + + if (j == 0) a *= 3f; + else if (a > 0.4) a *= 2f; + + var pos = new Vector2(centerX, centerY); + var color = new Color(0.93f, 0.75f, 0.55f, a * a * 2); + var pixelX = (int)pos.x + x; + var pixelY = (int)origHeight - (int)pos.y + y; + colors[pixelX + pixelY * texWidth] = color; + + if (color.a >= 1 && lit == null) + { + lit = new Vector2(theta + dotTheta, phi + dotPhi); + } + } + } + } + + if (lit != null) + verts.Add(new Vert { theta = lit.Value.x, phi = lit.Value.y }); + + i++; + } + + using (DeepProfilerWrapper.Section("SetPixels and Apply")) + { + texture.SetPixels(colors); + texture.Apply(); + } + + Mst(); + } + + class Vert + { + public float theta; + public float phi; + + public float cost; + public Vert costVert; + } + + class Edge + { + public readonly Vert v1, v2; + public readonly float dist; + + public Edge(Vert v1, Vert v2) + { + this.v1 = v1; + this.v2 = v2; + + dist = SphereDist(v1.theta, v1.phi, v2.theta, v2.phi); + } + + protected bool Equals(Edge other) + { + return + Equals(v1, other.v1) && Equals(v2, other.v2) + || Equals(v1, other.v2) && Equals(v2, other.v1); + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + return obj.GetType() == GetType() && Equals((Edge)obj); + } + + public override int GetHashCode() + { + return v1.GetHashCode() * v2.GetHashCode(); + } + + public Vert OtherVert(Vert v) + { + return v1 == v ? v2 : v2 == v ? v1 : null; + } + } + + class Pulse + { + public float progress; + public Vert start; + public Vert end; + public float dist; + + public bool starting; + public bool ending; + public Vert nextEnd; + + public bool EaseIn => starting; + public bool EaseOut => ending || nextEnd == null; + + public Pulse(Vert start, Vert end) + { + this.start = start; + this.end = end; + + dist = SphereDist(start.theta, start.phi, end.theta, end.phi); + nextEnd = GetNewEnd(this); + } + } + } + + [HarmonyPatch(typeof(UIRoot_Entry), nameof(UIRoot_Entry.ShouldDoMainMenu), MethodType.Getter)] + static class DoMainMenuPatch + { + public static bool hideMainMenu; + + static void Postfix(ref bool __result) + { + if (hideMainMenu) + __result = false; + } + } +} diff --git a/Source/Client/Util/MpUI.cs b/Source/Client/Util/MpUI.cs index f055dde2b..590b34d02 100644 --- a/Source/Client/Util/MpUI.cs +++ b/Source/Client/Util/MpUI.cs @@ -1,278 +1,278 @@ -using System.Collections.Generic; -using HarmonyLib; -using RimWorld; -using UnityEngine; -using Verse; -using Verse.Sound; - -namespace Multiplayer.Client.Util -{ - public static class MpUI - { - public static Vector2 Resolution => new Vector2(UI.screenWidth, UI.screenHeight); - - public static string FixedEllipsis() - { - int num = Mathf.FloorToInt(Time.realtimeSinceStartup) % 3; - return num switch - { - 0 => ". ", - 1 => ".. ", - _ => "..." - }; - } - - public static void DrawRotatedLine(Vector2 center, float length, float width, float angle, Color color) - { - var size = new Vector2(length, width); - var start = center - size / 2f; - Rect screenRect = new Rect(start.x, start.y, length, width); - Matrix4x4 m = Matrix4x4.TRS(center, Quaternion.Euler(0f, 0f, angle), Vector3.one) * Matrix4x4.TRS(-center, Quaternion.identity, Vector3.one); - - GL.PushMatrix(); - GL.MultMatrix(m); - GUI.DrawTexture(screenRect, Widgets.LineTexAA, ScaleMode.StretchToFill, true, 0f, color, 0f, 0f); - GL.PopMatrix(); - } - - public static void Label(Rect rect, string label, GameFont? font = null, TextAnchor? anchor = null, Color? color = null) - { - var prevFont = Text.Font; - var prevAnchor = Text.Anchor; - var prevColor = GUI.color; - - if (font != null) - Text.Font = font.Value; - - if (anchor != null) - Text.Anchor = anchor.Value; - - if (color != null) - GUI.color = color.Value; - - Widgets.Label(rect, label); - - GUI.color = prevColor; - Text.Anchor = prevAnchor; - Text.Font = prevFont; - } - - public static void Checkbox(float x, float y, ref bool checkOn, bool disabled, float size) - { - if (!disabled && Widgets.ButtonInvisible(new Rect(x, y, size, size), false)) - { - checkOn = !checkOn; - if (checkOn) - SoundDefOf.Checkbox_TurnedOn.PlayOneShotOnCamera(null); - else - SoundDefOf.Checkbox_TurnedOff.PlayOneShotOnCamera(null); - } - - Widgets.CheckboxDraw(x, y, checkOn, disabled, size); - } - - public static Rect CheckboxLabeled(Rect rect, string label, ref bool checkOn, bool disabled = false, ElementOrder order = ElementOrder.Apart, float size = 24f) - { - TextAnchor anchor = Text.Anchor; - Text.Anchor = TextAnchor.MiddleLeft; - - if (order == ElementOrder.Right) - { - float textWidth = Text.CalcSize(label).x; - rect.x = rect.xMax - textWidth - size - 5f; - rect.width = textWidth + size + 5f; - } - else if (order == ElementOrder.Left) - { - float textWidth = Text.CalcSize(label).x; - rect.width = textWidth + size + 5f; - } - - Widgets.Label(rect, label); - - Checkbox( - rect.xMax - size, - rect.y + (rect.height - size) / 2, - ref checkOn, - disabled, - size); - - Text.Anchor = anchor; - - return rect; - } - - public static string TextEntryLabeled(Rect rect, string label, string text, float labelWidth) - { - Rect labelRect = rect.Rounded(); - labelRect.width = labelWidth; - - using (MpStyle.Set(TextAnchor.MiddleRight)) - Widgets.Label(labelRect, label); - - rect.xMin += labelWidth; - return Widgets.TextField(rect, text); - } - - public static bool TextFieldNumericLabeled(Rect rect, string label, ref T val, ref string buffer, float labelWidth, float min = 0, float max = float.MaxValue, bool clickable = false, string tooltip = null) where T : struct - { - Rect labelRect = rect; - labelRect.width = labelWidth; - - using (MpStyle.Set(TextAnchor.MiddleRight)) - using (MpStyle.Set(WordWrap.NoWrap)) - Widgets.Label(labelRect, label); - - if (clickable) - Widgets.DrawHighlightIfMouseover(labelRect.MinX(labelRect.xMax - Text.CalcSize(label).x - 10)); - - if (tooltip != null) - TooltipHandler.TipRegion(labelRect, tooltip); - - rect.xMin += labelWidth; - Widgets.TextFieldNumeric(rect, ref val, ref buffer, min, max); - - return clickable && Widgets.ButtonInvisible(labelRect); - } - - public static void ClearWindowStack() - { - Find.WindowStack.windows.Clear(); - } - - public static void TryUnfocusCurrentNamedControl(Window window) - { - // Unfocus the input field after a click or ESC press - if ((Event.current.type == EventType.MouseDown || KeyBindingDefOf.Cancel.KeyDownEvent) && !GUI.GetNameOfFocusedControl().NullOrEmpty()) - { - Event.current.Use(); - UI.UnfocusCurrentControl(); - Find.WindowStack.Notify_ManuallySetFocus(window); - } - } - - public static void LabelWithTip(Rect rect, string str, string tip) - { - Widgets.Label(rect, str); - - if (Mouse.IsOver(rect) && tip != null) - TooltipHandler.TipRegion(rect, new TipSignal(tip, 3333)); - } - - public static void LabelTruncatedWithTip(Rect rect, string str, Dictionary cache) - { - var trunc = str.Truncate(rect.width, cache); - LabelWithTip(rect, trunc, trunc != str ? str : null); - } - - public static void CheckboxLabeledWithTip(Rect rect, string label, string tip, ref bool check, bool disabled = false) - { - Widgets.DrawHighlightIfMouseover(rect); - if (tip != null) - TooltipHandler.TipRegion(rect, tip); - CheckboxLabeled(rect, label, ref check, disabled); - } - - public static void CheckboxLabeledWithTipNoHighlight(Rect rect, string label, string tip, ref bool check, bool disabled = false, bool placeTextNearCheckbox = false) - { - if (tip != null) - TooltipHandler.TipRegion(rect, tip); - CheckboxLabeled(rect, label, ref check, disabled, order: placeTextNearCheckbox ? ElementOrder.Right : ElementOrder.Apart); - } - - public static bool ButtonTextWithTip(Rect rect, string label, string tip, bool disabled = false, int? tipId = null) - { - if (tip != null) - TooltipHandler.TipRegion(rect, new TipSignal(tip, tipId ?? tip.GetHashCode())); - - using (MpStyle.Set(TextAnchor.MiddleCenter)) - using (MpStyle.Set(disabled ? Color.grey : Color.white)) - { - var atlas = !disabled && Mouse.IsOver(rect) ? - (Input.GetMouseButton(0) ? Widgets.ButtonBGAtlasClick : Widgets.ButtonBGAtlasMouseover) : - Widgets.ButtonBGAtlas; - - Widgets.DrawAtlas(rect, atlas); - Widgets.Label(rect, label); - } - - var clicked = Widgets.ButtonInvisible(rect, false); - - if (!disabled) - { - MouseoverSounds.DoRegion(rect); - return clicked; - } - - if (clicked) - SoundDefOf.ClickReject.PlayOneShotOnCamera(); - - return false; - } - - public static float LabelFlexibleWidth(Rect rect, string label) - { - var size = Text.CalcSize(label); - rect.width = size.x; - Widgets.Label(rect, label); - return size.x; - } - - public static float LabelFlexibleWidthClickable(Rect rect, string label, ref bool clicked) - { - var size = Text.CalcSize(label); - rect.width = size.x; - Widgets.DrawHighlightIfMouseover(rect); - clicked = Widgets.ButtonInvisible(rect, false); - Widgets.Label(rect, label); - return size.x; - } - - const char PassChar = '\u2022'; - - public static void DoPasswordField(Rect rect, string controlName, ref string password) - { - password ??= ""; - - TextEditor te = - GUI.GetNameOfFocusedControl() == controlName ? - (TextEditor)GUIUtility.GetStateObject(typeof(TextEditor), GUIUtility.keyboardControl) : - null; - - Vector2 scrollOffset = default; - bool keyDown = Event.current.type == EventType.KeyDown; - if (te != null) - scrollOffset = te.scrollOffset; - - GUI.SetNextControlName(controlName); - - if (Event.current.type is EventType.Repaint or EventType.MouseDown) - GUI.TextField( - rect, - new string(PassChar, password.Length), - Text.textFieldStyles[1] - ); - else - password = GUI.TextField( - rect, - password, - Text.textFieldStyles[1] - ); - - if (te != null && keyDown) - { - te.scrollOffset = scrollOffset; +using System.Collections.Generic; +using HarmonyLib; +using RimWorld; +using UnityEngine; +using Verse; +using Verse.Sound; + +namespace Multiplayer.Client.Util +{ + public static class MpUI + { + public static Vector2 Resolution => new Vector2(UI.screenWidth, UI.screenHeight); + + public static string FixedEllipsis() + { + int num = Mathf.FloorToInt(Time.realtimeSinceStartup) % 3; + return num switch + { + 0 => ". ", + 1 => ".. ", + _ => "..." + }; + } + + public static void DrawRotatedLine(Vector2 center, float length, float width, float angle, Color color) + { + var size = new Vector2(length, width); + var start = center - size / 2f; + Rect screenRect = new Rect(start.x, start.y, length, width); + Matrix4x4 m = Matrix4x4.TRS(center, Quaternion.Euler(0f, 0f, angle), Vector3.one) * Matrix4x4.TRS(-center, Quaternion.identity, Vector3.one); + + GL.PushMatrix(); + GL.MultMatrix(m); + GUI.DrawTexture(screenRect, Widgets.LineTexAA, ScaleMode.StretchToFill, true, 0f, color, 0f, 0f); + GL.PopMatrix(); + } + + public static void Label(Rect rect, string label, GameFont? font = null, TextAnchor? anchor = null, Color? color = null) + { + var prevFont = Text.Font; + var prevAnchor = Text.Anchor; + var prevColor = GUI.color; + + if (font != null) + Text.Font = font.Value; + + if (anchor != null) + Text.Anchor = anchor.Value; + + if (color != null) + GUI.color = color.Value; + + Widgets.Label(rect, label); + + GUI.color = prevColor; + Text.Anchor = prevAnchor; + Text.Font = prevFont; + } + + public static void Checkbox(float x, float y, ref bool checkOn, bool disabled, float size) + { + if (!disabled && Widgets.ButtonInvisible(new Rect(x, y, size, size), false)) + { + checkOn = !checkOn; + if (checkOn) + SoundDefOf.Checkbox_TurnedOn.PlayOneShotOnCamera(null); + else + SoundDefOf.Checkbox_TurnedOff.PlayOneShotOnCamera(null); + } + + Widgets.CheckboxDraw(x, y, checkOn, disabled, size); + } + + public static Rect CheckboxLabeled(Rect rect, string label, ref bool checkOn, bool disabled = false, ElementOrder order = ElementOrder.Apart, float size = 24f) + { + TextAnchor anchor = Text.Anchor; + Text.Anchor = TextAnchor.MiddleLeft; + + if (order == ElementOrder.Right) + { + float textWidth = Text.CalcSize(label).x; + rect.x = rect.xMax - textWidth - size - 5f; + rect.width = textWidth + size + 5f; + } + else if (order == ElementOrder.Left) + { + float textWidth = Text.CalcSize(label).x; + rect.width = textWidth + size + 5f; + } + + Widgets.Label(rect, label); + + Checkbox( + rect.xMax - size, + rect.y + (rect.height - size) / 2, + ref checkOn, + disabled, + size); + + Text.Anchor = anchor; + + return rect; + } + + public static string TextEntryLabeled(Rect rect, string label, string text, float labelWidth) + { + Rect labelRect = rect.Rounded(); + labelRect.width = labelWidth; + + using (MpStyle.Set(TextAnchor.MiddleRight)) + Widgets.Label(labelRect, label); + + rect.xMin += labelWidth; + return Widgets.TextField(rect, text); + } + + public static bool TextFieldNumericLabeled(Rect rect, string label, ref T val, ref string buffer, float labelWidth, float min = 0, float max = float.MaxValue, bool clickable = false, string tooltip = null) where T : struct + { + Rect labelRect = rect; + labelRect.width = labelWidth; + + using (MpStyle.Set(TextAnchor.MiddleRight)) + using (MpStyle.Set(WordWrap.NoWrap)) + Widgets.Label(labelRect, label); + + if (clickable) + Widgets.DrawHighlightIfMouseover(labelRect.MinX(labelRect.xMax - Text.CalcSize(label).x - 10)); + + if (tooltip != null) + TooltipHandler.TipRegion(labelRect, tooltip); + + rect.xMin += labelWidth; + Widgets.TextFieldNumeric(rect, ref val, ref buffer, min, max); + + return clickable && Widgets.ButtonInvisible(labelRect); + } + + public static void ClearWindowStack() + { + Find.WindowStack.windows.Clear(); + } + + public static void TryUnfocusCurrentNamedControl(Window window) + { + // Unfocus the input field after a click or ESC press + if ((Event.current.type == EventType.MouseDown || KeyBindingDefOf.Cancel.KeyDownEvent) && !GUI.GetNameOfFocusedControl().NullOrEmpty()) + { + Event.current.Use(); + UI.UnfocusCurrentControl(); + Find.WindowStack.Notify_ManuallySetFocus(window); + } + } + + public static void LabelWithTip(Rect rect, string str, string tip) + { + Widgets.Label(rect, str); + + if (Mouse.IsOver(rect) && tip != null) + TooltipHandler.TipRegion(rect, new TipSignal(tip, 3333)); + } + + public static void LabelTruncatedWithTip(Rect rect, string str, Dictionary cache) + { + var trunc = str.Truncate(rect.width, cache); + LabelWithTip(rect, trunc, trunc != str ? str : null); + } + + public static void CheckboxLabeledWithTip(Rect rect, string label, string tip, ref bool check, bool disabled = false) + { + Widgets.DrawHighlightIfMouseover(rect); + if (tip != null) + TooltipHandler.TipRegion(rect, tip); + CheckboxLabeled(rect, label, ref check, disabled); + } + + public static void CheckboxLabeledWithTipNoHighlight(Rect rect, string label, string tip, ref bool check, bool disabled = false, bool placeTextNearCheckbox = false) + { + if (tip != null) + TooltipHandler.TipRegion(rect, tip); + CheckboxLabeled(rect, label, ref check, disabled, order: placeTextNearCheckbox ? ElementOrder.Right : ElementOrder.Apart); + } + + public static bool ButtonTextWithTip(Rect rect, string label, string tip, bool disabled = false, int? tipId = null) + { + if (tip != null) + TooltipHandler.TipRegion(rect, new TipSignal(tip, tipId ?? tip.GetHashCode())); + + using (MpStyle.Set(TextAnchor.MiddleCenter)) + using (MpStyle.Set(disabled ? Color.grey : Color.white)) + { + var atlas = !disabled && Mouse.IsOver(rect) ? + (Input.GetMouseButton(0) ? Widgets.ButtonBGAtlasClick : Widgets.ButtonBGAtlasMouseover) : + Widgets.ButtonBGAtlas; + + Widgets.DrawAtlas(rect, atlas); + Widgets.Label(rect, label); + } + + var clicked = Widgets.ButtonInvisible(rect, false); + + if (!disabled) + { + MouseoverSounds.DoRegion(rect); + return clicked; + } + + if (clicked) + SoundDefOf.ClickReject.PlayOneShotOnCamera(); + + return false; + } + + public static float LabelFlexibleWidth(Rect rect, string label) + { + var size = Text.CalcSize(label); + rect.width = size.x; + Widgets.Label(rect, label); + return size.x; + } + + public static float LabelFlexibleWidthClickable(Rect rect, string label, ref bool clicked) + { + var size = Text.CalcSize(label); + rect.width = size.x; + Widgets.DrawHighlightIfMouseover(rect); + clicked = Widgets.ButtonInvisible(rect, false); + Widgets.Label(rect, label); + return size.x; + } + + const char PassChar = '\u2022'; + + public static void DoPasswordField(Rect rect, string controlName, ref string password) + { + password ??= ""; + + TextEditor te = + GUI.GetNameOfFocusedControl() == controlName ? + (TextEditor)GUIUtility.GetStateObject(typeof(TextEditor), GUIUtility.keyboardControl) : + null; + + Vector2 scrollOffset = default; + bool keyDown = Event.current.type == EventType.KeyDown; + if (te != null) + scrollOffset = te.scrollOffset; + + GUI.SetNextControlName(controlName); + + if (Event.current.type is EventType.Repaint or EventType.MouseDown) + GUI.TextField( + rect, + new string(PassChar, password.Length), + Text.textFieldStyles[1] + ); + else + password = GUI.TextField( + rect, + password, + Text.textFieldStyles[1] + ); + + if (te != null && keyDown) + { + te.scrollOffset = scrollOffset; te.text = new string(PassChar, password.Length); #if !RIMWORLD_1_7 AccessTools.Field(typeof(TextEditor), "m_RevealCursor").SetValue(te, true); te.UpdateScrollOffsetIfNeeded(new Event()); #endif - } - } - } - - public enum ElementOrder - { - Apart, Left, Right - } -} + } + } + } + + public enum ElementOrder + { + Apart, Left, Right + } +} diff --git a/Source/Client/Util/MpUtil.cs b/Source/Client/Util/MpUtil.cs index ae7ee5f4b..447ae078c 100644 --- a/Source/Client/Util/MpUtil.cs +++ b/Source/Client/Util/MpUtil.cs @@ -1,206 +1,206 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Sockets; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.Serialization; -using System.Text; -using HarmonyLib; -using Verse; - -namespace Multiplayer.Client -{ - public static class MpUtil - { +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.Serialization; +using System.Text; +using HarmonyLib; +using Verse; + +namespace Multiplayer.Client +{ + public static class MpUtil + { public static unsafe void MarkNoInlining(MethodBase method) { // This offset is a MonoMethod layout, not a CoreCLR MethodDesc. // Harmony handles detouring on CoreCLR; writing here corrupts memory. if (!Native.IsMonoRuntime) return; ushort* iflags = (ushort*)(method.MethodHandle.Value) + 1; - *iflags |= (ushort)MethodImplOptions.NoInlining; - } - - public static object NewObjectNoCtor(Type type) - { - return FormatterServices.GetUninitializedObject(type); - } - - public static T NewObjectNoCtor() - { - return (T)NewObjectNoCtor(typeof(T)); - } - - private static bool ipv6; - private static bool ipv6Checked; - - // Socket.OSSupportsIPv6 doesn't seem to work - public static bool SupportsIPv6() - { - if (!ipv6Checked) - { - try - { - new Socket(AddressFamily.InterNetworkV6, SocketType.Dgram, ProtocolType.Udp); - ipv6 = true; - } - catch - { - ipv6 = false; - } - - ipv6Checked = true; - } - - return ipv6; - } - - public static string RwDataFile(string filename) - { - return Path.Combine(GenFilePaths.SaveDataFolderPath, filename); - } - - public static To ShallowCopy(From from, To to) where To : From - { - foreach (var f in AccessTools.GetDeclaredFields(typeof(From))) - if (!f.IsStatic) - f.SetValue(to, f.GetValue(from)); - - return to; - } - - public static string TranslateWithDoubleNewLines(string keyBase, int count) - { - return Enumerable.Range(1, count).Select(n => (keyBase + n).Translate()).Join(delimiter: "\n\n"); - } - - public static string DelegateMethodInfo(MethodBase m) - { - return - m == null - ? "No method" - : $"{m.DeclaringType.DeclaringType?.FullDescription()} {m.DeclaringType.FullDescription()} {m.Name}" - .Replace("<", "[").Replace(">", "]"); - } - - public static MethodBase GetOriginalFromHarmonyReplacement(long replacementAddr) - { - // Todo: this is using a non-public API of Harmony, we should refactor to use https://harmony.pardeike.net/api/HarmonyLib.Harmony.html#HarmonyLib_Harmony_GetOriginalMethod_System_Reflection_MethodInfo_ - // as pardeike suggested in https://github.com/rwmt/Multiplayer/pull/270#issuecomment-1003298289 - return HarmonySharedState.originals - .FirstOrDefault(kv => kv.Key.MethodHandle.GetFunctionPointer().ToInt64() == replacementAddr).Value; - } - - public static string JoinStringsAtMost(this IEnumerable strs, int atMost = 3) - { - return strs.Take(atMost).Join() + (strs.Count() > atMost ? "..." : ""); - } - } - - public struct Container - { - public T Inner { get; } - - public Container(T value) - { - Inner = value; - } - - public static implicit operator Container(T value) - { - return new Container(value); - } - } - - public class UniqueList : IEnumerable - { - private List list = new List(); - private HashSet set = new HashSet(); - - public int Count => list.Count; - public T this[int index] => list[index]; - - public bool Add(T t) - { - if (set.Add(t)) - { - list.Add(t); - return true; - } - - return false; - } - - public T[] ToArray() - { - return list.ToArray(); - } - - public bool Contains(T t) - { - return set.Contains(t); - } - - public int IndexOf(T t) - { - return list.IndexOf(t); - } - - public IEnumerator GetEnumerator() - { - return list.GetEnumerator(); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return list.GetEnumerator(); - } - } - - public class IdentityComparer : IEqualityComparer - { - public static IdentityComparer Instance = new(); - - public bool Equals(T x, T y) - { - return object.Equals(x, y); - } - - public int GetHashCode(T obj) - { - return RuntimeHelpers.GetHashCode(obj); - } - } - - public sealed class Utf8StringWriter : StringWriter - { - public override Encoding Encoding => Encoding.UTF8; - } - - public class FixedSizeQueue : IEnumerable - { - private Queue q = new Queue(); - - public int Limit { get; set; } - - public void Enqueue(T obj) - { - q.Enqueue(obj); - while (q.Count > Limit) q.Dequeue(); - } - - public IEnumerator GetEnumerator() - { - return q.GetEnumerator(); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return q.GetEnumerator(); - } - } -} + *iflags |= (ushort)MethodImplOptions.NoInlining; + } + + public static object NewObjectNoCtor(Type type) + { + return FormatterServices.GetUninitializedObject(type); + } + + public static T NewObjectNoCtor() + { + return (T)NewObjectNoCtor(typeof(T)); + } + + private static bool ipv6; + private static bool ipv6Checked; + + // Socket.OSSupportsIPv6 doesn't seem to work + public static bool SupportsIPv6() + { + if (!ipv6Checked) + { + try + { + new Socket(AddressFamily.InterNetworkV6, SocketType.Dgram, ProtocolType.Udp); + ipv6 = true; + } + catch + { + ipv6 = false; + } + + ipv6Checked = true; + } + + return ipv6; + } + + public static string RwDataFile(string filename) + { + return Path.Combine(GenFilePaths.SaveDataFolderPath, filename); + } + + public static To ShallowCopy(From from, To to) where To : From + { + foreach (var f in AccessTools.GetDeclaredFields(typeof(From))) + if (!f.IsStatic) + f.SetValue(to, f.GetValue(from)); + + return to; + } + + public static string TranslateWithDoubleNewLines(string keyBase, int count) + { + return Enumerable.Range(1, count).Select(n => (keyBase + n).Translate()).Join(delimiter: "\n\n"); + } + + public static string DelegateMethodInfo(MethodBase m) + { + return + m == null + ? "No method" + : $"{m.DeclaringType.DeclaringType?.FullDescription()} {m.DeclaringType.FullDescription()} {m.Name}" + .Replace("<", "[").Replace(">", "]"); + } + + public static MethodBase GetOriginalFromHarmonyReplacement(long replacementAddr) + { + // Todo: this is using a non-public API of Harmony, we should refactor to use https://harmony.pardeike.net/api/HarmonyLib.Harmony.html#HarmonyLib_Harmony_GetOriginalMethod_System_Reflection_MethodInfo_ + // as pardeike suggested in https://github.com/rwmt/Multiplayer/pull/270#issuecomment-1003298289 + return HarmonySharedState.originals + .FirstOrDefault(kv => kv.Key.MethodHandle.GetFunctionPointer().ToInt64() == replacementAddr).Value; + } + + public static string JoinStringsAtMost(this IEnumerable strs, int atMost = 3) + { + return strs.Take(atMost).Join() + (strs.Count() > atMost ? "..." : ""); + } + } + + public struct Container + { + public T Inner { get; } + + public Container(T value) + { + Inner = value; + } + + public static implicit operator Container(T value) + { + return new Container(value); + } + } + + public class UniqueList : IEnumerable + { + private List list = new List(); + private HashSet set = new HashSet(); + + public int Count => list.Count; + public T this[int index] => list[index]; + + public bool Add(T t) + { + if (set.Add(t)) + { + list.Add(t); + return true; + } + + return false; + } + + public T[] ToArray() + { + return list.ToArray(); + } + + public bool Contains(T t) + { + return set.Contains(t); + } + + public int IndexOf(T t) + { + return list.IndexOf(t); + } + + public IEnumerator GetEnumerator() + { + return list.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return list.GetEnumerator(); + } + } + + public class IdentityComparer : IEqualityComparer + { + public static IdentityComparer Instance = new(); + + public bool Equals(T x, T y) + { + return object.Equals(x, y); + } + + public int GetHashCode(T obj) + { + return RuntimeHelpers.GetHashCode(obj); + } + } + + public sealed class Utf8StringWriter : StringWriter + { + public override Encoding Encoding => Encoding.UTF8; + } + + public class FixedSizeQueue : IEnumerable + { + private Queue q = new Queue(); + + public int Limit { get; set; } + + public void Enqueue(T obj) + { + q.Enqueue(obj); + while (q.Count > Limit) q.Dequeue(); + } + + public IEnumerator GetEnumerator() + { + return q.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return q.GetEnumerator(); + } + } +} diff --git a/Source/Common/Common.csproj b/Source/Common/Common.csproj index 2f20117b2..e90e1c879 100644 --- a/Source/Common/Common.csproj +++ b/Source/Common/Common.csproj @@ -1,43 +1,43 @@ - - - + + + net48 - net10.0 - true - enable - 12 - false - false - Multiplayer.Common - MultiplayerCommon - - - - - - - - - - - - - - - - - - - - - - - - - - $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)'))=$(AssemblyName)/ - - - - - + net10.0 + true + enable + 12 + false + false + Multiplayer.Common + MultiplayerCommon + + + + + + + + + + + + + + + + + + + + + + + + + + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)'))=$(AssemblyName)/ + + + + + diff --git a/Source/Common/Native.cs b/Source/Common/Native.cs index 8b64b7b52..e9c993bc1 100644 --- a/Source/Common/Native.cs +++ b/Source/Common/Native.cs @@ -1,275 +1,275 @@ -using System; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using System.Threading; -using HarmonyLib; - -namespace Multiplayer.Client -{ +using System; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading; +using HarmonyLib; + +namespace Multiplayer.Client +{ public static class Native { // Waylith uses CoreCLR. Never load Mono libraries or dereference Mono // thread structures on that runtime, even when running old mod assemblies. public static bool IsMonoRuntime => Type.GetType("Mono.Runtime") != null; - public enum NativeOS - { - Windows, OSX, Linux, Dummy - } - - public static IntPtr DomainPtr { get; private set; } - - // LMF is Last Managed Frame - // current_thread->internal_thread->thread_info->tls[4] - public static long LmfPtr { get; private set; } - - public static Func? HarmonyOriginalGetter { get; set; } - + public enum NativeOS + { + Windows, OSX, Linux, Dummy + } + + public static IntPtr DomainPtr { get; private set; } + + // LMF is Last Managed Frame + // current_thread->internal_thread->thread_info->tls[4] + public static long LmfPtr { get; private set; } + + public static Func? HarmonyOriginalGetter { get; set; } + public static void EarlyInit(NativeOS os) { if (!IsMonoRuntime) return; - if (os == NativeOS.Linux) - TheLinuxWay(); - if (os == NativeOS.OSX) - TheOSXWay(); - - EarlyInitInternal(); - } - - [MethodImpl(MethodImplOptions.NoInlining)] - static void TheLinuxWay() => mono_dllmap_insert_linux(IntPtr.Zero, MonoWindows, null, MonoLinux, null); - - [MethodImpl(MethodImplOptions.NoInlining)] - static void TheOSXWay() => mono_dllmap_insert_osx(IntPtr.Zero, MonoWindows, null, MonoOSX, null); - - [MethodImpl(MethodImplOptions.NoInlining)] - private static void EarlyInitInternal() - { - DomainPtr = mono_domain_get(); - } - + if (os == NativeOS.Linux) + TheLinuxWay(); + if (os == NativeOS.OSX) + TheOSXWay(); + + EarlyInitInternal(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static void TheLinuxWay() => mono_dllmap_insert_linux(IntPtr.Zero, MonoWindows, null, MonoLinux, null); + + [MethodImpl(MethodImplOptions.NoInlining)] + static void TheOSXWay() => mono_dllmap_insert_osx(IntPtr.Zero, MonoWindows, null, MonoOSX, null); + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void EarlyInitInternal() + { + DomainPtr = mono_domain_get(); + } + public static unsafe void InitLmfPtr(NativeOS os) { if (!IsMonoRuntime) return; - // Don't bother on 32 bit runtimes - if (IntPtr.Size == 4) - return; - - const BindingFlags all = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | - BindingFlags.NonPublic; - - var internalThreadField = typeof(Thread).GetField("internal_thread", all); - var threadInfoField = internalThreadField.FieldType.GetField("runtime_thread_info", all); - var threadInfoPtr = (long)(IntPtr)threadInfoField.GetValue(internalThreadField.GetValue(Thread.CurrentThread)); - - // Struct offset found manually - // Navigate by string: "Handle Stack" - // Updated for RimWorld 1.6 (Unity 2022.3.35f1, Mono 6.13.0) - if (os == NativeOS.Linux) - LmfPtr = threadInfoPtr + 0x450; // Updated: 1.5 was 0x460, -16 bytes = 0x450 (following Windows pattern) - else if (os == NativeOS.Windows) - LmfPtr = threadInfoPtr + 0x418; // Updated for 1.6. Seems to work so far. - else if (os == NativeOS.OSX) - LmfPtr = threadInfoPtr + 0x3E8; // Updated: 1.5 was 0x3F8, -16 bytes = 0x3E8 (following Windows pattern) - else if (os == NativeOS.Dummy) - { - LmfPtr = (long)Marshal.AllocHGlobal(3 * 8); - *(long*)LmfPtr = LmfPtr; - *(long*)(LmfPtr + 8) = 0; - } - } - - private static IntPtr MaybeFindHarmonyOriginalMethod(long addr, bool harmonyOriginals) - { - var ji = mono_jit_info_table_find(DomainPtr, (IntPtr)addr); - if (ji == IntPtr.Zero) return IntPtr.Zero; - - var ptr = mono_jit_info_get_method(ji); - var codeStart = (long)mono_jit_info_get_code_start(ji); - - if (harmonyOriginals && HarmonyOriginalGetter != null) - { - var original = HarmonyOriginalGetter(codeStart); - if (original != null) - ptr = original.MethodHandle.Value; - } - - return ptr; - } - - public static string? MethodNameFromAddr(long addr, bool harmonyOriginals) - { - var ptr = MaybeFindHarmonyOriginalMethod(addr, harmonyOriginals); - if (ptr == IntPtr.Zero) return null; - var name = mono_debug_print_stack_frame(ptr, -1, DomainPtr); - return string.IsNullOrEmpty(name) ? null : name; - } - - public static string? MethodNameNormalizedFromAddr(long addr, bool harmonyOriginals) - { - var ptr = MaybeFindHarmonyOriginalMethod(addr, harmonyOriginals); - return ptr == IntPtr.Zero ? null : mono_method_get_reflection_name(ptr); - } - - private static ConstructorInfo runtimeMethodHandleCtor = AccessTools.Constructor(typeof(RuntimeMethodHandle), [typeof(IntPtr)]); - - public static bool GetMethodAggressiveInlining(long addr) - { - var domain = DomainPtr; - var ji = mono_jit_info_table_find(domain, (IntPtr)addr); - - if (ji == IntPtr.Zero) return false; - - var methodHandle = mono_jit_info_get_method(ji); - var methodBase = GetMethodBaseFromRuntimePointer(methodHandle); - - if (methodBase == null) return false; - - return (methodBase.MethodImplementationFlags & MethodImplAttributes.AggressiveInlining) != 0; - } - - public static MethodBase? GetMethodBaseFromRuntimePointer(IntPtr methodHandle) - { - if (methodHandle == IntPtr.Zero) - return null; - var rmh = (RuntimeMethodHandle)runtimeMethodHandleCtor.Invoke([methodHandle]); - var rth = new RuntimeTypeHandle(); - var methodBase = MethodBase.GetMethodFromHandle(rmh, rth); - return methodBase; - } - - // const string MonoWindows = "mono-2.0-sgen.dll"; - const string MonoWindows = "mono-2.0-bdwgc"; - const string MonoLinux = "libmonobdwgc-2.0.so"; - const string MonoOSX = "libmonobdwgc-2.0.dylib"; - - [DllImport(MonoLinux, EntryPoint = "mono_dllmap_insert")] - private static extern void mono_dllmap_insert_linux(IntPtr assembly, string? dll, string? func, string? tdll, string? tfunc); - - [DllImport(MonoOSX, EntryPoint = "mono_dllmap_insert")] - private static extern void mono_dllmap_insert_osx(IntPtr assembly, string? dll, string? func, string? tdll, string? tfunc); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_jit_info_table_find(IntPtr domain, IntPtr addr); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_jit_info_get_method(IntPtr ji); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_jit_info_get_code_start(IntPtr ji); - - [DllImport(MonoWindows)] - public static extern int mono_jit_info_get_code_size(IntPtr ji); - - [DllImport(MonoWindows)] - public static extern string mono_debug_print_stack_frame(IntPtr method, int nativeOffset, IntPtr domain); - - [DllImport(MonoWindows)] - public static extern int mini_parse_debug_option(string option); - - [DllImport(MonoWindows)] - public static extern void mono_set_defaults(int verboseLevel, int opts); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_domain_get(); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_compile_method(IntPtr method); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_class_from_mono_type(IntPtr type); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_class_vtable(IntPtr domain, IntPtr klass); - - [DllImport(MonoWindows)] - public static extern int mono_method_get_token(IntPtr method); - - [DllImport(MonoWindows)] - public static extern string mono_method_get_reflection_name(IntPtr method); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_method_get_class(IntPtr method); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_class_get_image(IntPtr klass); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_profiler_create(IntPtr profiler); - - public delegate void JitDoneCallback(IntPtr profiler, IntPtr method, IntPtr jinfo); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_profiler_set_jit_done_callback(IntPtr profiler, JitDoneCallback? cb); - - public static unsafe bool CctorRan(Type t) - { - return *((byte*)mono_class_vtable(mono_domain_get(), mono_class_from_mono_type(t.TypeHandle.Value)) + 45) != 0; - } - } - - /*public static class IcedDisasm - { - public unsafe static void DisasmMethod(long codeStart) - { - DisasmMethod(codeStart, Native.mono_jit_info_get_code_size(Native.mono_jit_info_table_find(Native.DomainPtr, (IntPtr)codeStart))); - } - - public unsafe static void DisasmMethod(long codeStart, long codeSize) - { - //Log.Message($"code {Convert.ToString(*(uint*)codeStart, 16)}"); - //return; - - var codeRip = (ulong)codeStart; - var codeReader = new UnsafeCodeReader() { start = (byte*)codeStart }; - var decoder = Decoder.Create(64, codeReader); - decoder.IP = codeRip; - ulong endRip = decoder.IP + (uint)codeSize + 8; - - var instructions = new InstructionList(); - while (decoder.IP < endRip) - decoder.Decode(out instructions.AllocUninitializedElement()); - - var formatter = new NasmFormatter(); - formatter.Options.DigitSeparator = "`"; - formatter.Options.FirstOperandCharIndex = 10; - var output = new StringOutput(); - - bool cond = (*(uint*)codeStart & 0xFFFFFF) != 0xEC8348 && *(byte*)codeStart != 0x55; - - Log.Message("Code size: " + codeSize, true); - - foreach (ref var instr in instructions) - { - var stringb = new StringBuilder(); - formatter.Format(instr, output); - stringb.Append(instr.IP.ToString("X16")); - stringb.Append(" "); - int instrLen = instr.Length; - int byteBaseIndex = (int)(instr.IP - codeRip); - for (int i = 0; i < instrLen; i++) - stringb.Append(((byte*)codeStart)[byteBaseIndex + i].ToString("X2")); - int missingBytes = 10 - instrLen; - for (int i = 0; i < missingBytes; i++) - stringb.Append(" "); - stringb.Append(" "); - stringb.Append(output.ToStringAndReset()); - //if (cond) - Log.Message(stringb.ToString(), ignoreStopLoggingLimit: true); - } - } - - public unsafe class UnsafeCodeReader : CodeReader - { - public byte* start; - - public unsafe override int ReadByte() - { - var val = *start; - start++; - return val; - } - } - }*/ -} + // Don't bother on 32 bit runtimes + if (IntPtr.Size == 4) + return; + + const BindingFlags all = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | + BindingFlags.NonPublic; + + var internalThreadField = typeof(Thread).GetField("internal_thread", all); + var threadInfoField = internalThreadField.FieldType.GetField("runtime_thread_info", all); + var threadInfoPtr = (long)(IntPtr)threadInfoField.GetValue(internalThreadField.GetValue(Thread.CurrentThread)); + + // Struct offset found manually + // Navigate by string: "Handle Stack" + // Updated for RimWorld 1.6 (Unity 2022.3.35f1, Mono 6.13.0) + if (os == NativeOS.Linux) + LmfPtr = threadInfoPtr + 0x450; // Updated: 1.5 was 0x460, -16 bytes = 0x450 (following Windows pattern) + else if (os == NativeOS.Windows) + LmfPtr = threadInfoPtr + 0x418; // Updated for 1.6. Seems to work so far. + else if (os == NativeOS.OSX) + LmfPtr = threadInfoPtr + 0x3E8; // Updated: 1.5 was 0x3F8, -16 bytes = 0x3E8 (following Windows pattern) + else if (os == NativeOS.Dummy) + { + LmfPtr = (long)Marshal.AllocHGlobal(3 * 8); + *(long*)LmfPtr = LmfPtr; + *(long*)(LmfPtr + 8) = 0; + } + } + + private static IntPtr MaybeFindHarmonyOriginalMethod(long addr, bool harmonyOriginals) + { + var ji = mono_jit_info_table_find(DomainPtr, (IntPtr)addr); + if (ji == IntPtr.Zero) return IntPtr.Zero; + + var ptr = mono_jit_info_get_method(ji); + var codeStart = (long)mono_jit_info_get_code_start(ji); + + if (harmonyOriginals && HarmonyOriginalGetter != null) + { + var original = HarmonyOriginalGetter(codeStart); + if (original != null) + ptr = original.MethodHandle.Value; + } + + return ptr; + } + + public static string? MethodNameFromAddr(long addr, bool harmonyOriginals) + { + var ptr = MaybeFindHarmonyOriginalMethod(addr, harmonyOriginals); + if (ptr == IntPtr.Zero) return null; + var name = mono_debug_print_stack_frame(ptr, -1, DomainPtr); + return string.IsNullOrEmpty(name) ? null : name; + } + + public static string? MethodNameNormalizedFromAddr(long addr, bool harmonyOriginals) + { + var ptr = MaybeFindHarmonyOriginalMethod(addr, harmonyOriginals); + return ptr == IntPtr.Zero ? null : mono_method_get_reflection_name(ptr); + } + + private static ConstructorInfo runtimeMethodHandleCtor = AccessTools.Constructor(typeof(RuntimeMethodHandle), [typeof(IntPtr)]); + + public static bool GetMethodAggressiveInlining(long addr) + { + var domain = DomainPtr; + var ji = mono_jit_info_table_find(domain, (IntPtr)addr); + + if (ji == IntPtr.Zero) return false; + + var methodHandle = mono_jit_info_get_method(ji); + var methodBase = GetMethodBaseFromRuntimePointer(methodHandle); + + if (methodBase == null) return false; + + return (methodBase.MethodImplementationFlags & MethodImplAttributes.AggressiveInlining) != 0; + } + + public static MethodBase? GetMethodBaseFromRuntimePointer(IntPtr methodHandle) + { + if (methodHandle == IntPtr.Zero) + return null; + var rmh = (RuntimeMethodHandle)runtimeMethodHandleCtor.Invoke([methodHandle]); + var rth = new RuntimeTypeHandle(); + var methodBase = MethodBase.GetMethodFromHandle(rmh, rth); + return methodBase; + } + + // const string MonoWindows = "mono-2.0-sgen.dll"; + const string MonoWindows = "mono-2.0-bdwgc"; + const string MonoLinux = "libmonobdwgc-2.0.so"; + const string MonoOSX = "libmonobdwgc-2.0.dylib"; + + [DllImport(MonoLinux, EntryPoint = "mono_dllmap_insert")] + private static extern void mono_dllmap_insert_linux(IntPtr assembly, string? dll, string? func, string? tdll, string? tfunc); + + [DllImport(MonoOSX, EntryPoint = "mono_dllmap_insert")] + private static extern void mono_dllmap_insert_osx(IntPtr assembly, string? dll, string? func, string? tdll, string? tfunc); + + [DllImport(MonoWindows)] + public static extern IntPtr mono_jit_info_table_find(IntPtr domain, IntPtr addr); + + [DllImport(MonoWindows)] + public static extern IntPtr mono_jit_info_get_method(IntPtr ji); + + [DllImport(MonoWindows)] + public static extern IntPtr mono_jit_info_get_code_start(IntPtr ji); + + [DllImport(MonoWindows)] + public static extern int mono_jit_info_get_code_size(IntPtr ji); + + [DllImport(MonoWindows)] + public static extern string mono_debug_print_stack_frame(IntPtr method, int nativeOffset, IntPtr domain); + + [DllImport(MonoWindows)] + public static extern int mini_parse_debug_option(string option); + + [DllImport(MonoWindows)] + public static extern void mono_set_defaults(int verboseLevel, int opts); + + [DllImport(MonoWindows)] + public static extern IntPtr mono_domain_get(); + + [DllImport(MonoWindows)] + public static extern IntPtr mono_compile_method(IntPtr method); + + [DllImport(MonoWindows)] + public static extern IntPtr mono_class_from_mono_type(IntPtr type); + + [DllImport(MonoWindows)] + public static extern IntPtr mono_class_vtable(IntPtr domain, IntPtr klass); + + [DllImport(MonoWindows)] + public static extern int mono_method_get_token(IntPtr method); + + [DllImport(MonoWindows)] + public static extern string mono_method_get_reflection_name(IntPtr method); + + [DllImport(MonoWindows)] + public static extern IntPtr mono_method_get_class(IntPtr method); + + [DllImport(MonoWindows)] + public static extern IntPtr mono_class_get_image(IntPtr klass); + + [DllImport(MonoWindows)] + public static extern IntPtr mono_profiler_create(IntPtr profiler); + + public delegate void JitDoneCallback(IntPtr profiler, IntPtr method, IntPtr jinfo); + + [DllImport(MonoWindows)] + public static extern IntPtr mono_profiler_set_jit_done_callback(IntPtr profiler, JitDoneCallback? cb); + + public static unsafe bool CctorRan(Type t) + { + return *((byte*)mono_class_vtable(mono_domain_get(), mono_class_from_mono_type(t.TypeHandle.Value)) + 45) != 0; + } + } + + /*public static class IcedDisasm + { + public unsafe static void DisasmMethod(long codeStart) + { + DisasmMethod(codeStart, Native.mono_jit_info_get_code_size(Native.mono_jit_info_table_find(Native.DomainPtr, (IntPtr)codeStart))); + } + + public unsafe static void DisasmMethod(long codeStart, long codeSize) + { + //Log.Message($"code {Convert.ToString(*(uint*)codeStart, 16)}"); + //return; + + var codeRip = (ulong)codeStart; + var codeReader = new UnsafeCodeReader() { start = (byte*)codeStart }; + var decoder = Decoder.Create(64, codeReader); + decoder.IP = codeRip; + ulong endRip = decoder.IP + (uint)codeSize + 8; + + var instructions = new InstructionList(); + while (decoder.IP < endRip) + decoder.Decode(out instructions.AllocUninitializedElement()); + + var formatter = new NasmFormatter(); + formatter.Options.DigitSeparator = "`"; + formatter.Options.FirstOperandCharIndex = 10; + var output = new StringOutput(); + + bool cond = (*(uint*)codeStart & 0xFFFFFF) != 0xEC8348 && *(byte*)codeStart != 0x55; + + Log.Message("Code size: " + codeSize, true); + + foreach (ref var instr in instructions) + { + var stringb = new StringBuilder(); + formatter.Format(instr, output); + stringb.Append(instr.IP.ToString("X16")); + stringb.Append(" "); + int instrLen = instr.Length; + int byteBaseIndex = (int)(instr.IP - codeRip); + for (int i = 0; i < instrLen; i++) + stringb.Append(((byte*)codeStart)[byteBaseIndex + i].ToString("X2")); + int missingBytes = 10 - instrLen; + for (int i = 0; i < missingBytes; i++) + stringb.Append(" "); + stringb.Append(" "); + stringb.Append(output.ToStringAndReset()); + //if (cond) + Log.Message(stringb.ToString(), ignoreStopLoggingLimit: true); + } + } + + public unsafe class UnsafeCodeReader : CodeReader + { + public byte* start; + + public unsafe override int ReadByte() + { + var val = *start; + start++; + return val; + } + } + }*/ +} diff --git a/Source/MultiplayerLoader/MultiplayerLoader.csproj b/Source/MultiplayerLoader/MultiplayerLoader.csproj index cd008eaea..e2eb6b50d 100644 --- a/Source/MultiplayerLoader/MultiplayerLoader.csproj +++ b/Source/MultiplayerLoader/MultiplayerLoader.csproj @@ -1,38 +1,38 @@ - - - + + + net48 - net10.0 - true - 12 - false - false - MultiplayerLoader - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - - - $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)'))=$(AssemblyName)/ - - + net10.0 + true + 12 + false + false + MultiplayerLoader + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)'))=$(AssemblyName)/ + + diff --git a/Source/MultiplayerLoader/Prepatches.cs b/Source/MultiplayerLoader/Prepatches.cs index a42334c42..348d69b89 100644 --- a/Source/MultiplayerLoader/Prepatches.cs +++ b/Source/MultiplayerLoader/Prepatches.cs @@ -1,85 +1,85 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using Mono.Cecil; -using Mono.Cecil.Cil; -using MonoMod.Utils; -using Multiplayer.Common; -using Prepatcher; -using RimWorld; - -namespace MultiplayerLoader; - -public static class Prepatches -{ - [FreePatch] - static void DontSetResolution(ModuleDefinition module) - { - if (MpVersion.IsDebug) - { - var resolutionUtilityType = module.ImportReference(typeof(ResolutionUtility)).Resolve(); - resolutionUtilityType.FindMethod(nameof(ResolutionUtility.SetResolutionRaw))!.Body.Instructions.Insert( - 0, - Instruction.Create(OpCodes.Ret) - ); - } - } - +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Mono.Cecil; +using Mono.Cecil.Cil; +using MonoMod.Utils; +using Multiplayer.Common; +using Prepatcher; +using RimWorld; + +namespace MultiplayerLoader; + +public static class Prepatches +{ + [FreePatch] + static void DontSetResolution(ModuleDefinition module) + { + if (MpVersion.IsDebug) + { + var resolutionUtilityType = module.ImportReference(typeof(ResolutionUtility)).Resolve(); + resolutionUtilityType.FindMethod(nameof(ResolutionUtility.SetResolutionRaw))!.Body.Instructions.Insert( + 0, + Instruction.Create(OpCodes.Ret) + ); + } + } + [FreePatch] static void ApplySyncPrepatches(ModuleDefinition module) { #if !RIMWORLD_1_7 SyncRituals.ApplyPrepatches(module); #endif - } - - internal static void AddPrepatcherPrefix(ModuleDefinition module, int id, MethodInfo target, MethodInfo prefix) - { - var targetMethod = module.ImportReference(target).Resolve(); - var firstInst = targetMethod.Body.Instructions.First(); - - IEnumerable PrefixInstructions() - { - yield return Instruction.Create(OpCodes.Ldc_I4, id); // Load index - yield return Instruction.Create(OpCodes.Ldarg_0); // Load __instance - - // Create __args array - yield return Instruction.Create(OpCodes.Ldc_I4, target.GetParameters().Length); - yield return Instruction.Create(OpCodes.Newarr, module.TypeSystem.Object); - - int idx = 0; - foreach (var pInfo in target.GetParameters()) - { - var pType = pInfo.ParameterType; - - yield return Instruction.Create(OpCodes.Dup); - yield return Instruction.Create(OpCodes.Ldc_I4, idx); - yield return Instruction.Create(OpCodes.Ldarg, targetMethod.Parameters.ElementAt(idx)); - - if (pType.IsByRef) - throw new Exception("Byrefs aren't handled"); - if (pType.IsValueType) - yield return Instruction.Create(OpCodes.Box, module.ImportReference(pType)); - - yield return Instruction.Create(OpCodes.Stelem_Ref); - - idx++; - } - - yield return Instruction.Create(OpCodes.Call, module.ImportReference(prefix)); - yield return Instruction.Create(OpCodes.Brtrue, firstInst); - - if (target.ReturnType == typeof(bool)) - yield return Instruction.Create(OpCodes.Ldc_I4_0); - else if (target.ReturnType == typeof(void)) - yield return Instruction.Create(OpCodes.Nop); - else - throw new Exception($"Can't handle return type {target.ReturnType}"); - - yield return Instruction.Create(OpCodes.Ret); - } - - targetMethod.Body.Instructions.InsertRange(0, PrefixInstructions()); - } -} + } + + internal static void AddPrepatcherPrefix(ModuleDefinition module, int id, MethodInfo target, MethodInfo prefix) + { + var targetMethod = module.ImportReference(target).Resolve(); + var firstInst = targetMethod.Body.Instructions.First(); + + IEnumerable PrefixInstructions() + { + yield return Instruction.Create(OpCodes.Ldc_I4, id); // Load index + yield return Instruction.Create(OpCodes.Ldarg_0); // Load __instance + + // Create __args array + yield return Instruction.Create(OpCodes.Ldc_I4, target.GetParameters().Length); + yield return Instruction.Create(OpCodes.Newarr, module.TypeSystem.Object); + + int idx = 0; + foreach (var pInfo in target.GetParameters()) + { + var pType = pInfo.ParameterType; + + yield return Instruction.Create(OpCodes.Dup); + yield return Instruction.Create(OpCodes.Ldc_I4, idx); + yield return Instruction.Create(OpCodes.Ldarg, targetMethod.Parameters.ElementAt(idx)); + + if (pType.IsByRef) + throw new Exception("Byrefs aren't handled"); + if (pType.IsValueType) + yield return Instruction.Create(OpCodes.Box, module.ImportReference(pType)); + + yield return Instruction.Create(OpCodes.Stelem_Ref); + + idx++; + } + + yield return Instruction.Create(OpCodes.Call, module.ImportReference(prefix)); + yield return Instruction.Create(OpCodes.Brtrue, firstInst); + + if (target.ReturnType == typeof(bool)) + yield return Instruction.Create(OpCodes.Ldc_I4_0); + else if (target.ReturnType == typeof(void)) + yield return Instruction.Create(OpCodes.Nop); + else + throw new Exception($"Can't handle return type {target.ReturnType}"); + + yield return Instruction.Create(OpCodes.Ret); + } + + targetMethod.Body.Instructions.InsertRange(0, PrefixInstructions()); + } +} diff --git a/Source/MultiplayerLoader/SyncRituals.cs b/Source/MultiplayerLoader/SyncRituals.cs index cb845dab1..02aa5aa2a 100644 --- a/Source/MultiplayerLoader/SyncRituals.cs +++ b/Source/MultiplayerLoader/SyncRituals.cs @@ -1,17 +1,17 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using HarmonyLib; -using Mono.Cecil; -using Multiplayer.API; -using Multiplayer.Client.Util; -using RimWorld; - -namespace MultiplayerLoader; - -public static class SyncRituals -{ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using HarmonyLib; +using Mono.Cecil; +using Multiplayer.API; +using Multiplayer.Client.Util; +using RimWorld; + +namespace MultiplayerLoader; + +public static class SyncRituals +{ private static List syncMethodsWithPrepatches = new(); #if RIMWORLD_1_7 private static readonly Dictionary<(Type, string), ISyncMethodForPrepatcher> runtimeSyncMethods = new(); @@ -24,41 +24,41 @@ public static bool RuntimePrefix(object __instance, object[] __args, MethodBase || !sync.DoSync(__instance, __args); } #endif - - public static bool PrepatcherPrefix(int index, object __instance, object[] __args) - { - // This prefix is applied to a generic method and called by all possible instantiations - // This check makes sure the type is correct - if (syncMethodsWithPrepatches[index].TargetType == __instance.GetType()) - return !syncMethodsWithPrepatches[index].DoSync(__instance, __args); - return true; - } - - private static MethodInfo syncMethod = AccessTools.Method("Multiplayer.Client.Sync:Method"); - - // This method is called twice (two passes) - // The first pass with module != null is called from Prepatcher and applies prepatches - // The second pass with module == null is called from Sync initialization and registers SyncMethods - public static void ApplyPrepatches(ModuleDefinition module) - { - void Register(Type baseType, string method, Type derivedType, Action postProcess = null) - { - if (module != null) - { - Prepatches.AddPrepatcherPrefix( - module, - syncMethodsWithPrepatches.Count, - AccessTools.Method(baseType, method), - AccessTools.Method(typeof(SyncRituals), nameof(PrepatcherPrefix)) - ); - - // The ids need to be in sync between passes (note the array is recreated after Prepatcher restarts the game) - syncMethodsWithPrepatches.Add(null); - } - else - { - // Sync.Method doesn't apply a Harmony patch and doesn't require the DeclaringType to be the target type - var newSyncMethod = (ISyncMethodForPrepatcher)syncMethod.Invoke(null, [derivedType, method, null]); + + public static bool PrepatcherPrefix(int index, object __instance, object[] __args) + { + // This prefix is applied to a generic method and called by all possible instantiations + // This check makes sure the type is correct + if (syncMethodsWithPrepatches[index].TargetType == __instance.GetType()) + return !syncMethodsWithPrepatches[index].DoSync(__instance, __args); + return true; + } + + private static MethodInfo syncMethod = AccessTools.Method("Multiplayer.Client.Sync:Method"); + + // This method is called twice (two passes) + // The first pass with module != null is called from Prepatcher and applies prepatches + // The second pass with module == null is called from Sync initialization and registers SyncMethods + public static void ApplyPrepatches(ModuleDefinition module) + { + void Register(Type baseType, string method, Type derivedType, Action postProcess = null) + { + if (module != null) + { + Prepatches.AddPrepatcherPrefix( + module, + syncMethodsWithPrepatches.Count, + AccessTools.Method(baseType, method), + AccessTools.Method(typeof(SyncRituals), nameof(PrepatcherPrefix)) + ); + + // The ids need to be in sync between passes (note the array is recreated after Prepatcher restarts the game) + syncMethodsWithPrepatches.Add(null); + } + else + { + // Sync.Method doesn't apply a Harmony patch and doesn't require the DeclaringType to be the target type + var newSyncMethod = (ISyncMethodForPrepatcher)syncMethod.Invoke(null, [derivedType, method, null]); postProcess?.Invoke(newSyncMethod); syncMethodsWithPrepatches.Add(newSyncMethod); #if RIMWORLD_1_7 @@ -68,86 +68,86 @@ void Register(Type baseType, string method, Type derivedType, Action, (PawnRitualRoleSelectionWidget roleSelectionWidget, string Key)>( - RitualRoleWriter, - RitualRoleReader - ); - var psychicRitualRolesSerializer = Serializer.New, (PawnPsychicRitualRoleSelectionWidget roleSelectionWidget, string Key)>( - RitualRoleWriter, - RitualRoleReader - ); - - Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignReplace), - typeof(PawnRitualRoleSelectionWidget), - m => m.TransformArgument(1, ritualRolesSerializer)); - Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignReplace), - typeof(PawnPsychicRitualRoleSelectionWidget), - m => m.TransformArgument(1, psychicRitualRolesSerializer)); - - Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignAnyRole), - typeof(PawnRitualRoleSelectionWidget)); - Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignAnyRole), - typeof(PawnPsychicRitualRoleSelectionWidget)); - - Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssign), - typeof(PawnRitualRoleSelectionWidget), - m => m.TransformArgument(1, ritualRolesSerializer)); - Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssign), - typeof(PawnPsychicRitualRoleSelectionWidget), - m => m.TransformArgument(1, psychicRitualRolesSerializer)); - - Register( - typeof(PawnRoleSelectionWidgetBase<>), - MpMethodUtil.GetLambda( - typeof(PawnRoleSelectionWidgetBase), - nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), - lambdaOrdinal: 7).Name, - typeof(PawnRitualRoleSelectionWidget) - ); // Roles right click delegate (try assign spectate) - Register( - typeof(PawnRoleSelectionWidgetBase<>), - MpMethodUtil.GetLambda( - typeof(PawnRoleSelectionWidgetBase), - nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), - lambdaOrdinal: 7).Name, - typeof(PawnPsychicRitualRoleSelectionWidget) - ); // Roles right click delegate (try assign spectate) - - Register( - typeof(PawnRoleSelectionWidgetBase<>), - MpMethodUtil.GetLambda( - typeof(PawnRoleSelectionWidgetBase), - nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), - lambdaOrdinal: 2).Name, - typeof(PawnRitualRoleSelectionWidget) - ); // Not participating left click delegate (try assign any role or spectate) - Register( - typeof(PawnRoleSelectionWidgetBase<>), - MpMethodUtil.GetLambda( - typeof(PawnRoleSelectionWidgetBase), - nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), - lambdaOrdinal: 2).Name, - typeof(PawnPsychicRitualRoleSelectionWidget) - ); // Not participating left click delegate (try assign any role or spectate) - } - - private static (WidgetType roleSelectionWidget, string Key) RitualRoleWriter(IEnumerable roles, object target, object[] _) - where WidgetType : PawnRoleSelectionWidgetBase - where RoleType : class, ILordJobRole - { - var roleSelectionWidget = (WidgetType)target; - return (roleSelectionWidget, roleSelectionWidget.assignments.RoleGroups().FirstOrDefault(g => g.SequenceEqual(roles))?.Key); - } - - private static IEnumerable RitualRoleReader((WidgetType roleSelectionWidget, string Key) data) - where WidgetType : PawnRoleSelectionWidgetBase - where RoleType : class, ILordJobRole - { - return data.roleSelectionWidget.assignments.RoleGroups().FirstOrDefault(g => g.Key == data.Key); - } -} + } + } + + // ======= PawnRitualRoleSelectionWidget ======= + + var ritualRolesSerializer = Serializer.New, (PawnRitualRoleSelectionWidget roleSelectionWidget, string Key)>( + RitualRoleWriter, + RitualRoleReader + ); + var psychicRitualRolesSerializer = Serializer.New, (PawnPsychicRitualRoleSelectionWidget roleSelectionWidget, string Key)>( + RitualRoleWriter, + RitualRoleReader + ); + + Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignReplace), + typeof(PawnRitualRoleSelectionWidget), + m => m.TransformArgument(1, ritualRolesSerializer)); + Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignReplace), + typeof(PawnPsychicRitualRoleSelectionWidget), + m => m.TransformArgument(1, psychicRitualRolesSerializer)); + + Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignAnyRole), + typeof(PawnRitualRoleSelectionWidget)); + Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignAnyRole), + typeof(PawnPsychicRitualRoleSelectionWidget)); + + Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssign), + typeof(PawnRitualRoleSelectionWidget), + m => m.TransformArgument(1, ritualRolesSerializer)); + Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssign), + typeof(PawnPsychicRitualRoleSelectionWidget), + m => m.TransformArgument(1, psychicRitualRolesSerializer)); + + Register( + typeof(PawnRoleSelectionWidgetBase<>), + MpMethodUtil.GetLambda( + typeof(PawnRoleSelectionWidgetBase), + nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), + lambdaOrdinal: 7).Name, + typeof(PawnRitualRoleSelectionWidget) + ); // Roles right click delegate (try assign spectate) + Register( + typeof(PawnRoleSelectionWidgetBase<>), + MpMethodUtil.GetLambda( + typeof(PawnRoleSelectionWidgetBase), + nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), + lambdaOrdinal: 7).Name, + typeof(PawnPsychicRitualRoleSelectionWidget) + ); // Roles right click delegate (try assign spectate) + + Register( + typeof(PawnRoleSelectionWidgetBase<>), + MpMethodUtil.GetLambda( + typeof(PawnRoleSelectionWidgetBase), + nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), + lambdaOrdinal: 2).Name, + typeof(PawnRitualRoleSelectionWidget) + ); // Not participating left click delegate (try assign any role or spectate) + Register( + typeof(PawnRoleSelectionWidgetBase<>), + MpMethodUtil.GetLambda( + typeof(PawnRoleSelectionWidgetBase), + nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), + lambdaOrdinal: 2).Name, + typeof(PawnPsychicRitualRoleSelectionWidget) + ); // Not participating left click delegate (try assign any role or spectate) + } + + private static (WidgetType roleSelectionWidget, string Key) RitualRoleWriter(IEnumerable roles, object target, object[] _) + where WidgetType : PawnRoleSelectionWidgetBase + where RoleType : class, ILordJobRole + { + var roleSelectionWidget = (WidgetType)target; + return (roleSelectionWidget, roleSelectionWidget.assignments.RoleGroups().FirstOrDefault(g => g.SequenceEqual(roles))?.Key); + } + + private static IEnumerable RitualRoleReader((WidgetType roleSelectionWidget, string Key) data) + where WidgetType : PawnRoleSelectionWidgetBase + where RoleType : class, ILordJobRole + { + return data.roleSelectionWidget.assignments.RoleGroups().FirstOrDefault(g => g.Key == data.Key); + } +} diff --git a/Source/Tests/Tests.csproj b/Source/Tests/Tests.csproj index ecde80b82..1264869cb 100644 --- a/Source/Tests/Tests.csproj +++ b/Source/Tests/Tests.csproj @@ -1,33 +1,33 @@ - - - + + + net8.0 - net10.0 - enable - enable - 12 - - false - - - - - - - - - - - - - - - - - - - - - - - + net10.0 + enable + enable + 12 + + false + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt b/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt index 08c775816..c5901528d 100644 --- a/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt +++ b/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt @@ -1,3 +1,3 @@ -This packet is not byte-stable while serialized, meaning it can be serialized differently due to various factors, but it does deserialize into the same object +This packet is not byte-stable while serialized, meaning it can be serialized differently due to various factors, but it does deserialize into the same object 05-00-00-00-31-2E-30-2E-30-04-00-00-00-01-00-00-00-02-00-00-00-03-00-00-00-04-00-00-00-01-00-00-00-01-00-00-00-00-00-00-03-02-00-00-00-03-00-00-00-6B-65-79-01-00-00-00-7B-00-00-00-04-00-00-00-6B-65-79-32-00-00-00-00-00-00-00-00-00-26-00-00-00-63-64-60-60-10-60-60-60-28-2A-CF-2D-D1-CB-2D-CD-29-C9-2C-C8-49-AC-4C-2D-E2-66-60-60-F0-45-70-19-A0-80-19-4C-01-00 (119 bytes) diff --git a/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt b/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt index 0e11269af..17e380770 100644 --- a/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt +++ b/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt @@ -1,3 +1,3 @@ -This packet is not byte-stable while serialized, meaning it can be serialized differently due to various factors, but it does deserialize into the same object +This packet is not byte-stable while serialized, meaning it can be serialized differently due to various factors, but it does deserialize into the same object 08-00-00-00-47-61-6D-65-4E-61-6D-65-01-00-00-00-08-00-00-00-31-2E-36-2E-34-35-36-36-0D-00-00-00-30-2E-31-31-2E-30-2B-31-32-33-34-35-36-05-00-00-00-00-00-02-03-01-00-26-00-00-00-63-64-60-60-10-60-60-60-28-2A-CF-2D-D1-CB-2D-CD-29-C9-2C-C8-49-AC-4C-2D-E2-66-60-60-F0-45-70-19-A0-80-19-4C-01-00 (97 bytes) From fb025c5edce6396564ddc108e0b0d857fa1cc7c5 Mon Sep 17 00:00:00 2001 From: mkiplm-svg Date: Tue, 20 Oct 2026 00:36:16 +0000 Subject: [PATCH 03/19] Avoid CoreCLR stack trace false desyncs --- Source/Client/Desyncs/ClientSyncOpinion.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Client/Desyncs/ClientSyncOpinion.cs b/Source/Client/Desyncs/ClientSyncOpinion.cs index 0ab3482f3..cbc052ccf 100644 --- a/Source/Client/Desyncs/ClientSyncOpinion.cs +++ b/Source/Client/Desyncs/ClientSyncOpinion.cs @@ -53,7 +53,7 @@ join map2 in other.mapStates on map1.mapId equals map2.mapId if (!commandRandomStates.SequenceEqual(other.commandRandomStates)) return "Random state from commands doesn't match"; - if (!simulating && !other.simulating && desyncStackTraceHashes.Any() && other.desyncStackTraceHashes.Any() && !desyncStackTraceHashes.SequenceEqual(other.desyncStackTraceHashes)) + if (Native.IsMonoRuntime && !simulating && !other.simulating && desyncStackTraceHashes.Any() && other.desyncStackTraceHashes.Any() && !desyncStackTraceHashes.SequenceEqual(other.desyncStackTraceHashes)) return "Trace hashes don't match"; return null; From cc02dfdbb9320980da784239a44aeaa910c32c85 Mon Sep 17 00:00:00 2001 From: roman_gr Date: Mon, 26 Oct 2026 00:03:09 +0100 Subject: [PATCH 04/19] Fix RimWorld 1.7 reload and runtime state Use matching Harmony 3 and RimWorld 1.7 reference packages. Make shared-reference registration idempotent and track simulation freezes at long-event update boundaries. Explicitly rerun static initializers on CoreCLR so reloads restore debug defaults and resolver caches, preventing empty progress-bar effects from breaking jobs. Add regression coverage for repeated resets. --- Source/Client/MultiplayerGame.cs | 8 ++++++-- Source/Client/Patches/LongEvents.cs | 26 +++++++------------------ Source/Client/Saving/CrossRefs.cs | 11 ++--------- Source/Client/Saving/SharedCrossRefs.cs | 15 ++++++++++++++ Source/Common/Util/MpReflection.cs | 12 ++++++++++++ Source/Directory.Build.targets | 14 ++++++------- Source/Tests/CoreClrRuntimeTest.cs | 26 +++++++++++++++++++++++++ 7 files changed, 74 insertions(+), 38 deletions(-) diff --git a/Source/Client/MultiplayerGame.cs b/Source/Client/MultiplayerGame.cs index ae9f3431d..9c52856f5 100644 --- a/Source/Client/MultiplayerGame.cs +++ b/Source/Client/MultiplayerGame.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Reflection; @@ -16,6 +17,9 @@ namespace Multiplayer.Client { public class MultiplayerGame { + private static readonly Action resetDebugSettings = MpReflection.CreateStaticConstructorInvoker(typeof(DebugSettings)); + private static readonly Action resetSingleThingResolver = MpReflection.CreateStaticConstructorInvoker(typeof(SymbolResolver_SingleThing)); + public SyncCoordinator sync = new(); public AsyncWorldTimeComp asyncWorldTimeComp; @@ -68,13 +72,13 @@ public MultiplayerGame() if (!field.IsLiteral && field.FieldType == typeof(bool)) field.SetValue(null, default(bool)); - typeof(DebugSettings).TypeInitializer.Invoke(null, null); + resetDebugSettings(); foreach (var resolver in DefDatabase.AllDefs.SelectMany(r => r.resolvers)) if (resolver is SymbolResolver_EdgeThing edgeThing) edgeThing.randomRotations = new List() { 0, 1, 2, 3 }; - typeof(SymbolResolver_SingleThing).TypeInitializer.Invoke(null, null); + resetSingleThingResolver(); FactionCreator.ClearData(); } diff --git a/Source/Client/Patches/LongEvents.cs b/Source/Client/Patches/LongEvents.cs index 473faea57..8db92b002 100644 --- a/Source/Client/Patches/LongEvents.cs +++ b/Source/Client/Patches/LongEvents.cs @@ -37,32 +37,20 @@ public static bool IsTickMarked(Action action) [HarmonyPatch(typeof(LongEventHandler), nameof(LongEventHandler.LongEventsUpdate))] static class NewLongEvent { - public static bool currentEventWasMarked; - - static void Prefix(ref bool __state) + static void Prefix(out bool __state) { - __state = LongEventHandler.currentEvent == null; - currentEventWasMarked = MarkLongEvents.IsTickMarked(LongEventHandler.currentEvent?.eventAction); + __state = MarkLongEvents.IsTickMarked(LongEventHandler.currentEvent?.eventAction); } static void Postfix(bool __state) { - currentEventWasMarked = false; - if (Multiplayer.Client == null) return; - if (__state && MarkLongEvents.IsTickMarked(LongEventHandler.currentEvent?.eventAction)) - Multiplayer.Client.Send(ClientFreezePacket.Freeze()); - } - } - - [HarmonyPatch(typeof(LongEventHandler), nameof(LongEventHandler.ExecuteToExecuteWhenFinished))] - static class LongEventEnd - { - static void Postfix() - { - if (Multiplayer.Client != null && NewLongEvent.currentEventWasMarked) - Multiplayer.Client.Send(ClientFreezePacket.Unfreeze()); + // Observe the event's lifetime at the update boundary. Completion can + // also clear the event after an exception, without running finish callbacks. + var marked = MarkLongEvents.IsTickMarked(LongEventHandler.currentEvent?.eventAction); + if (__state != marked) + Multiplayer.Client.Send(new ClientFreezePacket(marked)); } } diff --git a/Source/Client/Saving/CrossRefs.cs b/Source/Client/Saving/CrossRefs.cs index f533a8736..9ea305160 100644 --- a/Source/Client/Saving/CrossRefs.cs +++ b/Source/Client/Saving/CrossRefs.cs @@ -216,16 +216,9 @@ public static class LoadedObjectsRegisterPatch { static bool Prefix(LoadedObjectDirectory __instance, ILoadReferenceable reffable) { - if (__instance is not SharedCrossRefs) return true; - if (reffable == null) return false; - - string key = reffable.GetUniqueLoadID(); - if (ScribeUtil.sharedCrossRefs.allObjectsByLoadID.ContainsKey(key)) return false; - - if (Scribe.mode == LoadSaveMode.ResolvingCrossRefs) - ScribeUtil.sharedCrossRefs.tempKeys.Add(key); + if (__instance is not SharedCrossRefs sharedCrossRefs) return true; - ScribeUtil.sharedCrossRefs.allObjectsByLoadID.Add(key, reffable); + sharedCrossRefs.RegisterLoaded(reffable); return false; } diff --git a/Source/Client/Saving/SharedCrossRefs.cs b/Source/Client/Saving/SharedCrossRefs.cs index 70fc93fcd..04af4689a 100644 --- a/Source/Client/Saving/SharedCrossRefs.cs +++ b/Source/Client/Saving/SharedCrossRefs.cs @@ -10,6 +10,21 @@ public class SharedCrossRefs : LoadedObjectDirectory // Used in CrossRefs patches public HashSet tempKeys = new(); + // Mod-owned calls must be idempotent even when the base method's Harmony + // prefix isn't reached. Loading and spawning can register the same object. + public new void RegisterLoaded(ILoadReferenceable reffable) + { + if (reffable == null) return; + + var key = reffable.GetUniqueLoadID(); + if (allObjectsByLoadID.ContainsKey(key)) return; + + if (Scribe.mode == LoadSaveMode.ResolvingCrossRefs) + tempKeys.Add(key); + + allObjectsByLoadID.Add(key, reffable); + } + public void Unregister(ILoadReferenceable reffable) { allObjectsByLoadID.Remove(reffable.GetUniqueLoadID()); diff --git a/Source/Common/Util/MpReflection.cs b/Source/Common/Util/MpReflection.cs index d44e25906..59c5f9158 100644 --- a/Source/Common/Util/MpReflection.cs +++ b/Source/Common/Util/MpReflection.cs @@ -22,6 +22,18 @@ public static class MpReflection private static Dictionary getters = new(); private static Dictionary setters = new(); + public static Action CreateStaticConstructorInvoker(Type type) + { + var constructor = type.TypeInitializer ?? throw new ArgumentException("Type has no static constructor", nameof(type)); + // ConstructorInfo.Invoke only ensures initialization on CoreCLR. An + // explicit call is needed to execute the initializer again on reload. + var method = new DynamicMethod("MP_Reinitialize_" + type.Name, typeof(void), Type.EmptyTypes, type, true); + var il = method.GetILGenerator(); + il.Emit(OpCodes.Call, constructor); + il.Emit(OpCodes.Ret); + return (Action)method.CreateDelegate(typeof(Action)); + } + /// /// Get the value of a static property/field in type specified by memberPath /// diff --git a/Source/Directory.Build.targets b/Source/Directory.Build.targets index 91306404b..638330daf 100644 --- a/Source/Directory.Build.targets +++ b/Source/Directory.Build.targets @@ -1,4 +1,8 @@ + + + + net10.0 @@ -8,17 +12,11 @@ - - - - false - + + - - - diff --git a/Source/Tests/CoreClrRuntimeTest.cs b/Source/Tests/CoreClrRuntimeTest.cs index d790e573f..bb0785f73 100644 --- a/Source/Tests/CoreClrRuntimeTest.cs +++ b/Source/Tests/CoreClrRuntimeTest.cs @@ -4,6 +4,32 @@ namespace Tests; public class CoreClrRuntimeTest { + [Test] + public void StaticConstructorInvokerRestoresDefaultsOnEveryReload() + { + var reset = MpReflection.CreateStaticConstructorInvoker(typeof(StaticDefaults)); + var originalValues = StaticDefaults.Values; + + for (var reload = 0; reload < 2; reload++) + { + StaticDefaults.EffectsEnabled = false; + StaticDefaults.Values.Clear(); + + reset(); + + Assert.That(StaticDefaults.EffectsEnabled, Is.True); + Assert.That(StaticDefaults.Values, Is.EqualTo(new[] { 1, 2, 3 })); + Assert.That(StaticDefaults.Values, Is.Not.SameAs(originalValues)); + originalValues = StaticDefaults.Values; + } + } + + private static class StaticDefaults + { + public static bool EffectsEnabled = true; + public static readonly List Values = new() { 1, 2, 3 }; + } + [Test] public void MonoInitializationIsSkippedOnCoreClr() { From 2330d3847b45d47a01936a706ae76ae6956e957e Mon Sep 17 00:00:00 2001 From: roman_gr Date: Mon, 26 Oct 2026 00:39:18 +0100 Subject: [PATCH 05/19] Remove redundant RimWorld 1.7 build overrides --- Source/Directory.Build.targets | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/Source/Directory.Build.targets b/Source/Directory.Build.targets index 638330daf..d2f018c5e 100644 --- a/Source/Directory.Build.targets +++ b/Source/Directory.Build.targets @@ -5,20 +5,13 @@ - net10.0 $(DefineConstants);RIMWORLD_1_7 + true - - - - + - - - - From 1fc7441eac6e9a7d8224f439c42da8ef3cffc8c7 Mon Sep 17 00:00:00 2001 From: roman_gr Date: Mon, 26 Oct 2026 14:28:20 +0100 Subject: [PATCH 06/19] Fix RimWorld 1.7 compatibility hashes and solution build --- Source/Client/MultiplayerData.cs | 11 ++++++++++- Source/Multiplayer.sln | 6 ------ Source/Server/Server.csproj | 3 ++- Source/TestsOnMono/README.md | 10 ++++++++++ 4 files changed, 22 insertions(+), 8 deletions(-) create mode 100644 Source/TestsOnMono/README.md diff --git a/Source/Client/MultiplayerData.cs b/Source/Client/MultiplayerData.cs index 0212296c9..e4e604857 100644 --- a/Source/Client/MultiplayerData.cs +++ b/Source/Client/MultiplayerData.cs @@ -135,7 +135,7 @@ internal static void CollectDefInfos() dict["ISyncSimple"] = GetDefInfo(ApiSerialization.syncSimples, TypeHash); dict["ISession"] = GetDefInfo(ApiSerialization.sessions, TypeHash); - dict["PawnBio"] = GetDefInfo(SolidBioDatabase.allBios, b => b.name.GetHashCode()); + dict["PawnBio"] = GetDefInfo(SolidBioDatabase.allBios, b => GetPawnBioHash(b.name)); foreach (var defType in GenTypes.AllLeafSubclasses(typeof(Def))) { @@ -149,6 +149,15 @@ internal static void CollectDefInfos() localDefInfos = dict; } + private static int GetPawnBioHash(NameTriple name) + { + // NameTriple.GetHashCode uses process-randomized string hashes on CoreCLR. + var hash = Gen.HashCombineInt(0, GenText.StableStringHash(name.First)); + hash = Gen.HashCombineInt(hash, GenText.StableStringHash(name.Last)); + // An unset Nick is chosen from First/Last using those same unstable hashes. + return Gen.HashCombineInt(hash, GenText.StableStringHash(name.NickSet ? name.Nick : null)); + } + internal static DefInfo GetDefInfo(IEnumerable types, Func hash) { return new DefInfo() diff --git a/Source/Multiplayer.sln b/Source/Multiplayer.sln index 8c8f90207..5d94f4a03 100644 --- a/Source/Multiplayer.sln +++ b/Source/Multiplayer.sln @@ -13,8 +13,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MultiplayerLoader", "MultiplayerLoader\MultiplayerLoader.csproj", "{D9F53E9B-A72C-4183-B549-63B649320B61}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestsOnMono", "TestsOnMono\TestsOnMono.csproj", "{B9258593-604D-4862-96EF-192B2BC6250E}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SourceGen", "SourceGen\SourceGen.csproj", "{61D773A9-7B5D-443D-893E-F5C7A56272CA}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatCommandContracts", "ChatCommandContracts\ChatCommandContracts.csproj", "{64FFAC22-1A74-492B-965E-7AA953F263C3}" @@ -49,10 +47,6 @@ Global {D9F53E9B-A72C-4183-B549-63B649320B61}.Debug|Any CPU.Build.0 = Debug|Any CPU {D9F53E9B-A72C-4183-B549-63B649320B61}.Release|Any CPU.ActiveCfg = Release|Any CPU {D9F53E9B-A72C-4183-B549-63B649320B61}.Release|Any CPU.Build.0 = Release|Any CPU - {B9258593-604D-4862-96EF-192B2BC6250E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B9258593-604D-4862-96EF-192B2BC6250E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B9258593-604D-4862-96EF-192B2BC6250E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B9258593-604D-4862-96EF-192B2BC6250E}.Release|Any CPU.Build.0 = Release|Any CPU {61D773A9-7B5D-443D-893E-F5C7A56272CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {61D773A9-7B5D-443D-893E-F5C7A56272CA}.Debug|Any CPU.Build.0 = Debug|Any CPU {61D773A9-7B5D-443D-893E-F5C7A56272CA}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/Source/Server/Server.csproj b/Source/Server/Server.csproj index 322a8f5fe..2589bc05b 100644 --- a/Source/Server/Server.csproj +++ b/Source/Server/Server.csproj @@ -2,7 +2,8 @@ Exe - net8.0 + net8.0 + net10.0 enable enable 12 diff --git a/Source/TestsOnMono/README.md b/Source/TestsOnMono/README.md new file mode 100644 index 000000000..8261e0098 --- /dev/null +++ b/Source/TestsOnMono/README.md @@ -0,0 +1,10 @@ +This is a manual test executable for Windows Mono, separate from the main solution. +It exercises Mono runtime internals and does not run on RimWorld 1.7's CoreCLR runtime. + +Build it explicitly using the RimWorld 1.6 configuration: + +```sh +dotnet build Source/TestsOnMono/TestsOnMono.csproj -c Release -p:RimWorld17=false +``` + +Run the resulting `TestsOnMono.exe` under Windows Mono. From b8f12680b05456892dc6e9afb36f0a0de6569b87 Mon Sep 17 00:00:00 2001 From: roman_gr Date: Mon, 26 Oct 2026 15:37:07 +0100 Subject: [PATCH 07/19] Restore pawn bio hashing with Waylith compatibility comment --- Source/Client/MultiplayerData.cs | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/Source/Client/MultiplayerData.cs b/Source/Client/MultiplayerData.cs index e4e604857..cbafe06f5 100644 --- a/Source/Client/MultiplayerData.cs +++ b/Source/Client/MultiplayerData.cs @@ -135,7 +135,9 @@ internal static void CollectDefInfos() dict["ISyncSimple"] = GetDefInfo(ApiSerialization.syncSimples, TypeHash); dict["ISession"] = GetDefInfo(ApiSerialization.sessions, TypeHash); - dict["PawnBio"] = GetDefInfo(SolidBioDatabase.allBios, b => GetPawnBioHash(b.name)); + // RimWorld 1.7 enables WaylithClr.MonoStringHashCode, keeping NameTriple's + // string-based hashes compatible with Mono and stable across processes. + dict["PawnBio"] = GetDefInfo(SolidBioDatabase.allBios, b => b.name.GetHashCode()); foreach (var defType in GenTypes.AllLeafSubclasses(typeof(Def))) { @@ -149,15 +151,6 @@ internal static void CollectDefInfos() localDefInfos = dict; } - private static int GetPawnBioHash(NameTriple name) - { - // NameTriple.GetHashCode uses process-randomized string hashes on CoreCLR. - var hash = Gen.HashCombineInt(0, GenText.StableStringHash(name.First)); - hash = Gen.HashCombineInt(hash, GenText.StableStringHash(name.Last)); - // An unset Nick is chosen from First/Last using those same unstable hashes. - return Gen.HashCombineInt(hash, GenText.StableStringHash(name.NickSet ? name.Nick : null)); - } - internal static DefInfo GetDefInfo(IEnumerable types, Func hash) { return new DefInfo() From b014fbf4d8aae8f45d99fa97f77d1e29dfe9b848 Mon Sep 17 00:00:00 2001 From: roman_gr Date: Mon, 26 Oct 2026 22:04:44 +0100 Subject: [PATCH 08/19] Fix collection hashing and multiplayer reload state --- Source/Client/AsyncTime/AsyncTimeComp.cs | 10 +- Source/Client/AsyncTime/AsyncWorldTimeComp.cs | 10 +- Source/Client/Networking/HostUtil.cs | 8 + .../Networking/State/ClientLoadingState.cs | 10 +- Source/Client/Patches/HashCodes.cs | 153 ++---------------- Source/Client/Patches/VTRSyncPatch.cs | 77 ++++----- Source/Client/Patches/ViewedMapTracker.cs | 18 +++ .../Client/Syncing/Game/ThingFilterMarkers.cs | 40 ++--- Source/Client/Util/DeterministicHash.cs | 22 +++ Source/Client/Util/HashCodeTranspiler.cs | 29 ++++ Source/Client/Util/MpPatch.cs | 14 +- Source/Tests/HashCodeTranspilerTest.cs | 74 +++++++++ Source/Tests/Tests.csproj | 3 + Source/Tests/ViewedMapTrackerTest.cs | 58 +++++++ 14 files changed, 313 insertions(+), 213 deletions(-) create mode 100644 Source/Client/Patches/ViewedMapTracker.cs create mode 100644 Source/Client/Util/HashCodeTranspiler.cs create mode 100644 Source/Tests/HashCodeTranspilerTest.cs create mode 100644 Source/Tests/ViewedMapTrackerTest.cs diff --git a/Source/Client/AsyncTime/AsyncTimeComp.cs b/Source/Client/AsyncTime/AsyncTimeComp.cs index 32233940b..610daf613 100644 --- a/Source/Client/AsyncTime/AsyncTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncTimeComp.cs @@ -95,7 +95,8 @@ public int GameStartAbsTick public Queue cmds = new(); - public int CurrentPlayerCount { get; private set; } + private int currentPlayerCount; + public int CurrentPlayerCount => currentPlayerCount; public int VTR => CurrentPlayerCount > 0 ? VTRSync.MinimumVtr : VTRSync.MaximumVtr; public AsyncTimeComp(Map map, int gameStartAbsTick = 0) @@ -205,6 +206,7 @@ public void PostContext() public void ExposeData() { + Scribe_Values.Look(ref currentPlayerCount, "currentPlayerCount"); Scribe_Values.Look(ref mapTicks, "mapTicks"); Scribe_Values.Look(ref timeSpeedInt, "timeSpeed"); @@ -213,10 +215,12 @@ public void ExposeData() Scribe_Custom.LookULong(ref randState, "randState", 1); } - public int IncreasePlayerCount() => CurrentPlayerCount += 1; + public void ResetPlayerCount() => currentPlayerCount = 0; + + public int IncreasePlayerCount() => currentPlayerCount += 1; // This should never go below 0, this is just defensive programming. Hopefully not needed anymore, but // nevertheless still left. - public int DecreasePlayerCount() => CurrentPlayerCount = Math.Max(0, CurrentPlayerCount - 1); + public int DecreasePlayerCount() => currentPlayerCount = Math.Max(0, currentPlayerCount - 1); public void FinalizeInit() { diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 8184163ab..b3465419d 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -58,7 +58,8 @@ public TimeSpeed DesiredTimeSpeed public Queue Cmds => cmds; public Queue cmds = new(); - public int CurrentPlayerCount { get; private set; } + private int currentPlayerCount; + public int CurrentPlayerCount => currentPlayerCount; public int VTR => CurrentPlayerCount > 0 ? VTRSync.MinimumVtr : VTRSync.MaximumVtr; public int TickableId => -1; @@ -79,6 +80,7 @@ public AsyncWorldTimeComp(World world) public void ExposeData() { + Scribe_Values.Look(ref currentPlayerCount, "currentPlayerCount"); var timer = TickPatch.Timer; Scribe_Values.Look(ref timer, "timer"); TickPatch.SetTimer(timer); @@ -100,6 +102,8 @@ public void ExposeData() worldTicks = Find.TickManager.TicksGame; } + public void ResetPlayerCount() => currentPlayerCount = 0; + public void Tick() { tickingWorld = true; @@ -239,13 +243,13 @@ public void ExecuteCmd(ScheduledCommand cmd) if (previousMapId >= 0) prev = Find.Maps.FirstOrDefault(x => x.uniqueID == previousMapId)?.AsyncTime()?.DecreasePlayerCount() ?? -1; else if (previousMapId == VTRSync.WorldMapId) - prev = Multiplayer.AsyncWorldTime.CurrentPlayerCount -= 1; + prev = currentPlayerCount = Math.Max(0, currentPlayerCount - 1); var curr = -1; if (newMapId >= 0) curr = Find.Maps.FirstOrDefault(x => x.uniqueID == newMapId)?.AsyncTime()?.IncreasePlayerCount() ?? -1; else if (newMapId == VTRSync.WorldMapId) - curr = Multiplayer.AsyncWorldTime.CurrentPlayerCount += 1; + curr = currentPlayerCount += 1; MpLog.Debug($"[{worldTicks}|{Multiplayer.session.remoteTickUntil}] Player count change: previousMapId={previousMapId} ({prev}), newMapId={newMapId} ({curr}), mapCount={mapCount}"); } diff --git a/Source/Client/Networking/HostUtil.cs b/Source/Client/Networking/HostUtil.cs index 0b736217e..d63962f7a 100644 --- a/Source/Client/Networking/HostUtil.cs +++ b/Source/Client/Networking/HostUtil.cs @@ -7,6 +7,7 @@ using Multiplayer.Client.AsyncTime; using Multiplayer.Client.Comp; using Multiplayer.Client.Networking; +using Multiplayer.Client.Patches; using Multiplayer.Client.Util; using Multiplayer.Common; using RimWorld; @@ -26,6 +27,7 @@ public static async ClientTask HostServer(ServerSettings settings, bool fromRepl { Log.Message("Starting the server"); + VTRSync.Reset(); CreateSession(settings); if (!fromReplay) @@ -38,9 +40,15 @@ public static async ClientTask HostServer(ServerSettings settings, bool fromRepl PrepareGame(); SetGameState(settings); + // A new server has no viewers yet, including when hosting a replay. + Multiplayer.AsyncWorldTime.ResetPlayerCount(); + foreach (var asyncTime in Multiplayer.game.asyncTimeComps) + asyncTime.ResetPlayerCount(); + Multiplayer.session.dataSnapshot = await CreateGameData(); MakeHostOnServer(serverConn); + VTRSync.Start(); // todo handle sending cmds for hosting from loaded replay? SaveLoad.SendGameData(Multiplayer.session.dataSnapshot, false); diff --git a/Source/Client/Networking/State/ClientLoadingState.cs b/Source/Client/Networking/State/ClientLoadingState.cs index 2622c9611..7bda54ead 100644 --- a/Source/Client/Networking/State/ClientLoadingState.cs +++ b/Source/Client/Networking/State/ClientLoadingState.cs @@ -3,6 +3,7 @@ using System.Linq; using Ionic.Zlib; using Multiplayer.Client.Saving; +using Multiplayer.Client.Patches; using Multiplayer.Common; using Verse; @@ -63,6 +64,9 @@ public void HandleWorldDataFragment(FragmentedPacket packet) [PacketHandler(Packets.Server_WorldData, allowFragmented: true)] public void HandleWorldData(ByteReader data) { + // Preserve the last reported map on rejoin, but don't report temporary + // loading views or views encountered while replaying catch-up commands. + VTRSync.Suspend(); var downloadMs = downloadTimeStopwatch.ElapsedMilliseconds; downloadTimeStopwatch.Reset(); Log.Message($"Game data size: {data.Length}. Took {downloadMs}ms to receive."); @@ -129,7 +133,11 @@ public void HandleWorldData(ByteReader data) TickPatch.SetSimulation( toTickUntil: true, - onFinish: () => Multiplayer.Client.Send(Packets.Client_WorldReady), + onFinish: () => + { + Multiplayer.Client.Send(Packets.Client_WorldReady); + VTRSync.Start(); + }, cancelButtonKey: "Quit", onCancel: GenScene.GoToMainMenu // Calls StopMultiplayer through a patch ); diff --git a/Source/Client/Patches/HashCodes.cs b/Source/Client/Patches/HashCodes.cs index 497fd75f8..638cd7cba 100644 --- a/Source/Client/Patches/HashCodes.cs +++ b/Source/Client/Patches/HashCodes.cs @@ -2,9 +2,7 @@ using Multiplayer.Client.Util; using RimWorld; using RimWorld.Planet; -using System; using System.Collections.Generic; -using System.Linq; using System.Reflection; using System.Reflection.Emit; using System.Runtime.CompilerServices; @@ -94,149 +92,22 @@ static void Postfix(Tradeable __instance, ref int __result) } } - // Have created a patch that will handle 2-8 System.HashCode.Combine functions. - // TODO: Check the following: - // TileQueryParams.GetHashCode - // UnmanagedGridTraverseParams.GetHashCode - // MapGridRequest.GetHashCode - // SimplifiedPastureNutritionSimulator.GetHashCode - // FieldAliasCache.GetHashCode - static class HashCodeDetours - { - private static readonly Type HashCodeType = typeof(string).Assembly.GetType("System.HashCode"); - - internal static MethodBase GetCombineIntMethod(int numOfInts) - { - Type[] intTypes = Enumerable.Repeat(typeof(int), numOfInts).ToArray(); - - // Look for a pre-compiled int,int,… overload (only exists on some runtimes) - MethodInfo method = HashCodeType.GetMethod("Combine", BindingFlags.Public | BindingFlags.Static, binder: null, types: intTypes, modifiers: null); - if (method != null) - return method; // good – no generics involved - - // Fall back to the generic definition and close it for - MethodInfo genericMethod = HashCodeType.GetMethods(BindingFlags.Public | BindingFlags.Static) - .First(x => - x.Name == "Combine" && - x.IsGenericMethodDefinition && - x.GetGenericArguments().Length == numOfInts); - - return genericMethod.MakeGenericMethod(intTypes); - } - } - - // ─── PATCH 2 ints ───────────────────────────────────────── - [HarmonyPatch] - static class Combine2Patch - { - static MethodBase TargetMethod() => HashCodeDetours.GetCombineIntMethod(2); - - static bool Prefix(int value1, int value2, ref int __result) - { - if (Multiplayer.Client == null) - return true; - - __result = Gen.HashCombineInt(value1, value2); - return false; - } - } - - // ─── PATCH 3 ints ───────────────────────────────────────── - [HarmonyPatch] - static class Combine3Patch - { - static MethodBase TargetMethod() => HashCodeDetours.GetCombineIntMethod(3); - - static bool Prefix(int value1, int value2, int value3, ref int __result) - { - if (Multiplayer.Client == null) - return true; - - __result = DeterministicHash.HashCombineInt(value1, value2, value3); - return false; - } - } - - // ─── PATCH 4 ints ───────────────────────────────────────── - [HarmonyPatch] - static class Combine4Patch - { - static MethodBase TargetMethod() => HashCodeDetours.GetCombineIntMethod(4); - - static bool Prefix(int value1, int value2, int value3, int value4, ref int __result) - { - if (Multiplayer.Client == null) - return true; - - __result = Gen.HashCombineInt(value1, value2, value3, value4); - return false; - } - } - - // ─── PATCH 5 ints ───────────────────────────────────────── + // Patch game callers rather than System.HashCode: ValueTuple and Dictionary + // also use Combine, and changing it when a session starts invalidates their keys. [HarmonyPatch] - static class Combine5Patch + static class DeterministicGameHashCalls { - static MethodBase TargetMethod() => HashCodeDetours.GetCombineIntMethod(5); - - static bool Prefix(int value1, int value2, int value3, int value4, int value5, ref int __result) - { - if (Multiplayer.Client == null) - return true; - - __result = DeterministicHash.HashCombineInt(value1, value2, value3, value4, value5); - return false; - } - } - - // ─── PATCH 6 ints ───────────────────────────────────────── - [HarmonyPatch] - static class Combine6Patch - { - static MethodBase TargetMethod() => HashCodeDetours.GetCombineIntMethod(6); - - static bool Prefix(int value1, int value2, int value3, int value4, int value5, int value6, ref int __result) - { - if (Multiplayer.Client == null) - return true; - - __result = DeterministicHash.HashCombineInt(value1, value2, value3, - value4, value5, value6); - return false; - } - } - - // ─── PATCH 7 ints ───────────────────────────────────────── - [HarmonyPatch] - static class Combine7Patch - { - static MethodBase TargetMethod() => HashCodeDetours.GetCombineIntMethod(7); - - static bool Prefix(int value1, int value2, int value3, int value4, int value5, int value6, int value7, ref int __result) + static IEnumerable TargetMethods() { - if (Multiplayer.Client == null) - return true; - - __result = DeterministicHash.HashCombineInt(value1, value2, value3, value4, - value5, value6, value7); - return false; + yield return AccessTools.PropertyGetter(typeof(Map), nameof(Map.NextGenSeed)); + yield return AccessTools.PropertyGetter(typeof(Gravship), nameof(Gravship.ConstantRandSeed)); + yield return AccessTools.Method(typeof(LayoutWorker), "FillAllRooms"); + yield return AccessTools.Method(typeof(Building_Trap), nameof(Building_Trap.KnowsOfTrap)); + yield return AccessTools.Method(typeof(PathFinderCostTuning), nameof(PathFinderCostTuning.GetHashCode)); + yield return AccessTools.Method(typeof(FastTileFinder.TileQueryParams), nameof(FastTileFinder.TileQueryParams.GetHashCode)); } - } - - // ─── PATCH 8 ints ───────────────────────────────────────── - [HarmonyPatch] - static class Combine8Patch - { - static MethodBase TargetMethod() => HashCodeDetours.GetCombineIntMethod(8); - static bool Prefix(int value1, int value2, int value3, int value4, int value5, int value6, int value7, int value8, ref int __result) - { - if (Multiplayer.Client == null) - return true; - - __result = DeterministicHash.HashCombineInt(value1, value2, value3, value4, - value5, value6, value7, value8); - return false; - } + static IEnumerable Transpiler(IEnumerable instructions) => + HashCodeTranspiler.ReplaceCalls(instructions); } } diff --git a/Source/Client/Patches/VTRSyncPatch.cs b/Source/Client/Patches/VTRSyncPatch.cs index eb5812a86..734a33be7 100644 --- a/Source/Client/Patches/VTRSyncPatch.cs +++ b/Source/Client/Patches/VTRSyncPatch.cs @@ -53,8 +53,9 @@ static class VTRSync // Special identifier for the world map (since it doesn't have a uniqueID like regular maps) public const int WorldMapId = -2; public const int InvalidMapId = -1; - public static int lastMovedToMapId = InvalidMapId; - public static int lastSentAtTick = -1; + private static readonly ViewedMapTracker viewedMap = new(); + public static int lastMovedToMapId => viewedMap.LastReportedMapId; + public static bool Enabled { get; private set; } // Vtr rates public const int MaximumVtr = 15; @@ -62,25 +63,32 @@ static class VTRSync public static int GetSynchronizedUpdateRate(Thing thing) => thing?.MapHeld?.AsyncTime()?.VTR ?? MaximumVtr; - public static void SendViewedMapUpdate(int previous, int current) + public static void Start() { - if (Multiplayer.reloading) - return; - - string warn = string.Empty; - if (previous != lastMovedToMapId) - warn = $" mismatch between expected previous map {previous} and last moved to map {lastMovedToMapId}"; - else if (previous == current) return; - int currentTick = Find.TickManager?.TicksGame ?? 0; - MpLog.Debug($"VTR MapSwitchPatch: {lastMovedToMapId}->{current} @ tick {currentTick}{warn}"); - Multiplayer.Client.SendCommand(CommandType.PlayerCount, ScheduledCommand.Global, ByteWriter.GetBytes(previous, current)); - lastMovedToMapId = current; + Enabled = true; + SendViewedMapUpdate(WorldRendererUtility.WorldSelected ? WorldMapId : Find.CurrentMap?.uniqueID ?? InvalidMapId); + } + + public static void Suspend() => Enabled = false; + + public static void SendViewedMapUpdate(int current) + { + if (!Enabled || Multiplayer.reloading || Multiplayer.Client is not { State: ConnectionStateEnum.ClientPlaying }) return; + + // Reloading can change the local view without reporting it. Use the + // last sent map as the previous map so the server sees a valid chain. + viewedMap.Report(current, (previous, next) => + { + MpLog.Debug($"VTR MapSwitchPatch: {previous}->{next} @ tick {Find.TickManager?.TicksGame ?? 0}"); + Multiplayer.Client.SendCommand(CommandType.PlayerCount, ScheduledCommand.Global, ByteWriter.GetBytes(previous, next)); + }); } public static void Reset() { - lastMovedToMapId = InvalidMapId; - lastSentAtTick = -1; + Enabled = false; + viewedMap.Reset(); + WorldRenderModePatch.Reset(); } } @@ -89,39 +97,20 @@ static class MapSwitchPatch { static void Prefix(Map value) { - if (Multiplayer.Client == null) return; + if (Multiplayer.Client == null || !VTRSync.Enabled) return; try { // WorldRenderModePatch will handle it if (VTRSync.lastMovedToMapId == VTRSync.WorldMapId) return; - int previousMap = GetPreviousMapId(); int newMap = value?.uniqueID ?? VTRSync.InvalidMapId; - int currentTick = Find.TickManager?.TicksGame ?? 0; - - if (previousMap == newMap) return; - if (VTRSync.lastMovedToMapId == newMap && currentTick == VTRSync.lastSentAtTick) return; - - VTRSync.SendViewedMapUpdate(previousMap, newMap); - VTRSync.lastSentAtTick = currentTick; + VTRSync.SendViewedMapUpdate(newMap); } catch (Exception ex) { MpLog.Error($"VTR MapSwitchPatch error: {ex}"); } } - - private static int GetPreviousMapId() - { - bool currentMapIsRemovedAndWasLatestMap = Current.Game.currentMapIndex >= Find.Maps.Count; - - if (currentMapIsRemovedAndWasLatestMap) - { - return VTRSync.InvalidMapId; - } - - return Find.CurrentMap?.uniqueID ?? VTRSync.InvalidMapId; - } } [HarmonyPatch(typeof(WorldRendererUtility), nameof(WorldRendererUtility.CurrentWorldRenderMode), MethodType.Getter)] @@ -129,23 +118,23 @@ static class WorldRenderModePatch { private static WorldRenderMode lastRenderMode = WorldRenderMode.None; + public static void Reset() => lastRenderMode = WorldRenderMode.None; + static void Postfix(WorldRenderMode __result) { - if (Multiplayer.Client == null) return; + if (Multiplayer.Client == null || !VTRSync.Enabled || Multiplayer.reloading) return; try { // Detect transition to world map (Planet mode) if (__result == WorldRenderMode.Planet && lastRenderMode != WorldRenderMode.Planet) { - if (VTRSync.lastMovedToMapId != VTRSync.InvalidMapId && VTRSync.lastMovedToMapId != VTRSync.WorldMapId) - { - VTRSync.SendViewedMapUpdate(VTRSync.lastMovedToMapId, VTRSync.WorldMapId); - } + var leavingMap = VTRSync.lastMovedToMapId >= 0; + VTRSync.SendViewedMapUpdate(VTRSync.WorldMapId); // On standalone with streaming, trigger a join point when leaving a map // so each player can save independently without disturbing others - if (Multiplayer.session?.ConnectedToStandaloneServer == true && Multiplayer.GameComp.multifaction && Multiplayer.GameComp.asyncTime) + if (leavingMap && Multiplayer.session?.ConnectedToStandaloneServer == true && Multiplayer.GameComp.multifaction && Multiplayer.GameComp.asyncTime) Autosaving.SendAutosavingRequest(JoinPointRequestReason.WorldTravel); } // Detect transition back to tile map @@ -154,7 +143,7 @@ static void Postfix(WorldRenderMode __result) var newMap = Find.CurrentMap?.uniqueID ?? VTRSync.InvalidMapId; if (newMap != VTRSync.InvalidMapId && VTRSync.lastMovedToMapId == VTRSync.WorldMapId) { - VTRSync.SendViewedMapUpdate(VTRSync.WorldMapId, newMap); + VTRSync.SendViewedMapUpdate(newMap); } } diff --git a/Source/Client/Patches/ViewedMapTracker.cs b/Source/Client/Patches/ViewedMapTracker.cs new file mode 100644 index 000000000..092a5e63f --- /dev/null +++ b/Source/Client/Patches/ViewedMapTracker.cs @@ -0,0 +1,18 @@ +using System; + +namespace Multiplayer.Client.Patches; + +internal sealed class ViewedMapTracker +{ + public int LastReportedMapId { get; private set; } = -1; + + public void Report(int currentMapId, Action send) + { + if (currentMapId == LastReportedMapId) return; + + send(LastReportedMapId, currentMapId); + LastReportedMapId = currentMapId; + } + + public void Reset() => LastReportedMapId = -1; +} diff --git a/Source/Client/Syncing/Game/ThingFilterMarkers.cs b/Source/Client/Syncing/Game/ThingFilterMarkers.cs index 98651e066..8f9599a0c 100644 --- a/Source/Client/Syncing/Game/ThingFilterMarkers.cs +++ b/Source/Client/Syncing/Game/ThingFilterMarkers.cs @@ -37,56 +37,56 @@ static void TabStorageFillTab_Prefix(ITab_Storage __instance) DrawnThingFilter = new TabStorageWrapper(selParent); } - [MpPostfix(typeof(ITab_Storage), nameof(ITab_Storage.FillTab))] - static void TabStorageFillTab_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(ITab_Storage), nameof(ITab_Storage.FillTab))] + static void TabStorageFillTab_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(Dialog_BillConfig), nameof(Dialog_BillConfig.DoWindowContents))] static void BillConfig_Prefix(Dialog_BillConfig __instance) => DrawnThingFilter = new BillConfigWrapper(__instance.bill); - [MpPostfix(typeof(Dialog_BillConfig), nameof(Dialog_BillConfig.DoWindowContents))] - static void BillConfig_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(Dialog_BillConfig), nameof(Dialog_BillConfig.DoWindowContents))] + static void BillConfig_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(Dialog_ManageApparelPolicies), nameof(Dialog_ManageApparelPolicies.DoContentsRect))] static void ManageOutfit_Prefix(Dialog_ManageApparelPolicies __instance) => DrawnThingFilter = new OutfitWrapper(__instance.SelectedPolicy); - [MpPostfix(typeof(Dialog_ManageApparelPolicies), nameof(Dialog_ManageApparelPolicies.DoContentsRect))] - static void ManageOutfit_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(Dialog_ManageApparelPolicies), nameof(Dialog_ManageApparelPolicies.DoContentsRect))] + static void ManageOutfit_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(Dialog_ManageFoodPolicies), nameof(Dialog_ManageFoodPolicies.DoContentsRect))] static void ManageFoodRestriction_Prefix(Dialog_ManageFoodPolicies __instance) => DrawnThingFilter = new FoodRestrictionWrapper(__instance.SelectedPolicy); - [MpPostfix(typeof(Dialog_ManageFoodPolicies), nameof(Dialog_ManageFoodPolicies.DoContentsRect))] - static void ManageFoodRestriction_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(Dialog_ManageFoodPolicies), nameof(Dialog_ManageFoodPolicies.DoContentsRect))] + static void ManageFoodRestriction_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(ITab_PenAutoCut), nameof(ITab_PenAutoCut.FillTab))] static void TabPenAutocutFillTab_Prefix(ITab_PenAutoCut __instance) => DrawnThingFilter = new PenAutocutWrapper(__instance.SelectedCompAnimalPenMarker); - [MpPostfix(typeof(ITab_PenAutoCut), nameof(ITab_PenAutoCut.FillTab))] - static void TabPenAutocutFillTab_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(ITab_PenAutoCut), nameof(ITab_PenAutoCut.FillTab))] + static void TabPenAutocutFillTab_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(ITab_PenAnimals), nameof(ITab_PenAnimals.FillTab))] static void TabPenAnimalsFillTab_Prefix(ITab_PenAnimals __instance) => DrawnThingFilter = new PenAnimalsWrapper(__instance.SelectedCompAnimalPenMarker); - [MpPostfix(typeof(ITab_PenAnimals), nameof(ITab_PenAnimals.FillTab))] - static void TabPenAnimalsFillTab_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(ITab_PenAnimals), nameof(ITab_PenAnimals.FillTab))] + static void TabPenAnimalsFillTab_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(ITab_WindTurbineAutoCut), nameof(ITab_WindTurbineAutoCut.FillTab))] static void TabWindTurbineAutocutFillTab_Prefix(ITab_WindTurbineAutoCut __instance) => DrawnThingFilter = new DefaultAutocutWrapper(__instance.AutoCut); - [MpPostfix(typeof(ITab_WindTurbineAutoCut), nameof(ITab_WindTurbineAutoCut.FillTab))] - static void TabWindTurbineAutocutFillTab_Postfix(ITab_WindTurbineAutoCut __instance) => DrawnThingFilter = null; + [MpFinalizer(typeof(ITab_WindTurbineAutoCut), nameof(ITab_WindTurbineAutoCut.FillTab))] + static void TabWindTurbineAutocutFillTab_Finalizer(ITab_WindTurbineAutoCut __instance) => DrawnThingFilter = null; [MpPrefix(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] static void ThingFilterUI_Prefix() => drawingThingFilter = true; - [MpPostfix(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] - static void ThingFilterUI_Postfix() => drawingThingFilter = false; + [MpFinalizer(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] + static void ThingFilterUI_Finalizer() => drawingThingFilter = false; // Reading policies need special handling as they draw two ThingFilters private static ReadingPolicy drawnReadingPolicy; @@ -94,8 +94,8 @@ static void TabWindTurbineAutocutFillTab_Prefix(ITab_WindTurbineAutoCut __instan [MpPrefix(typeof(Dialog_ManageReadingPolicies), nameof(Dialog_ManageReadingPolicies.DoContentsRect))] static void Dialog_ManageReadingPolicies_Prefix(Dialog_ManageReadingPolicies __instance) => drawnReadingPolicy = __instance.SelectedPolicy; - [MpPostfix(typeof(Dialog_ManageReadingPolicies), nameof(Dialog_ManageReadingPolicies.DoContentsRect))] - static void Dialog_ManageReadingPolicies_Postfix(Dialog_ManageReadingPolicies __instance) => drawnReadingPolicy = null; + [MpFinalizer(typeof(Dialog_ManageReadingPolicies), nameof(Dialog_ManageReadingPolicies.DoContentsRect))] + static void Dialog_ManageReadingPolicies_Finalizer(Dialog_ManageReadingPolicies __instance) => drawnReadingPolicy = null; [MpPrefix(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] static void ThingFilterUI_ReadingPolicy_Prefix(ThingFilter filter) @@ -107,8 +107,8 @@ static void ThingFilterUI_ReadingPolicy_Prefix(ThingFilter filter) DrawnThingFilter = new ReadingPolicyEffectFilterWrapper(drawnReadingPolicy); } - [MpPostfix(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] - static void ThingFilterUI_ReadingPolicy_Postfix(ThingFilter filter) + [MpFinalizer(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] + static void ThingFilterUI_ReadingPolicy_Finalizer(ThingFilter filter) { if (drawnReadingPolicy != null && drawnReadingPolicy.defFilter == filter) DrawnThingFilter = null; diff --git a/Source/Client/Util/DeterministicHash.cs b/Source/Client/Util/DeterministicHash.cs index a8d9adf98..68330964d 100644 --- a/Source/Client/Util/DeterministicHash.cs +++ b/Source/Client/Util/DeterministicHash.cs @@ -5,6 +5,28 @@ public static class DeterministicHash public const int DefaultSeed = 352654597; public const int DefaultSeed2 = 1566083941; + public static int HashCombineInt(int v1, int v2) + { + unchecked + { + return (int)(v1 ^ (v2 + 2654435769u + (v1 << 6) + (v1 >> 2))); + } + } + + public static int HashCombineInt(int v1, int v2, int v3, int v4) + { + unchecked + { + int h1 = DefaultSeed; + int h2 = h1; + h1 = ((h1 << 5) + h1 + (h1 >> 27)) ^ v1; + h2 = ((h2 << 5) + h2 + (h2 >> 27)) ^ v2; + h1 = ((h1 << 5) + h1 + (h1 >> 27)) ^ v3; + h2 = ((h2 << 5) + h2 + (h2 >> 27)) ^ v4; + return h1 + h2 * DefaultSeed2; + } + } + // 3-value combiner ────────────────────────────────────── public static int HashCombineInt(int v1, int v2, int v3) { diff --git a/Source/Client/Util/HashCodeTranspiler.cs b/Source/Client/Util/HashCodeTranspiler.cs new file mode 100644 index 000000000..d0fcb160f --- /dev/null +++ b/Source/Client/Util/HashCodeTranspiler.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; +using HarmonyLib; + +namespace Multiplayer.Client.Util; + +internal static class HashCodeTranspiler +{ + public static IEnumerable ReplaceCalls(IEnumerable instructions) + { + foreach (var instruction in instructions) + { + if (instruction.opcode == OpCodes.Call && instruction.operand is MethodInfo method + && method.DeclaringType?.FullName == "System.HashCode" && method.Name == "Combine") + { + var args = method.GetParameters(); + if (args.Length is >= 2 and <= 8 && args.All(p => p.ParameterType == typeof(int))) + instruction.operand = AccessTools.Method(typeof(DeterministicHash), nameof(DeterministicHash.HashCombineInt), + Enumerable.Repeat(typeof(int), args.Length).ToArray()); + } + + // Retain labels and exception blocks on replaced calls. + yield return instruction; + } + } +} diff --git a/Source/Client/Util/MpPatch.cs b/Source/Client/Util/MpPatch.cs index cce8bdea6..f45e52e37 100644 --- a/Source/Client/Util/MpPatch.cs +++ b/Source/Client/Util/MpPatch.cs @@ -120,7 +120,8 @@ public static List DoMpPatches(this Harmony harmony, Type type) toPatch, (attr is MpPrefix) ? patch : null, (attr is MpPostfix) ? patch : null, - (attr is MpTranspiler) ? patch : null + (attr is MpTranspiler) ? patch : null, + finalizer: (attr is MpFinalizer) ? patch : null ); } @@ -189,6 +190,17 @@ public MpPostfix(Type parentType, string parentMethod, int lambdaOrdinal) : base } } + /// + /// Finalizer method attribute + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + public class MpFinalizer : MpPatch + { + public MpFinalizer(Type type, string method, Type[] argTypes = null) : base(type, method, argTypes) + { + } + } + /// /// Transpiler method attribute /// diff --git a/Source/Tests/HashCodeTranspilerTest.cs b/Source/Tests/HashCodeTranspilerTest.cs new file mode 100644 index 000000000..d14b08012 --- /dev/null +++ b/Source/Tests/HashCodeTranspilerTest.cs @@ -0,0 +1,74 @@ +using System.Reflection; +using System.Reflection.Emit; +using System.Runtime.CompilerServices; +using HarmonyLib; +using Multiplayer.Client.Util; + +namespace Tests; + +[NonParallelizable] +public class HashCodeTranspilerTest +{ + [Test] + public void PatchingGameCallerPreservesExistingDictionaryKeys() + { + var key = (new object(), "ingredients"); + var dictionary = new Dictionary<(object, string), string> { [key] = "cached" }; + var tupleHash = key.GetHashCode(); + var systemHash = HashCode.Combine(17, 42); + var target = AccessTools.Method(typeof(HashCodeTranspilerTest), nameof(GameSeed)); + var harmony = new Harmony("Tests.GameHashCalls"); + + try + { + harmony.Patch(target, transpiler: new HarmonyMethod(typeof(HashCodeTranspiler), nameof(HashCodeTranspiler.ReplaceCalls))); + + Assert.That(target.Invoke(null, new object[] { 17, 42 }), Is.EqualTo(DeterministicHash.HashCombineInt(17, 42))); + Assert.That(HashCode.Combine(17, 42), Is.EqualTo(systemHash)); + Assert.That(key.GetHashCode(), Is.EqualTo(tupleHash)); + Assert.That(dictionary[key], Is.EqualTo("cached")); + Assert.That(dictionary.Remove(key), Is.True); + } + finally + { + harmony.Unpatch(target, AccessTools.Method(typeof(HashCodeTranspiler), nameof(HashCodeTranspiler.ReplaceCalls))); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static int GameSeed(int a, int b) => HashCode.Combine(a, b); + + [TestCase(2)] + [TestCase(3)] + [TestCase(4)] + [TestCase(5)] + [TestCase(6)] + [TestCase(7)] + [TestCase(8)] + public void ReplacesIntegerCombinesWithoutDroppingLabels(int arity) + { + var combine = typeof(HashCode).GetMethods().Single(m => m.Name == "Combine" && m.GetGenericArguments().Length == arity) + .MakeGenericMethod(Enumerable.Repeat(typeof(int), arity).ToArray()); + var generator = new DynamicMethod("labels", typeof(void), Type.EmptyTypes).GetILGenerator(); + var label = generator.DefineLabel(); + var call = new CodeInstruction(OpCodes.Call, combine); + call.labels.Add(label); + + var replaced = HashCodeTranspiler.ReplaceCalls(new[] { call }).Single(); + + Assert.That(replaced.labels, Does.Contain(label)); + var replacement = (MethodInfo)replaced.operand; + Assert.That(replacement.DeclaringType, Is.EqualTo(typeof(DeterministicHash))); + Assert.That(replacement.GetParameters().Select(p => p.ParameterType), Is.EqualTo(Enumerable.Repeat(typeof(int), arity))); + } + + [Test] + public void LeavesMixedTypeCombinesAlone() + { + var combine = typeof(HashCode).GetMethods().Single(m => m.Name == "Combine" && m.GetGenericArguments().Length == 2) + .MakeGenericMethod(typeof(int), typeof(string)); + var call = new CodeInstruction(OpCodes.Call, combine); + + Assert.That(HashCodeTranspiler.ReplaceCalls(new[] { call }).Single().operand, Is.EqualTo(combine)); + } +} diff --git a/Source/Tests/Tests.csproj b/Source/Tests/Tests.csproj index 1264869cb..db29947bc 100644 --- a/Source/Tests/Tests.csproj +++ b/Source/Tests/Tests.csproj @@ -25,6 +25,9 @@ + + + diff --git a/Source/Tests/ViewedMapTrackerTest.cs b/Source/Tests/ViewedMapTrackerTest.cs new file mode 100644 index 000000000..207403385 --- /dev/null +++ b/Source/Tests/ViewedMapTrackerTest.cs @@ -0,0 +1,58 @@ +using Multiplayer.Client.Patches; + +namespace Tests; + +public class ViewedMapTrackerTest +{ + [Test] + public void ReportsInitialWorldViewWithoutVisitingATileMap() + { + var tracker = new ViewedMapTracker(); + var sent = new List<(int, int)>(); + + tracker.Report(-2, (previous, current) => sent.Add((previous, current))); + + Assert.That(sent, Is.EqualTo(new[] { (-1, -2) })); + } + + [Test] + public void ReportsInitialMapAndPreservesTransitionChainAfterReload() + { + var tracker = new ViewedMapTracker(); + var sent = new List<(int, int)>(); + void Send(int previous, int current) => sent.Add((previous, current)); + + tracker.Report(0, Send); + tracker.Report(0, Send); + // A reload changes the local map without sending a transition. + tracker.Report(-2, Send); + tracker.Report(5, Send); + + Assert.That(sent, Is.EqualTo(new[] { (-1, 0), (0, -2), (-2, 5) })); + } + + [Test] + public void FailedSendDoesNotAdvanceReportedMap() + { + var tracker = new ViewedMapTracker(); + Assert.Throws(() => tracker.Report(0, (_, _) => throw new InvalidOperationException())); + var sent = new List<(int, int)>(); + + tracker.Report(1, (previous, current) => sent.Add((previous, current))); + + Assert.That(sent, Is.EqualTo(new[] { (-1, 1) })); + } + + [Test] + public void NewSessionStartsWithUnknownMapAgain() + { + var tracker = new ViewedMapTracker(); + var sent = new List<(int, int)>(); + void Send(int previous, int current) => sent.Add((previous, current)); + tracker.Report(0, Send); + tracker.Reset(); + tracker.Report(0, Send); + + Assert.That(sent, Is.EqualTo(new[] { (-1, 0), (-1, 0) })); + } +} From 6956d170c4c996a6783af97f7c4ab8b209e77cf7 Mon Sep 17 00:00:00 2001 From: roman_gr Date: Mon, 26 Oct 2026 23:09:37 +0100 Subject: [PATCH 09/19] WIP: checkpoint RimWorld 1.6 and 1.7 compatibility changes --- .github/workflows/build-beta.yml | 2 +- .github/workflows/build-workshop.yml | 2 +- .github/workflows/pr-check.yml | 3 +- .gitignore | 4 +++ About/About.xml | 18 ++++++++++++ README.md | 26 +++++++++++++++++ Source/Client/Desyncs/SaveableDesyncInfo.cs | 8 ++++-- Source/Client/Multiplayer.csproj | 20 ++++++------- Source/Client/MultiplayerStatic.cs | 3 +- Source/Client/Patches/VanillaTweaks.cs | 11 ++++++-- Source/Client/Util/MpUI.cs | 28 +++++++++++++++---- Source/Common/Common.csproj | 5 ++-- Source/Directory.Build.targets | 17 ----------- .../MultiplayerLoader.csproj | 6 ++-- Source/MultiplayerLoader/Prepatches.cs | 6 ++-- Source/MultiplayerLoader/SyncRituals.cs | 18 ++++++------ Source/Server/Server.csproj | 5 ++-- Source/Tests/Tests.csproj | 5 ++-- Source/TestsOnMono/README.md | 6 ++-- Source/TestsOnMono/TestsOnMono.csproj | 2 +- workshop_bundler.sh | 6 +++- 21 files changed, 130 insertions(+), 71 deletions(-) delete mode 100644 Source/Directory.Build.targets diff --git a/.github/workflows/build-beta.yml b/.github/workflows/build-beta.yml index d4e2db162..3dea3fc45 100644 --- a/.github/workflows/build-beta.yml +++ b/.github/workflows/build-beta.yml @@ -26,7 +26,7 @@ jobs: - name: Setup Dotnet uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.x + dotnet-version: 10.x - name: Install Mod Dependencies run: dotnet restore ${{ env.SLN_PATH }} diff --git a/.github/workflows/build-workshop.yml b/.github/workflows/build-workshop.yml index 441801a74..1698600cb 100644 --- a/.github/workflows/build-workshop.yml +++ b/.github/workflows/build-workshop.yml @@ -20,7 +20,7 @@ jobs: - name: Setup Dotnet uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.x + dotnet-version: 10.x - name: Run workshop bundler run: ./workshop_bundler.sh diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index b44397c6c..84df9b1ff 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -19,8 +19,9 @@ jobs: - name: Set up .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.x + dotnet-version: 10.x env: + DOTNET_ROLL_FORWARD: Major DOTNET_NOLOGO: true DOTNET_CLI_TELEMETRY_OPTOUT: true diff --git a/.gitignore b/.gitignore index 066bc4f10..04aa40115 100644 --- a/.gitignore +++ b/.gitignore @@ -265,6 +265,10 @@ __pycache__/ /Assemblies /AssembliesCustom +/1.6/Assemblies/ +/1.6/AssembliesCustom/ +/1.7/Assemblies/ +/1.7/AssembliesCustom/ /Source/Publicise-1.0.0.zip /Source/publicise_hash.txt /Source/Publicise.exe diff --git a/About/About.xml b/About/About.xml index c69e97dd2..508d272dc 100644 --- a/About/About.xml +++ b/About/About.xml @@ -4,6 +4,7 @@ Multiplayer
  • 1.6
  • +
  • 1.7
  • RimWorld Multiplayer Team https://github.com/rwmt/Multiplayer @@ -22,6 +23,23 @@ Discord - https://discord.gg/S4bxXpv steam://url/CommunityFilePage/2934420800 + + + +
  • + zetrith.prepatcher + Prepatcher + steam://url/CommunityFilePage/2934420800 +
  • +
    + +
  • + brrainz.harmony + Harmony 3 + steam://url/CommunityFilePage/2009463077 +
  • +
    +
  • Ludeon.RimWorld
  • Ludeon.RimWorld.Royalty
  • diff --git a/README.md b/README.md index b0158fcad..17f238baa 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,32 @@ The RimWorld Multiplayer mod allows users to play full games of Rimworld coopera Please do all pull requests to the [dev](https://github.com/rwmt/Multiplayer/tree/dev) branch. +## Building for RimWorld 1.6 and 1.7 + +Use the .NET 10 SDK. Both mod variants target .NET Framework 4.8 with C# 12 +and compile against the RimWorld 1.6 reference assemblies. The 1.7 runtime +redirects legacy Unity assembly references; runtime checks handle game API differences. + +Select the game version with `RimWorldVersion` (defaults to `1.6`). This selects +Harmony 2.4.2 for 1.6, matching Prepatcher's bundled Harmony, or Harmony 3.0.0-rc.3 +for 1.7. The source and target framework are shared, but the resulting DLLs are +version-specific because they reference different Harmony assemblies. + +```sh +dotnet build Source/Multiplayer.sln -c Release -p:RimWorldVersion=1.6 +dotnet test Source/Tests/Tests.csproj -c Release --no-build -p:RimWorldVersion=1.6 +dotnet build Source/Multiplayer.sln -c Release -p:RimWorldVersion=1.7 +dotnet test Source/Tests/Tests.csproj -c Release --no-build -p:RimWorldVersion=1.7 +``` + +Restore intermediates and build outputs are isolated by game version. Mod DLLs +are copied to `1.6/Assemblies/` and `1.6/AssembliesCustom/`, or their `1.7/` +equivalents. `LoadFolders.xml` selects the matching variant. Install Prepatcher +on 1.6; install Harmony 3 on 1.7, which does not require Prepatcher. The package +contains no bundled Harmony DLL. When updating an older checkout or installation, +remove obsolete root `Assemblies/` and `AssembliesCustom/` folders before running +the game, so only the versioned DLLs are loaded. + ## Donations If you’re feeling generous these are people who have contributed greatly to the mod’s development and upkeep. diff --git a/Source/Client/Desyncs/SaveableDesyncInfo.cs b/Source/Client/Desyncs/SaveableDesyncInfo.cs index 7379d9b65..34ae3438b 100644 --- a/Source/Client/Desyncs/SaveableDesyncInfo.cs +++ b/Source/Client/Desyncs/SaveableDesyncInfo.cs @@ -3,6 +3,7 @@ using System.IO; using System.IO.Compression; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -128,9 +129,7 @@ private string GetDesyncDetails() .AppendLine($"Processor Speed (MHz)|||{SystemInfo.processorFrequency}") .AppendLine($"Thread Count|||{SystemInfo.processorCount}") .AppendLine("\n###GPU Info###") -#if !RIMWORLD_1_7 - .AppendLine($"GPU Family|||{SystemInfo.graphicsDeviceVendor}") -#endif + .AppendLine($"GPU Family|||{(Native.IsMonoRuntime ? MonoGraphicsDeviceVendor() : "Unavailable")}") .AppendLine($"GPU Type|||{SystemInfo.graphicsDeviceType}") .AppendLine($"GPU Name|||{SystemInfo.graphicsDeviceName}") .AppendLine($"GPU VRAM|||{SystemInfo.graphicsMemorySize}") @@ -143,6 +142,9 @@ private string GetDesyncDetails() return desyncInfo.ToString(); } + [MethodImpl(MethodImplOptions.NoInlining)] + private static string MonoGraphicsDeviceVendor() => SystemInfo.graphicsDeviceVendor; + private static string FindFileNameForNextDesyncFile() { const string FilePrefix = "Desync-"; diff --git a/Source/Client/Multiplayer.csproj b/Source/Client/Multiplayer.csproj index 8f38f6a00..19a38b838 100644 --- a/Source/Client/Multiplayer.csproj +++ b/Source/Client/Multiplayer.csproj @@ -1,12 +1,10 @@ - net48 - net10.0 + net48 true 12 false - bin false false 0.6.2 @@ -25,7 +23,7 @@ - + @@ -60,7 +58,7 @@ - ..\..\ + $(MSBuildThisFileDirectory)..\..\$(RimWorldVersion)\ @@ -68,13 +66,15 @@ - - - - + + + + - + + diff --git a/Source/Client/MultiplayerStatic.cs b/Source/Client/MultiplayerStatic.cs index 49519f9b9..41a6f244f 100644 --- a/Source/Client/MultiplayerStatic.cs +++ b/Source/Client/MultiplayerStatic.cs @@ -59,7 +59,8 @@ static MultiplayerStatic() } ); - Native.HarmonyOriginalGetter = MpUtil.GetOriginalFromHarmonyReplacement; + if (Native.IsMonoRuntime) + Native.HarmonyOriginalGetter = MpUtil.GetOriginalFromHarmonyReplacement; // UnityEngine.Debug.Log instead of Verse.Log.Message because the server runs on its own thread ServerLog.info = str => Debug.Log($"MpServerLog: {str}"); diff --git a/Source/Client/Patches/VanillaTweaks.cs b/Source/Client/Patches/VanillaTweaks.cs index d8716df64..dde613169 100644 --- a/Source/Client/Patches/VanillaTweaks.cs +++ b/Source/Client/Patches/VanillaTweaks.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Reflection; using System.Reflection.Emit; +using System.Runtime.CompilerServices; using UnityEngine; using Verse; @@ -19,11 +20,15 @@ static void Prefix() { if (done) return; GUI.skin.font = Text.fontStyles[1].font; -#if !RIMWORLD_1_7 - Text.fontStyles[1].font.fontNames = new[] { "arial", "arialbd", "ariali", "arialbi" }; -#endif + if (Native.IsMonoRuntime) + SetMonoFontNames(); done = true; } + + // Keep missing Unity APIs out of methods JIT-compiled on Waylith. + [MethodImpl(MethodImplOptions.NoInlining)] + private static void SetMonoFontNames() => + Text.fontStyles[1].font.fontNames = new[] { "arial", "arialbd", "ariali", "arialbi" }; } // Fix window focus handling diff --git a/Source/Client/Util/MpUI.cs b/Source/Client/Util/MpUI.cs index 590b34d02..29b04e42c 100644 --- a/Source/Client/Util/MpUI.cs +++ b/Source/Client/Util/MpUI.cs @@ -1,4 +1,6 @@ using System.Collections.Generic; +using System.Reflection; +using System.Runtime.CompilerServices; using HarmonyLib; using RimWorld; using UnityEngine; @@ -9,6 +11,10 @@ namespace Multiplayer.Client.Util { public static class MpUI { + // RimWorld 1.6 exposes this as a field; Waylith exposes it as a property. + private static readonly FieldInfo editorScrollOffsetField = AccessTools.Field(typeof(TextEditor), "scrollOffset"); + private static readonly PropertyInfo editorScrollOffsetProperty = AccessTools.Property(typeof(TextEditor), "scrollOffset"); + public static Vector2 Resolution => new Vector2(UI.screenWidth, UI.screenHeight); public static string FixedEllipsis() @@ -242,7 +248,9 @@ public static void DoPasswordField(Rect rect, string controlName, ref string pas Vector2 scrollOffset = default; bool keyDown = Event.current.type == EventType.KeyDown; if (te != null) - scrollOffset = te.scrollOffset; + scrollOffset = (Vector2)(editorScrollOffsetField != null + ? editorScrollOffsetField.GetValue(te) + : editorScrollOffsetProperty.GetValue(te)); GUI.SetNextControlName(controlName); @@ -261,14 +269,22 @@ public static void DoPasswordField(Rect rect, string controlName, ref string pas if (te != null && keyDown) { - te.scrollOffset = scrollOffset; + if (editorScrollOffsetField != null) + editorScrollOffsetField.SetValue(te, scrollOffset); + else + editorScrollOffsetProperty.SetValue(te, scrollOffset); te.text = new string(PassChar, password.Length); -#if !RIMWORLD_1_7 - AccessTools.Field(typeof(TextEditor), "m_RevealCursor").SetValue(te, true); - te.UpdateScrollOffsetIfNeeded(new Event()); -#endif + if (Native.IsMonoRuntime) + UpdateMonoPasswordCursor(te); } } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void UpdateMonoPasswordCursor(TextEditor editor) + { + AccessTools.Field(typeof(TextEditor), "m_RevealCursor").SetValue(editor, true); + editor.UpdateScrollOffsetIfNeeded(new Event()); + } } public enum ElementOrder diff --git a/Source/Common/Common.csproj b/Source/Common/Common.csproj index e90e1c879..c4855ede9 100644 --- a/Source/Common/Common.csproj +++ b/Source/Common/Common.csproj @@ -1,8 +1,7 @@ - net48 - net10.0 + net48 true enable 12 @@ -15,7 +14,7 @@ - + diff --git a/Source/Directory.Build.targets b/Source/Directory.Build.targets deleted file mode 100644 index d2f018c5e..000000000 --- a/Source/Directory.Build.targets +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - $(DefineConstants);RIMWORLD_1_7 - - true - - - - - - - diff --git a/Source/MultiplayerLoader/MultiplayerLoader.csproj b/Source/MultiplayerLoader/MultiplayerLoader.csproj index e2eb6b50d..6de969ce3 100644 --- a/Source/MultiplayerLoader/MultiplayerLoader.csproj +++ b/Source/MultiplayerLoader/MultiplayerLoader.csproj @@ -1,8 +1,7 @@ - net48 - net10.0 + net48 true 12 false @@ -17,12 +16,13 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + + diff --git a/Source/MultiplayerLoader/Prepatches.cs b/Source/MultiplayerLoader/Prepatches.cs index 348d69b89..a3d859b9c 100644 --- a/Source/MultiplayerLoader/Prepatches.cs +++ b/Source/MultiplayerLoader/Prepatches.cs @@ -6,6 +6,7 @@ using Mono.Cecil.Cil; using MonoMod.Utils; using Multiplayer.Common; +using Multiplayer.Client; using Prepatcher; using RimWorld; @@ -29,9 +30,8 @@ static void DontSetResolution(ModuleDefinition module) [FreePatch] static void ApplySyncPrepatches(ModuleDefinition module) { -#if !RIMWORLD_1_7 - SyncRituals.ApplyPrepatches(module); -#endif + if (Native.IsMonoRuntime) + SyncRituals.ApplyPrepatches(module); } internal static void AddPrepatcherPrefix(ModuleDefinition module, int id, MethodInfo target, MethodInfo prefix) diff --git a/Source/MultiplayerLoader/SyncRituals.cs b/Source/MultiplayerLoader/SyncRituals.cs index 02aa5aa2a..3cc8e4b31 100644 --- a/Source/MultiplayerLoader/SyncRituals.cs +++ b/Source/MultiplayerLoader/SyncRituals.cs @@ -5,6 +5,7 @@ using HarmonyLib; using Mono.Cecil; using Multiplayer.API; +using Multiplayer.Client; using Multiplayer.Client.Util; using RimWorld; @@ -13,7 +14,6 @@ namespace MultiplayerLoader; public static class SyncRituals { private static List syncMethodsWithPrepatches = new(); -#if RIMWORLD_1_7 private static readonly Dictionary<(Type, string), ISyncMethodForPrepatcher> runtimeSyncMethods = new(); private static readonly HashSet runtimePatchedMethods = new(); private static readonly Harmony runtimeHarmony = new("rwmt.Multiplayer.WaylithRituals"); @@ -23,7 +23,6 @@ public static bool RuntimePrefix(object __instance, object[] __args, MethodBase return !runtimeSyncMethods.TryGetValue((__instance.GetType(), __originalMethod.Name), out var sync) || !sync.DoSync(__instance, __args); } -#endif public static bool PrepatcherPrefix(int index, object __instance, object[] __args) { @@ -61,13 +60,14 @@ void Register(Type baseType, string method, Type derivedType, Action Exe - net8.0 - net10.0 + net8.0 enable enable 12 @@ -18,7 +17,7 @@ - + diff --git a/Source/Tests/Tests.csproj b/Source/Tests/Tests.csproj index db29947bc..7242ba550 100644 --- a/Source/Tests/Tests.csproj +++ b/Source/Tests/Tests.csproj @@ -1,8 +1,7 @@ - net8.0 - net10.0 + net8.0 enable enable 12 @@ -19,7 +18,7 @@ - + diff --git a/Source/TestsOnMono/README.md b/Source/TestsOnMono/README.md index 8261e0098..3f890daaf 100644 --- a/Source/TestsOnMono/README.md +++ b/Source/TestsOnMono/README.md @@ -1,10 +1,12 @@ This is a manual test executable for Windows Mono, separate from the main solution. It exercises Mono runtime internals and does not run on RimWorld 1.7's CoreCLR runtime. -Build it explicitly using the RimWorld 1.6 configuration: +Build it explicitly with the .NET 10 SDK: ```sh -dotnet build Source/TestsOnMono/TestsOnMono.csproj -c Release -p:RimWorld17=false +dotnet build Source/TestsOnMono/TestsOnMono.csproj -c Release -p:RimWorldVersion=1.6 ``` +This uses Harmony 2.4.2, matching Prepatcher on RimWorld 1.6. + Run the resulting `TestsOnMono.exe` under Windows Mono. diff --git a/Source/TestsOnMono/TestsOnMono.csproj b/Source/TestsOnMono/TestsOnMono.csproj index 187ca8c74..3e5193e80 100644 --- a/Source/TestsOnMono/TestsOnMono.csproj +++ b/Source/TestsOnMono/TestsOnMono.csproj @@ -10,7 +10,7 @@ - + diff --git a/workshop_bundler.sh b/workshop_bundler.sh index bc0b3a188..1569547db 100755 --- a/workshop_bundler.sh +++ b/workshop_bundler.sh @@ -17,6 +17,10 @@ cp -r ../About ../Textures . cat < LoadFolders.xml + +
  • /
  • +
  • 1.6
  • +
  • /
  • 1.6
  • @@ -39,7 +43,7 @@ fi sed -i "// a \ \ \ \
  • 1.5
  • " About/About.xml sed -i "s/.*<\/modVersion>.*\$/${FULL_VERSION}<\/modVersion>/" About/About.xml -# The current version +# Shared 1.6/1.7 build; keep the folder name for existing installations. mkdir -p 1.6 cp -r ../Assemblies ../AssembliesCustom ../Defs ../Languages 1.6/ rm -f 1.6/Languages/.git 1.6/Languages/LICENSE 1.6/Languages/README.md From b9fbdce4270377aba12bf272be56b9eaf3068eb1 Mon Sep 17 00:00:00 2001 From: roman_gr Date: Tue, 27 Oct 2026 20:02:09 +0100 Subject: [PATCH 10/19] Support RimWorld 1.6 and 1.7 with versioned builds --- .github/scripts/stage-multiplayer-beta.sh | 46 +++++ .github/workflows/alpha-notes.md | 10 +- .github/workflows/build-beta.yml | 38 +++-- .github/workflows/pr-check.yml | 19 ++- .gitignore | 3 +- About/About.xml | 21 +-- LoadFolders.xml | 15 ++ README.md | 44 +++-- Source/1.6/Client/MonoCompatibility.cs | 50 ++++++ Source/1.6/Client/SyncMethod.Prepatcher.cs | 5 + .../ISyncMethodForPrepatcher.cs | 0 .../{ => 1.6}/MultiplayerLoader/Prepatches.cs | 6 +- .../SyncRituals.Prepatcher.cs | 46 +++++ Source/1.7/Client/KeepDebugResolution.cs | 20 +++ .../MultiplayerLoader/Multiplayer.Harmony.cs | 16 ++ .../SyncRituals.RuntimeHarmony.cs | 38 +++++ Source/Client/Desyncs/SaveableDesyncInfo.cs | 11 +- Source/Client/Multiplayer.csproj | 21 +-- Source/Client/Patches/Letters.cs | 1 + Source/Client/Patches/VanillaTweaks.cs | 11 +- Source/Client/Syncing/Game/SyncDelegates.cs | 2 +- Source/Client/Syncing/Handler/SyncMethod.cs | 4 +- Source/Client/Util/MpUI.cs | 13 +- Source/Common/Common.csproj | 6 +- Source/Common/Networking/MpConnectionState.cs | 1 + Source/Directory.Build.props | 27 +++ Source/Directory.Build.targets | 17 ++ Source/MultiplayerLoader/MultiplayerLoader.cs | 6 +- .../MultiplayerLoader.csproj | 6 +- Source/MultiplayerLoader/SyncRituals.cs | 129 +++++--------- Source/RuntimeTests/MetadataAudit.cs | 132 +++++++++++++++ Source/RuntimeTests/MetadataAudit.csproj | 8 + Source/RuntimeTests/README.md | 51 ++++++ .../RuntimeTests/RitualPrepatcherMonoProbe.cs | 160 ++++++++++++++++++ .../audit-rimworld-1.7-reference.sh | 27 +++ Source/RuntimeTests/run-ritual-probe.sh | 55 ++++++ Source/Server/Server.csproj | 2 +- Source/Tests/Tests.csproj | 2 +- Source/TestsOnMono/README.md | 5 +- Source/TestsOnMono/TestsOnMono.csproj | 2 +- 40 files changed, 865 insertions(+), 211 deletions(-) create mode 100755 .github/scripts/stage-multiplayer-beta.sh create mode 100644 LoadFolders.xml create mode 100644 Source/1.6/Client/MonoCompatibility.cs create mode 100644 Source/1.6/Client/SyncMethod.Prepatcher.cs rename Source/{ => 1.6}/MultiplayerLoader/ISyncMethodForPrepatcher.cs (100%) rename Source/{ => 1.6}/MultiplayerLoader/Prepatches.cs (96%) create mode 100644 Source/1.6/MultiplayerLoader/SyncRituals.Prepatcher.cs create mode 100644 Source/1.7/Client/KeepDebugResolution.cs create mode 100644 Source/1.7/MultiplayerLoader/Multiplayer.Harmony.cs create mode 100644 Source/1.7/MultiplayerLoader/SyncRituals.RuntimeHarmony.cs create mode 100644 Source/Directory.Build.props create mode 100644 Source/Directory.Build.targets create mode 100644 Source/RuntimeTests/MetadataAudit.cs create mode 100644 Source/RuntimeTests/MetadataAudit.csproj create mode 100644 Source/RuntimeTests/README.md create mode 100644 Source/RuntimeTests/RitualPrepatcherMonoProbe.cs create mode 100755 Source/RuntimeTests/audit-rimworld-1.7-reference.sh create mode 100755 Source/RuntimeTests/run-ritual-probe.sh diff --git a/.github/scripts/stage-multiplayer-beta.sh b/.github/scripts/stage-multiplayer-beta.sh new file mode 100755 index 000000000..a3985e0e9 --- /dev/null +++ b/.github/scripts/stage-multiplayer-beta.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +destination="${1:-output/Multiplayer}" +if [[ "$destination" != /* ]]; then + destination="$repo_root/$destination" +fi + +# Package only shared assets and the resolved outputs from each selector build. +rm -rf "$destination" +mkdir -p "$destination" +cp -R "$repo_root/About" "$repo_root/Defs" "$repo_root/Languages" "$repo_root/Textures" "$destination" +cp "$repo_root/LoadFolders.xml" "$destination/LoadFolders.xml" +rm -f "$destination/Languages/.git" +sed -i 's#Multiplayer#Multiplayer [Continuous]#' "$destination/About/About.xml" + +for version in 1.6 1.7; do + build="$repo_root/Source/Client/bin/$version/Release" + [[ -f "$build/Multiplayer.dll" ]] || { echo "Missing $version Client output: $build/Multiplayer.dll" >&2; exit 1; } + [[ -f "$build/MultiplayerCommon.dll" ]] || { echo "Missing $version Common output: $build/MultiplayerCommon.dll" >&2; exit 1; } + + mkdir -p "$destination/$version/Assemblies" "$destination/$version/AssembliesCustom" + cp "$build/Multiplayer.dll" "$build/MultiplayerCommon.dll" "$destination/$version/AssembliesCustom/" + + found_loader=false + for dll in "$build"/*.dll; do + [[ -f "$dll" ]] || continue + name="${dll##*/}" + case "$name" in + Multiplayer.dll|MultiplayerCommon.dll|0Harmony.dll|0Prepatcher*.dll|Prepatcher*.dll|Mono.Cecil*.dll|UnityStubs.dll|UnityEngine*.dll|Assembly-CSharp*.dll|Verse.dll) + continue + ;; + esac + cp "$dll" "$destination/$version/Assemblies/" + [[ "$name" != MultiplayerLoader.dll ]] || found_loader=true + done + + [[ "$found_loader" == true ]] || { echo "Missing resolved MultiplayerLoader.dll for RimWorld $version" >&2; exit 1; } +done + +# Harmony and Prepatcher are supplied by the dependencies declared in About.xml. +if find "$destination/1.7" -type f \( -iname '*prepatcher*.dll' -o -iname '0Harmony.dll' -o -iname 'Mono.Cecil*.dll' \) -print -quit | grep -q .; then + echo "Unexpected Harmony or Prepatcher implementation in the 1.7 package" >&2 + exit 1 +fi diff --git a/.github/workflows/alpha-notes.md b/.github/workflows/alpha-notes.md index f8ec5c59f..646c58dc2 100644 --- a/.github/workflows/alpha-notes.md +++ b/.github/workflows/alpha-notes.md @@ -1,6 +1,6 @@ ### This 'alpha' release is an automatically generated snapshot of the current state of development. It is continuously updated with work-in-progress changes that may be broken, incomplete, or incompatible. -#### Supported RimWorld versions: latest 1.6 +#### Supported RimWorld versions: 1.6 and 1.7 #### Installation 1. Download the `Multiplayer-beta.zip` file below. @@ -20,9 +20,9 @@ Download `Server-beta.zip` if you want to host a dedicated standalone server for **Setup** 1. Download and extract `Server-beta.zip`. -2. Open the folder for your platform: - - `Server/Windows` - - `Server/Linux` +2. Open the folder for your platform and game version: + - RimWorld 1.6: `Server/Windows` or `Server/Linux` (targets .NET 8) + - RimWorld 1.7: `Server/1.7/Windows` or `Server/1.7/Linux` (targets .NET 10) 3. Start the server using: - Windows: `Server.exe` - Linux: `./Server.sh` @@ -32,4 +32,4 @@ Download `Server-beta.zip` if you want to host a dedicated standalone server for No manual configuration files are required. The **first player to connect** via the Multiplayer mod client will be prompted to perform the initial setup: they can configure the game world, scenario, and other options directly from within RimWorld. -Subsequent players connect to the already-running session. \ No newline at end of file +Subsequent players connect to the already-running session. diff --git a/.github/workflows/build-beta.yml b/.github/workflows/build-beta.yml index 3dea3fc45..49260352a 100644 --- a/.github/workflows/build-beta.yml +++ b/.github/workflows/build-beta.yml @@ -1,8 +1,5 @@ name: Build beta -env: - SLN_PATH: Source/ - on: workflow_dispatch: push: @@ -28,34 +25,43 @@ jobs: with: dotnet-version: 10.x - - name: Install Mod Dependencies - run: dotnet restore ${{ env.SLN_PATH }} + - name: Restore and build RimWorld 1.6 + run: | + dotnet restore Source/Multiplayer.sln -p:RimWorldVersion=1.6 + dotnet build Source/Multiplayer.sln --configuration Release --no-restore -p:RimWorldVersion=1.6 - - name: Build Mod - run: dotnet build ${{ env.SLN_PATH }} --configuration Release --no-restore + - name: Restore and build RimWorld 1.7 + run: | + dotnet restore Source/Multiplayer.sln -p:RimWorldVersion=1.7 + dotnet build Source/Multiplayer.sln --configuration Release --no-restore -p:RimWorldVersion=1.7 - - name: Publish Server (Windows) - run: dotnet publish ${{ env.SLN_PATH }}Server/Server.csproj --configuration Release --runtime win-x64 --self-contained false -p:UseAppHost=true -o output/Server/Windows + - name: Publish Server 1.6 and 1.7 (Windows) + run: | + dotnet publish Source/Server/Server.csproj --configuration Release --runtime win-x64 --self-contained false -p:UseAppHost=true -p:RimWorldVersion=1.6 -o output/Server/Windows + dotnet publish Source/Server/Server.csproj --configuration Release --runtime win-x64 --self-contained false -p:UseAppHost=true -p:RimWorldVersion=1.7 -o output/Server/1.7/Windows - - name: Publish Server (Linux) - run: dotnet publish ${{ env.SLN_PATH }}Server/Server.csproj --configuration Release --runtime linux-x64 --self-contained false -p:UseAppHost=true -o output/Server/Linux + - name: Publish Server 1.6 and 1.7 (Linux) + run: | + dotnet publish Source/Server/Server.csproj --configuration Release --runtime linux-x64 --self-contained false -p:UseAppHost=true -p:RimWorldVersion=1.6 -o output/Server/Linux + dotnet publish Source/Server/Server.csproj --configuration Release --runtime linux-x64 --self-contained false -p:UseAppHost=true -p:RimWorldVersion=1.7 -o output/Server/1.7/Linux - name: Add Server Launchers run: | - cat > output/Server/Linux/Server.sh <<'EOF' + for directory in output/Server/Linux output/Server/1.7/Linux; do + cat > "$directory/Server.sh" <<'EOF' #!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" exec dotnet "$SCRIPT_DIR/Server.dll" "$@" EOF + chmod +x "$directory/Server.sh" + done - chmod +x output/Server/Linux/Server.sh + - name: Stage versioned Multiplayer mod + run: bash .github/scripts/stage-multiplayer-beta.sh output/Multiplayer - name: Package files run: | - sed -i "s/\(.*\)<\/name>\$/\1 [Continuous]<\/name>/" About/About.xml - mkdir -p output/Multiplayer - mv About/ Assemblies/ AssembliesCustom/ Defs/ Languages/ Textures/ output/Multiplayer cd output/ zip -r ../Multiplayer-beta.zip Multiplayer/ zip -r ../Server-beta.zip Server/ diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 84df9b1ff..aa1f99f99 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -4,6 +4,7 @@ name: Validate Pull Request env: SLN_PATH: Source/ + DOTNET_ROLL_FORWARD: Major on: pull_request @@ -12,6 +13,9 @@ jobs: builds: name: Builds runs-on: ubuntu-latest + strategy: + matrix: + rimworld: ['1.6', '1.7'] steps: - uses: actions/checkout@v4 @@ -21,18 +25,23 @@ jobs: with: dotnet-version: 10.x env: - DOTNET_ROLL_FORWARD: Major DOTNET_NOLOGO: true DOTNET_CLI_TELEMETRY_OPTOUT: true - name: Restore dependencies - run: dotnet restore ${{ env.SLN_PATH }} + run: dotnet restore Source/Client/Multiplayer.csproj -p:RimWorldVersion=${{ matrix.rimworld }} - name: Build debug - run: dotnet build ${{ env.SLN_PATH }} --no-restore + run: | + dotnet build Source/Client/Multiplayer.csproj -c Debug --no-restore -p:RimWorldVersion=${{ matrix.rimworld }} + dotnet build Source/Server/Server.csproj -c Debug -p:RimWorldVersion=${{ matrix.rimworld }} + dotnet build Source/Tests/Tests.csproj -c Debug -p:RimWorldVersion=${{ matrix.rimworld }} - name: Build release - run: dotnet build ${{ env.SLN_PATH }} --configuration Release --no-restore + run: | + dotnet build Source/Client/Multiplayer.csproj -c Release --no-restore -p:RimWorldVersion=${{ matrix.rimworld }} + dotnet build Source/Server/Server.csproj -c Release -p:RimWorldVersion=${{ matrix.rimworld }} + dotnet build Source/Tests/Tests.csproj -c Release -p:RimWorldVersion=${{ matrix.rimworld }} - name: Run tests - run: dotnet test ${{ env.SLN_PATH }} --no-restore + run: dotnet test Source/Tests/Tests.csproj -c Release --no-build --no-restore -p:RimWorldVersion=${{ matrix.rimworld }} diff --git a/.gitignore b/.gitignore index 04aa40115..13a9db3fa 100644 --- a/.gitignore +++ b/.gitignore @@ -269,6 +269,7 @@ __pycache__/ /1.6/AssembliesCustom/ /1.7/Assemblies/ /1.7/AssembliesCustom/ +/.versioned-build-backup-*/ /Source/Publicise-1.0.0.zip /Source/publicise_hash.txt /Source/Publicise.exe @@ -281,4 +282,4 @@ PersonalDefinitions.targets !Source/Client/Debug/ -/Source/mpdb \ No newline at end of file +/Source/mpdb diff --git a/About/About.xml b/About/About.xml index 508d272dc..298f81db1 100644 --- a/About/About.xml +++ b/About/About.xml @@ -16,28 +16,13 @@ Multiplayer mod for RimWorld. FAQ - https://hackmd.io/@rimworldmultiplayer/docs/ Discord - https://discord.gg/S4bxXpv - -
  • - zetrith.prepatcher - Prepatcher - steam://url/CommunityFilePage/2934420800 -
  • -
    - -
  • - zetrith.prepatcher - Prepatcher - steam://url/CommunityFilePage/2934420800 -
  • +
  • zetrith.prepatcherPrepatchersteam://url/CommunityFilePage/2934420800
  • +
  • brrainz.harmonyHarmony 2 (via Prepatcher)steam://url/CommunityFilePage/2009463077
  • -
  • - brrainz.harmony - Harmony 3 - steam://url/CommunityFilePage/2009463077 -
  • +
  • brrainz.harmonyHarmony 3steam://url/CommunityFilePage/2009463077
  • diff --git a/LoadFolders.xml b/LoadFolders.xml new file mode 100644 index 000000000..f1a1ba882 --- /dev/null +++ b/LoadFolders.xml @@ -0,0 +1,15 @@ + + + +
  • /
  • +
  • 1.6
  • +
    + +
  • /
  • +
  • 1.7
  • +
    + +
  • /
  • +
  • 1.5
  • +
    +
    diff --git a/README.md b/README.md index 17f238baa..e174705d2 100644 --- a/README.md +++ b/README.md @@ -22,29 +22,39 @@ Please do all pull requests to the [dev](https://github.com/rwmt/Multiplayer/tre ## Building for RimWorld 1.6 and 1.7 -Use the .NET 10 SDK. Both mod variants target .NET Framework 4.8 with C# 12 -and compile against the RimWorld 1.6 reference assemblies. The 1.7 runtime -redirects legacy Unity assembly references; runtime checks handle game API differences. - -Select the game version with `RimWorldVersion` (defaults to `1.6`). This selects -Harmony 2.4.2 for 1.6, matching Prepatcher's bundled Harmony, or Harmony 3.0.0-rc.3 -for 1.7. The source and target framework are shared, but the resulting DLLs are -version-specific because they reference different Harmony assemblies. +Use the .NET 10 SDK and select the matching game references with `RimWorldVersion`. +The selector defaults to `1.6`; only `1.6` and `1.7` are accepted. It separates +restore assets and outputs under version-specific `bin/` and `obj/` directories. ```sh dotnet build Source/Multiplayer.sln -c Release -p:RimWorldVersion=1.6 -dotnet test Source/Tests/Tests.csproj -c Release --no-build -p:RimWorldVersion=1.6 dotnet build Source/Multiplayer.sln -c Release -p:RimWorldVersion=1.7 -dotnet test Source/Tests/Tests.csproj -c Release --no-build -p:RimWorldVersion=1.7 +DOTNET_ROLL_FORWARD=Major dotnet test Source/Tests/Tests.csproj -c Release --no-build -p:RimWorldVersion=1.6 +DOTNET_ROLL_FORWARD=Major dotnet test Source/Tests/Tests.csproj -c Release --no-build -p:RimWorldVersion=1.7 ``` -Restore intermediates and build outputs are isolated by game version. Mod DLLs -are copied to `1.6/Assemblies/` and `1.6/AssembliesCustom/`, or their `1.7/` -equivalents. `LoadFolders.xml` selects the matching variant. Install Prepatcher -on 1.6; install Harmony 3 on 1.7, which does not require Prepatcher. The package -contains no bundled Harmony DLL. When updating an older checkout or installation, -remove obsolete root `Assemblies/` and `AssembliesCustom/` folders before running -the game, so only the versioned DLLs are loaded. +RimWorld 1.6 builds target .NET Framework 4.8 and use Harmony 2.4.2 with +Prepatcher. RimWorld 1.7 builds target .NET 10, use Harmony 3.0.0-rc.3, and patch +ritual handlers at runtime without Prepatcher. Install the matching Harmony and +Prepatcher mods. `LoadFolders.xml` selects the corresponding `1.6/` or `1.7/` DLLs; +shared assets remain at the mod root. When migrating from a flat install, remove old +root `Assemblies/` and `AssembliesCustom/` folders after backing up any local changes. + +Version-specific C# implementations live under `Source/1.6/Client/`, +`Source/1.6/MultiplayerLoader/`, `Source/1.7/Client/`, and +`Source/1.7/MultiplayerLoader/`. MSBuild selects exactly one tree for each build; +put shared code in the existing project folders and version-only code in the matching +tree. + +The workshop bundler builds both variants and retains the historical 1.5 package. + +The [version-specific ritual checks](Source/RuntimeTests/README.md) cover the 1.6 +registrar and direct `PrepatcherPrefix` dispatch under Mono/Harmony 2, including +normal and psychic handlers and serializer registration. The probe stubs `DoSync` +and calls the prefix directly; it does not exercise Prepatcher's Cecil injection or a +full game session. The 1.7 check reads .NET 10, RimWorld, and Harmony assembly metadata +without running the game assemblies. Live 1.7 CoreCLR and host/join testing remain +unavailable. ## Donations diff --git a/Source/1.6/Client/MonoCompatibility.cs b/Source/1.6/Client/MonoCompatibility.cs new file mode 100644 index 000000000..160967173 --- /dev/null +++ b/Source/1.6/Client/MonoCompatibility.cs @@ -0,0 +1,50 @@ +using System.Runtime.CompilerServices; +using System.Text; +using HarmonyLib; +using Multiplayer.Common; +using UnityEngine; +using Verse; + +namespace Multiplayer.Client +{ + static partial class UIRootPatch + { + [MethodImpl(MethodImplOptions.NoInlining)] + static partial void SetMonoFontNames() + { + if (!Native.IsMonoRuntime) + return; + + Text.fontStyles[1].font.fontNames = new[] { "arial", "arialbd", "ariali", "arialbi" }; + } + } +} + +namespace Multiplayer.Client.Util +{ + public static partial class MpUI + { + [MethodImpl(MethodImplOptions.NoInlining)] + static partial void UpdateMonoPasswordCursor(TextEditor editor) + { + if (!Native.IsMonoRuntime) + return; + + AccessTools.Field(typeof(TextEditor), "m_RevealCursor").SetValue(editor, true); + editor.UpdateScrollOffsetIfNeeded(new Event()); + } + } +} + +namespace Multiplayer.Client.Desyncs +{ + public partial class SaveableDesyncInfo + { + [MethodImpl(MethodImplOptions.NoInlining)] + partial void SetMonoGraphicsDeviceVendor(StringBuilder gpuFamily) + { + gpuFamily.Clear(); + gpuFamily.Append(SystemInfo.graphicsDeviceVendor); + } + } +} diff --git a/Source/1.6/Client/SyncMethod.Prepatcher.cs b/Source/1.6/Client/SyncMethod.Prepatcher.cs new file mode 100644 index 000000000..811fe2cfd --- /dev/null +++ b/Source/1.6/Client/SyncMethod.Prepatcher.cs @@ -0,0 +1,5 @@ +namespace Multiplayer.Client; + +public partial class SyncMethod : MultiplayerLoader.ISyncMethodForPrepatcher +{ +} diff --git a/Source/MultiplayerLoader/ISyncMethodForPrepatcher.cs b/Source/1.6/MultiplayerLoader/ISyncMethodForPrepatcher.cs similarity index 100% rename from Source/MultiplayerLoader/ISyncMethodForPrepatcher.cs rename to Source/1.6/MultiplayerLoader/ISyncMethodForPrepatcher.cs diff --git a/Source/MultiplayerLoader/Prepatches.cs b/Source/1.6/MultiplayerLoader/Prepatches.cs similarity index 96% rename from Source/MultiplayerLoader/Prepatches.cs rename to Source/1.6/MultiplayerLoader/Prepatches.cs index a3d859b9c..3797ed4af 100644 --- a/Source/MultiplayerLoader/Prepatches.cs +++ b/Source/1.6/MultiplayerLoader/Prepatches.cs @@ -5,9 +5,8 @@ using Mono.Cecil; using Mono.Cecil.Cil; using MonoMod.Utils; -using Multiplayer.Common; -using Multiplayer.Client; using Prepatcher; +using Multiplayer.Common; using RimWorld; namespace MultiplayerLoader; @@ -30,8 +29,7 @@ static void DontSetResolution(ModuleDefinition module) [FreePatch] static void ApplySyncPrepatches(ModuleDefinition module) { - if (Native.IsMonoRuntime) - SyncRituals.ApplyPrepatches(module); + SyncRituals.ApplyPrepatches(module); } internal static void AddPrepatcherPrefix(ModuleDefinition module, int id, MethodInfo target, MethodInfo prefix) diff --git a/Source/1.6/MultiplayerLoader/SyncRituals.Prepatcher.cs b/Source/1.6/MultiplayerLoader/SyncRituals.Prepatcher.cs new file mode 100644 index 000000000..05208fcfd --- /dev/null +++ b/Source/1.6/MultiplayerLoader/SyncRituals.Prepatcher.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using Mono.Cecil; +using Multiplayer.API; +using RimWorld; + +namespace MultiplayerLoader; + +public static partial class SyncRituals +{ + private static List syncMethodsWithPrepatches = new(); + + public static bool PrepatcherPrefix(int index, object __instance, object[] __args) + { + var sync = syncMethodsWithPrepatches[index]; + return sync.TargetType != __instance.GetType() || !sync.DoSync(__instance, __args); + } + + public static void Init() => ApplyPrepatches(null); + + public static void ApplyPrepatches(ModuleDefinition module) + { + void Register(string method, Type widgetType, Action postProcess) + { + if (module != null) + { + Prepatches.AddPrepatcherPrefix( + module, + syncMethodsWithPrepatches.Count, + AccessTools.Method(typeof(PawnRoleSelectionWidgetBase<>), method), + AccessTools.Method(typeof(SyncRituals), nameof(PrepatcherPrefix)) + ); + syncMethodsWithPrepatches.Add(null); + return; + } + + var sync = (ISyncMethodForPrepatcher)syncMethod.Invoke(null, [widgetType, method, null]); + postProcess?.Invoke(sync); + syncMethodsWithPrepatches.Add(sync); + } + + RegisterRitualSyncMethods(Register); + } +} diff --git a/Source/1.7/Client/KeepDebugResolution.cs b/Source/1.7/Client/KeepDebugResolution.cs new file mode 100644 index 000000000..f05561617 --- /dev/null +++ b/Source/1.7/Client/KeepDebugResolution.cs @@ -0,0 +1,20 @@ +#if DEBUG +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using HarmonyLib; +using RimWorld; + +namespace Multiplayer.Client.Patches; + +[HarmonyPatch] +static class KeepDebugResolution +{ + // Include every overload: RimWorld 1.7 also accepts FullScreenMode. + static IEnumerable TargetMethods() => + AccessTools.GetDeclaredMethods(typeof(ResolutionUtility)) + .Where(method => method.Name == nameof(ResolutionUtility.SetResolutionRaw)); + + static bool Prefix() => false; +} +#endif diff --git a/Source/1.7/MultiplayerLoader/Multiplayer.Harmony.cs b/Source/1.7/MultiplayerLoader/Multiplayer.Harmony.cs new file mode 100644 index 000000000..e9e6eaa9f --- /dev/null +++ b/Source/1.7/MultiplayerLoader/Multiplayer.Harmony.cs @@ -0,0 +1,16 @@ +using System; +using HarmonyLib; + +namespace MultiplayerLoader; + +public partial class Multiplayer +{ + partial void CheckHarmonyVersion() + { + var harmonyVersion = typeof(Harmony).Assembly.GetName().Version; + if (harmonyVersion.Major < 3) + throw new InvalidOperationException( + $"Multiplayer requires Harmony 3, but Harmony {harmonyVersion} was loaded. " + + "Update the Harmony mod and any mods that supply their own 0Harmony.dll."); + } +} diff --git a/Source/1.7/MultiplayerLoader/SyncRituals.RuntimeHarmony.cs b/Source/1.7/MultiplayerLoader/SyncRituals.RuntimeHarmony.cs new file mode 100644 index 000000000..ad429deae --- /dev/null +++ b/Source/1.7/MultiplayerLoader/SyncRituals.RuntimeHarmony.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using Multiplayer.API; + +namespace MultiplayerLoader; + +public static partial class SyncRituals +{ + private static readonly Dictionary<(Type, string), ISyncMethod> runtimeSyncMethods = new(); + private static readonly HashSet runtimePatchedMethods = new(); + private static readonly Harmony runtimeHarmony = new("rwmt.Multiplayer.Rituals"); + + public static bool RuntimePrefix(object __instance, object[] __args, MethodBase __originalMethod) + { + return !runtimeSyncMethods.TryGetValue((__instance.GetType(), __originalMethod.Name), out var sync) + || !sync.DoSync(__instance, __args); + } + + public static void Init() + { + void Register(string method, Type widgetType, Action postProcess) + { + // Keep the concrete widget type for serializers and dispatch on shared generic bodies. + var sync = (ISyncMethod)syncMethod.Invoke(null, [widgetType, method, null]); + postProcess?.Invoke(sync); + runtimeSyncMethods.Add((widgetType, method), sync); + + // The closed generic method is the runtime target for each concrete widget. + var target = AccessTools.Method(widgetType, method); + if (runtimePatchedMethods.Add(target)) + runtimeHarmony.Patch(target, prefix: new HarmonyMethod(typeof(SyncRituals), nameof(RuntimePrefix))); + } + + RegisterRitualSyncMethods(Register); + } +} diff --git a/Source/Client/Desyncs/SaveableDesyncInfo.cs b/Source/Client/Desyncs/SaveableDesyncInfo.cs index 34ae3438b..280c0cc84 100644 --- a/Source/Client/Desyncs/SaveableDesyncInfo.cs +++ b/Source/Client/Desyncs/SaveableDesyncInfo.cs @@ -3,7 +3,6 @@ using System.IO; using System.IO.Compression; using System.Linq; -using System.Runtime.CompilerServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -18,7 +17,7 @@ namespace Multiplayer.Client.Desyncs; -public class SaveableDesyncInfo( +public partial class SaveableDesyncInfo( SyncCoordinator coordinator, ClientSyncOpinion local, ClientSyncOpinion remote, @@ -106,6 +105,9 @@ private string GetLocalTraces() private string GetDesyncDetails() { var desyncInfo = new StringBuilder(); + var gpuFamily = new StringBuilder("Unavailable"); + if (Native.IsMonoRuntime) + SetMonoGraphicsDeviceVendor(gpuFamily); desyncInfo .AppendLine("###Tick Data###") @@ -129,7 +131,7 @@ private string GetDesyncDetails() .AppendLine($"Processor Speed (MHz)|||{SystemInfo.processorFrequency}") .AppendLine($"Thread Count|||{SystemInfo.processorCount}") .AppendLine("\n###GPU Info###") - .AppendLine($"GPU Family|||{(Native.IsMonoRuntime ? MonoGraphicsDeviceVendor() : "Unavailable")}") + .AppendLine($"GPU Family|||{gpuFamily}") .AppendLine($"GPU Type|||{SystemInfo.graphicsDeviceType}") .AppendLine($"GPU Name|||{SystemInfo.graphicsDeviceName}") .AppendLine($"GPU VRAM|||{SystemInfo.graphicsMemorySize}") @@ -142,8 +144,7 @@ private string GetDesyncDetails() return desyncInfo.ToString(); } - [MethodImpl(MethodImplOptions.NoInlining)] - private static string MonoGraphicsDeviceVendor() => SystemInfo.graphicsDeviceVendor; + partial void SetMonoGraphicsDeviceVendor(StringBuilder gpuFamily); private static string FindFileNameForNextDesyncFile() { diff --git a/Source/Client/Multiplayer.csproj b/Source/Client/Multiplayer.csproj index 19a38b838..10a1bfb91 100644 --- a/Source/Client/Multiplayer.csproj +++ b/Source/Client/Multiplayer.csproj @@ -1,7 +1,8 @@ - net48 + $(RimWorldTargetFramework) + true true 12 false @@ -24,7 +25,7 @@ - + @@ -58,27 +59,13 @@ - $(MSBuildThisFileDirectory)..\..\$(RimWorldVersion)\ + bin\$(RimWorldVersion)\$(Configuration)\ $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)'))=$(AssemblyName)/ - - - - - - - - - - - - - diff --git a/Source/Client/Patches/Letters.cs b/Source/Client/Patches/Letters.cs index 93ba7396b..d9f240851 100644 --- a/Source/Client/Patches/Letters.cs +++ b/Source/Client/Patches/Letters.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Reflection; using HarmonyLib; +using MethodInvoker = HarmonyLib.MethodInvoker; using RimWorld; using UnityEngine; using Verse; diff --git a/Source/Client/Patches/VanillaTweaks.cs b/Source/Client/Patches/VanillaTweaks.cs index dde613169..310b4c2c3 100644 --- a/Source/Client/Patches/VanillaTweaks.cs +++ b/Source/Client/Patches/VanillaTweaks.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Reflection; using System.Reflection.Emit; -using System.Runtime.CompilerServices; using UnityEngine; using Verse; @@ -12,7 +11,7 @@ namespace Multiplayer.Client // Fixes a lag spike when opening debug tools [HarmonyPatch(typeof(UIRoot))] [HarmonyPatch(nameof(UIRoot.UIRootOnGUI))] - static class UIRootPatch + static partial class UIRootPatch { static bool done; @@ -20,15 +19,11 @@ static void Prefix() { if (done) return; GUI.skin.font = Text.fontStyles[1].font; - if (Native.IsMonoRuntime) - SetMonoFontNames(); + SetMonoFontNames(); done = true; } - // Keep missing Unity APIs out of methods JIT-compiled on Waylith. - [MethodImpl(MethodImplOptions.NoInlining)] - private static void SetMonoFontNames() => - Text.fontStyles[1].font.fontNames = new[] { "arial", "arialbd", "ariali", "arialbi" }; + static partial void SetMonoFontNames(); } // Fix window focus handling diff --git a/Source/Client/Syncing/Game/SyncDelegates.cs b/Source/Client/Syncing/Game/SyncDelegates.cs index fbacb638e..dc530fc61 100644 --- a/Source/Client/Syncing/Game/SyncDelegates.cs +++ b/Source/Client/Syncing/Game/SyncDelegates.cs @@ -333,7 +333,7 @@ The UI's main interaction area is split into three types of groups of pawns. SyncMethod.Register(typeof(PsychicRitualRoleAssignments), nameof(PsychicRitualRoleAssignments.TryAssignSpectate)); SyncMethod.Register(typeof(PsychicRitualRoleAssignments), nameof(PsychicRitualRoleAssignments.RemoveParticipant)); - SyncRituals.ApplyPrepatches(null); + SyncRituals.Init(); } private static void InitChoiceLetters() diff --git a/Source/Client/Syncing/Handler/SyncMethod.cs b/Source/Client/Syncing/Handler/SyncMethod.cs index 3e0ba4b19..19c41f0f9 100644 --- a/Source/Client/Syncing/Handler/SyncMethod.cs +++ b/Source/Client/Syncing/Handler/SyncMethod.cs @@ -2,10 +2,10 @@ using System.Linq; using System.Reflection; using HarmonyLib; +using MethodInvoker = HarmonyLib.MethodInvoker; using Multiplayer.API; using Multiplayer.Client.Util; using Multiplayer.Common; -using MultiplayerLoader; using Verse; namespace Multiplayer.Client @@ -20,7 +20,7 @@ public class SyncTransformer(Type networkType, Delegate writer, Delegate reader) public delegate void SyncMethodWriter(object obj, SyncType type, string debugInfo); - public class SyncMethod : SyncHandler, ISyncMethod, ISyncMethodForPrepatcher + public partial class SyncMethod : SyncHandler, ISyncMethod { public readonly Type targetType; public readonly MethodInfo method; diff --git a/Source/Client/Util/MpUI.cs b/Source/Client/Util/MpUI.cs index 29b04e42c..b69c6a479 100644 --- a/Source/Client/Util/MpUI.cs +++ b/Source/Client/Util/MpUI.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.Reflection; -using System.Runtime.CompilerServices; using HarmonyLib; using RimWorld; using UnityEngine; @@ -9,7 +8,7 @@ namespace Multiplayer.Client.Util { - public static class MpUI + public static partial class MpUI { // RimWorld 1.6 exposes this as a field; Waylith exposes it as a property. private static readonly FieldInfo editorScrollOffsetField = AccessTools.Field(typeof(TextEditor), "scrollOffset"); @@ -274,17 +273,11 @@ public static void DoPasswordField(Rect rect, string controlName, ref string pas else editorScrollOffsetProperty.SetValue(te, scrollOffset); te.text = new string(PassChar, password.Length); - if (Native.IsMonoRuntime) - UpdateMonoPasswordCursor(te); + UpdateMonoPasswordCursor(te); } } - [MethodImpl(MethodImplOptions.NoInlining)] - private static void UpdateMonoPasswordCursor(TextEditor editor) - { - AccessTools.Field(typeof(TextEditor), "m_RevealCursor").SetValue(editor, true); - editor.UpdateScrollOffsetIfNeeded(new Event()); - } + static partial void UpdateMonoPasswordCursor(TextEditor editor); } public enum ElementOrder diff --git a/Source/Common/Common.csproj b/Source/Common/Common.csproj index c4855ede9..e44c44b53 100644 --- a/Source/Common/Common.csproj +++ b/Source/Common/Common.csproj @@ -1,18 +1,18 @@ - net48 + $(RimWorldCommonTargetFrameworks) + true true enable 12 - false false Multiplayer.Common MultiplayerCommon - + diff --git a/Source/Common/Networking/MpConnectionState.cs b/Source/Common/Networking/MpConnectionState.cs index 8551f48eb..18351c88d 100644 --- a/Source/Common/Networking/MpConnectionState.cs +++ b/Source/Common/Networking/MpConnectionState.cs @@ -2,6 +2,7 @@ using System.Reflection; using System.Reflection.Emit; using HarmonyLib; +using MethodInvoker = HarmonyLib.MethodInvoker; using Multiplayer.Common.Networking.Packet; namespace Multiplayer.Common diff --git a/Source/Directory.Build.props b/Source/Directory.Build.props new file mode 100644 index 000000000..1ebec3c7d --- /dev/null +++ b/Source/Directory.Build.props @@ -0,0 +1,27 @@ + + + 1.6 + net48 + net10.0 + net8.0 + net10.0 + net48;net8.0 + net10.0 + + net48 + net8.0 + net48;net8.0 + 1.6.4850 + 2.4.2 + 1.6.4850 + 1.7.4985-unstable + 2.4.2 + 3.0.0-rc.3 + obj/$(RimWorldVersion)/ + bin/$(RimWorldVersion)/ + $(DefaultItemExcludesInProjectFolder);**/bin/**;**/obj/** + + + + + diff --git a/Source/Directory.Build.targets b/Source/Directory.Build.targets new file mode 100644 index 000000000..c2102902f --- /dev/null +++ b/Source/Directory.Build.targets @@ -0,0 +1,17 @@ + + + + <_RimWorldVersionSourceDirectory Condition="'$(MSBuildProjectName)' == 'Multiplayer'">Client + <_RimWorldVersionSourceDirectory Condition="'$(MSBuildProjectName)' == 'MultiplayerLoader'">MultiplayerLoader + <_RimWorldVersionSourcePath Condition="'$(_RimWorldVersionSourceDirectory)' != ''">$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)$(RimWorldVersion)/$(_RimWorldVersionSourceDirectory)'))/ + $(PathMap),$(_RimWorldVersionSourcePath)=Versions/$(RimWorldVersion)/$(_RimWorldVersionSourceDirectory)/ + + + + + + Versions/$(RimWorldVersion)/$(_RimWorldVersionSourceDirectory)/%(RecursiveDir)%(Filename)%(Extension) + + + diff --git a/Source/MultiplayerLoader/MultiplayerLoader.cs b/Source/MultiplayerLoader/MultiplayerLoader.cs index a3bb738a9..ab80eb29f 100644 --- a/Source/MultiplayerLoader/MultiplayerLoader.cs +++ b/Source/MultiplayerLoader/MultiplayerLoader.cs @@ -9,19 +9,23 @@ namespace MultiplayerLoader { // This class has to be named Multiplayer for backwards compatibility of the settings file location - public class Multiplayer : Mod + public partial class Multiplayer : Mod { public static Multiplayer instance; public static Action settingsWindowDrawer; public Multiplayer(ModContentPack content) : base(content) { + CheckHarmonyVersion(); + instance = this; LoadAssembliesCustom(); FindTypeInAppDomain("Multiplayer.Client.Multiplayer")!.GetMethod("InitMultiplayer")!.Invoke(null, [content]); } + partial void CheckHarmonyVersion(); + public static Type? FindTypeInAppDomain(string typeFullName) => AppDomain.CurrentDomain.GetAssemblies() .Select(assembly => assembly.GetType(typeFullName, throwOnError: false, ignoreCase: false)) diff --git a/Source/MultiplayerLoader/MultiplayerLoader.csproj b/Source/MultiplayerLoader/MultiplayerLoader.csproj index 6de969ce3..5df5ed0af 100644 --- a/Source/MultiplayerLoader/MultiplayerLoader.csproj +++ b/Source/MultiplayerLoader/MultiplayerLoader.csproj @@ -1,7 +1,7 @@ - net48 + $(RimWorldTargetFramework) true 12 false @@ -10,13 +10,13 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Source/MultiplayerLoader/SyncRituals.cs b/Source/MultiplayerLoader/SyncRituals.cs index 3cc8e4b31..1e7a4407f 100644 --- a/Source/MultiplayerLoader/SyncRituals.cs +++ b/Source/MultiplayerLoader/SyncRituals.cs @@ -3,76 +3,18 @@ using System.Linq; using System.Reflection; using HarmonyLib; -using Mono.Cecil; using Multiplayer.API; -using Multiplayer.Client; using Multiplayer.Client.Util; using RimWorld; namespace MultiplayerLoader; -public static class SyncRituals +public static partial class SyncRituals { - private static List syncMethodsWithPrepatches = new(); - private static readonly Dictionary<(Type, string), ISyncMethodForPrepatcher> runtimeSyncMethods = new(); - private static readonly HashSet runtimePatchedMethods = new(); - private static readonly Harmony runtimeHarmony = new("rwmt.Multiplayer.WaylithRituals"); + private static readonly MethodInfo syncMethod = AccessTools.Method("Multiplayer.Client.Sync:Method"); - public static bool RuntimePrefix(object __instance, object[] __args, MethodBase __originalMethod) + private static void RegisterRitualSyncMethods(Action> register) { - return !runtimeSyncMethods.TryGetValue((__instance.GetType(), __originalMethod.Name), out var sync) - || !sync.DoSync(__instance, __args); - } - - public static bool PrepatcherPrefix(int index, object __instance, object[] __args) - { - // This prefix is applied to a generic method and called by all possible instantiations - // This check makes sure the type is correct - if (syncMethodsWithPrepatches[index].TargetType == __instance.GetType()) - return !syncMethodsWithPrepatches[index].DoSync(__instance, __args); - return true; - } - - private static MethodInfo syncMethod = AccessTools.Method("Multiplayer.Client.Sync:Method"); - - // This method is called twice (two passes) - // The first pass with module != null is called from Prepatcher and applies prepatches - // The second pass with module == null is called from Sync initialization and registers SyncMethods - public static void ApplyPrepatches(ModuleDefinition module) - { - void Register(Type baseType, string method, Type derivedType, Action postProcess = null) - { - if (module != null) - { - Prepatches.AddPrepatcherPrefix( - module, - syncMethodsWithPrepatches.Count, - AccessTools.Method(baseType, method), - AccessTools.Method(typeof(SyncRituals), nameof(PrepatcherPrefix)) - ); - - // The ids need to be in sync between passes (note the array is recreated after Prepatcher restarts the game) - syncMethodsWithPrepatches.Add(null); - } - else - { - // Sync.Method doesn't apply a Harmony patch and doesn't require the DeclaringType to be the target type - var newSyncMethod = (ISyncMethodForPrepatcher)syncMethod.Invoke(null, [derivedType, method, null]); - postProcess?.Invoke(newSyncMethod); - syncMethodsWithPrepatches.Add(newSyncMethod); - if (!Native.IsMonoRuntime) - { - // Waylith supports runtime patching; the Mono preloader is not used. - runtimeSyncMethods.Add((derivedType, method), newSyncMethod); - var target = AccessTools.Method(derivedType, method); - if (runtimePatchedMethods.Add(target)) - runtimeHarmony.Patch(target, prefix: new HarmonyMethod(typeof(SyncRituals), nameof(RuntimePrefix))); - } - } - } - - // ======= PawnRitualRoleSelectionWidget ======= - var ritualRolesSerializer = Serializer.New, (PawnRitualRoleSelectionWidget roleSelectionWidget, string Key)>( RitualRoleWriter, RitualRoleReader @@ -82,58 +24,63 @@ void Register(Type baseType, string method, Type derivedType, Action ); - Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignReplace), + // Keep one ordered registration list for both version-specific patch paths. + register( + nameof(PawnRoleSelectionWidgetBase.TryAssignReplace), typeof(PawnRitualRoleSelectionWidget), - m => m.TransformArgument(1, ritualRolesSerializer)); - Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignReplace), + method => method.TransformArgument(1, ritualRolesSerializer)); + register( + nameof(PawnRoleSelectionWidgetBase.TryAssignReplace), typeof(PawnPsychicRitualRoleSelectionWidget), - m => m.TransformArgument(1, psychicRitualRolesSerializer)); + method => method.TransformArgument(1, psychicRitualRolesSerializer)); - Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignAnyRole), - typeof(PawnRitualRoleSelectionWidget)); - Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignAnyRole), - typeof(PawnPsychicRitualRoleSelectionWidget)); + register( + nameof(PawnRoleSelectionWidgetBase.TryAssignAnyRole), + typeof(PawnRitualRoleSelectionWidget), + null); + register( + nameof(PawnRoleSelectionWidgetBase.TryAssignAnyRole), + typeof(PawnPsychicRitualRoleSelectionWidget), + null); - Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssign), + register( + nameof(PawnRoleSelectionWidgetBase.TryAssign), typeof(PawnRitualRoleSelectionWidget), - m => m.TransformArgument(1, ritualRolesSerializer)); - Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssign), + method => method.TransformArgument(1, ritualRolesSerializer)); + register( + nameof(PawnRoleSelectionWidgetBase.TryAssign), typeof(PawnPsychicRitualRoleSelectionWidget), - m => m.TransformArgument(1, psychicRitualRolesSerializer)); + method => method.TransformArgument(1, psychicRitualRolesSerializer)); - Register( - typeof(PawnRoleSelectionWidgetBase<>), + register( MpMethodUtil.GetLambda( typeof(PawnRoleSelectionWidgetBase), nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), lambdaOrdinal: 7).Name, - typeof(PawnRitualRoleSelectionWidget) - ); // Roles right click delegate (try assign spectate) - Register( - typeof(PawnRoleSelectionWidgetBase<>), + typeof(PawnRitualRoleSelectionWidget), + null); // Roles right click delegate (try assign spectate) + register( MpMethodUtil.GetLambda( typeof(PawnRoleSelectionWidgetBase), nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), lambdaOrdinal: 7).Name, - typeof(PawnPsychicRitualRoleSelectionWidget) - ); // Roles right click delegate (try assign spectate) + typeof(PawnPsychicRitualRoleSelectionWidget), + null); // Roles right click delegate (try assign spectate) - Register( - typeof(PawnRoleSelectionWidgetBase<>), + register( MpMethodUtil.GetLambda( typeof(PawnRoleSelectionWidgetBase), nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), lambdaOrdinal: 2).Name, - typeof(PawnRitualRoleSelectionWidget) - ); // Not participating left click delegate (try assign any role or spectate) - Register( - typeof(PawnRoleSelectionWidgetBase<>), + typeof(PawnRitualRoleSelectionWidget), + null); // Not participating left click delegate (try assign any role or spectate) + register( MpMethodUtil.GetLambda( typeof(PawnRoleSelectionWidgetBase), nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), lambdaOrdinal: 2).Name, - typeof(PawnPsychicRitualRoleSelectionWidget) - ); // Not participating left click delegate (try assign any role or spectate) + typeof(PawnPsychicRitualRoleSelectionWidget), + null); // Not participating left click delegate (try assign any role or spectate) } private static (WidgetType roleSelectionWidget, string Key) RitualRoleWriter(IEnumerable roles, object target, object[] _) @@ -141,13 +88,13 @@ private static (WidgetType roleSelectionWidget, string Key) RitualRoleWriter g.SequenceEqual(roles))?.Key); + return (roleSelectionWidget, roleSelectionWidget.assignments.RoleGroups().FirstOrDefault(group => group.SequenceEqual(roles))?.Key); } private static IEnumerable RitualRoleReader((WidgetType roleSelectionWidget, string Key) data) where WidgetType : PawnRoleSelectionWidgetBase where RoleType : class, ILordJobRole { - return data.roleSelectionWidget.assignments.RoleGroups().FirstOrDefault(g => g.Key == data.Key); + return data.roleSelectionWidget.assignments.RoleGroups().FirstOrDefault(group => group.Key == data.Key); } } diff --git a/Source/RuntimeTests/MetadataAudit.cs b/Source/RuntimeTests/MetadataAudit.cs new file mode 100644 index 000000000..b6028b0dd --- /dev/null +++ b/Source/RuntimeTests/MetadataAudit.cs @@ -0,0 +1,132 @@ +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +if (args.Length != 1) + throw new ArgumentException("Usage: MetadataAudit "); + +var root = Path.GetFullPath(args[0]); +var binaries = new Dictionary +{ + ["client"] = Path.Combine(root, "AssembliesCustom", "Multiplayer.dll"), + ["common"] = Path.Combine(root, "AssembliesCustom", "MultiplayerCommon.dll"), + ["loader"] = Path.Combine(root, "Assemblies", "MultiplayerLoader.dll"), +}; + +var assemblies = new Dictionary(); +foreach (var (role, path) in binaries) +{ + if (!File.Exists(path)) + throw new FileNotFoundException($"Missing 1.7 {role} assembly", path); + + using var stream = File.OpenRead(path); + using var pe = new PEReader(stream); + if (!pe.HasMetadata) + throw new BadImageFormatException($"No CLR metadata in {path}"); + var reader = pe.GetMetadataReader(); + var definition = reader.GetAssemblyDefinition(); + var name = reader.GetString(definition.Name); + var framework = ReadTargetFramework(reader); + if (framework != ".NETCoreApp,Version=v10.0") + throw new InvalidDataException($"{role} {name} targets {framework ?? "unknown"}, expected .NETCoreApp,Version=v10.0"); + + var references = reader.AssemblyReferences + .Select(handle => reader.GetAssemblyReference(handle)) + .Select(reference => new AssemblyReferenceInfo(reader.GetString(reference.Name), reference.Version)) + .ToArray(); + var harmonyReference = references.FirstOrDefault(reference => reference.Name == "0Harmony"); + if (harmonyReference?.Version.Major != 3) + throw new InvalidDataException($"{role} must reference Harmony major 3; got {harmonyReference?.Version.ToString() ?? "no 0Harmony AssemblyRef"}."); + var forbidden = references.FirstOrDefault(reference => + reference.Name.Contains("Prepatcher", StringComparison.OrdinalIgnoreCase) + || reference.Name.Contains("Mono.Cecil", StringComparison.OrdinalIgnoreCase)); + if (forbidden is not null) + throw new InvalidDataException($"{role} has forbidden AssemblyRef {forbidden.Name}, Version={forbidden.Version}"); + + foreach (var handle in reader.TypeReferences) + { + var type = reader.GetTypeReference(handle); + var ns = reader.GetString(type.Namespace); + var typeName = reader.GetString(type.Name); + if (ns.StartsWith("Prepatcher", StringComparison.Ordinal) + || ns.StartsWith("Mono.Cecil", StringComparison.Ordinal) + || typeName.Contains("ISyncMethodForPrepatcher", StringComparison.Ordinal)) + throw new InvalidDataException($"{role} contains forbidden TypeRef {ns}.{typeName}"); + } + + assemblies.Add(role, new AssemblyMetadata(name, framework!, references)); + Console.WriteLine($"PASS {role}: {name}; target framework {framework}"); + foreach (var reference in references.Where(reference => + reference.Name is "System.Runtime" or "Assembly-CSharp" or "0Harmony")) + Console.WriteLine($" AssemblyRef {reference.Name}, Version={reference.Version}"); +} + +var clientReferences = assemblies["client"].References; +var runtimeReference = clientReferences.FirstOrDefault(reference => reference.Name == "System.Runtime"); +if (runtimeReference?.Version.Major != 10) + throw new InvalidDataException("Client does not reference System.Runtime 10."); +var gameReference = clientReferences.FirstOrDefault(reference => reference.Name == "Assembly-CSharp"); +if (gameReference is null || gameReference.Version.Major != 1 || gameReference.Version.Minor != 7) + throw new InvalidDataException("Client does not reference the RimWorld 1.7 Assembly-CSharp API."); +foreach (var directory in new[] { Path.Combine(root, "Assemblies"), Path.Combine(root, "AssembliesCustom") }) +foreach (var path in Directory.EnumerateFiles(directory, "*.dll")) +{ + var name = Path.GetFileName(path); + var lowered = name.ToLowerInvariant(); + if (name == "0Harmony.dll" || lowered.Contains("prepatcher") || lowered.Contains("mono.cecil")) + throw new InvalidDataException($"Unexpected bundled Harmony/Prepatcher assembly: {path}"); + if (name == "UnityStubs.dll" || name == "Assembly-CSharp.dll" || name == "Verse.dll" + || name.StartsWith("UnityEngine", StringComparison.Ordinal)) + throw new InvalidDataException($"Game reference DLL must not be packaged: {path}"); +} + +Console.WriteLine("PASS 1.7 AssemblyRef and TypeRef audit: .NET 10, RimWorld 1.7 API, external Harmony, no Prepatcher/Cecil/game DLLs."); +Console.WriteLine("Metadata-only audit; no game assembly was loaded or executed."); + +static string? ReadTargetFramework(MetadataReader reader) +{ + foreach (var handle in reader.GetAssemblyDefinition().GetCustomAttributes()) + { + var attribute = reader.GetCustomAttribute(handle); + var constructorType = GetAttributeType(reader, attribute.Constructor); + if (constructorType != ("System.Runtime.Versioning", "TargetFrameworkAttribute")) + continue; + + var value = reader.GetBlobReader(attribute.Value); + if (value.ReadUInt16() != 1) + throw new BadImageFormatException("Malformed TargetFrameworkAttribute custom-attribute blob."); + return value.ReadSerializedString(); + } + return null; +} + +static (string Namespace, string Name) GetAttributeType(MetadataReader reader, EntityHandle constructor) +{ + EntityHandle parent = constructor.Kind switch + { + HandleKind.MemberReference => reader.GetMemberReference((MemberReferenceHandle)constructor).Parent, + HandleKind.MethodDefinition => reader.GetMethodDefinition((MethodDefinitionHandle)constructor).GetDeclaringType(), + _ => default, + }; + + return parent.Kind switch + { + HandleKind.TypeReference => ReadTypeReference(reader, (TypeReferenceHandle)parent), + HandleKind.TypeDefinition => ReadTypeDefinition(reader, (TypeDefinitionHandle)parent), + _ => (string.Empty, string.Empty), + }; +} + +static (string Namespace, string Name) ReadTypeReference(MetadataReader reader, TypeReferenceHandle handle) +{ + var type = reader.GetTypeReference(handle); + return (reader.GetString(type.Namespace), reader.GetString(type.Name)); +} + +static (string Namespace, string Name) ReadTypeDefinition(MetadataReader reader, TypeDefinitionHandle handle) +{ + var type = reader.GetTypeDefinition(handle); + return (reader.GetString(type.Namespace), reader.GetString(type.Name)); +} + +sealed record AssemblyMetadata(string Name, string TargetFramework, AssemblyReferenceInfo[] References); +sealed record AssemblyReferenceInfo(string Name, Version Version); diff --git a/Source/RuntimeTests/MetadataAudit.csproj b/Source/RuntimeTests/MetadataAudit.csproj new file mode 100644 index 000000000..238f157e2 --- /dev/null +++ b/Source/RuntimeTests/MetadataAudit.csproj @@ -0,0 +1,8 @@ + + + Exe + net10.0 + enable + enable + + diff --git a/Source/RuntimeTests/README.md b/Source/RuntimeTests/README.md new file mode 100644 index 000000000..70c8913bf --- /dev/null +++ b/Source/RuntimeTests/README.md @@ -0,0 +1,51 @@ +# Version-specific ritual checks + +The 1.6 and 1.7 runtime paths differ, so these checks are deliberately separate. +They are standalone probes, not full gameplay or multiplayer-session tests. + +## RimWorld 1.6: Mono and Harmony 2 + +Build the 1.6 mod first. Then pass the actual 1.6 `Managed` directory, Prepatcher +`Assemblies` directory, and installed Harmony mod's 1.6 `Assemblies` directory: + +```sh +bash Source/RuntimeTests/run-ritual-probe.sh \ + "/path/to/RimWorld-1.6/Managed" \ + "/path/to/Prepatcher/Assemblies" \ + "/path/to/Harmony/1.6/Assemblies" +``` + +An optional fourth argument selects a mod root containing `1.6/Assemblies` and +`1.6/AssembliesCustom`; by default the repository root is used. The script uses +Docker's `mono:6.12` and the supplied Harmony 2 runtime. It does not call Multiplayer's +native Mono profiler and does not require `mono-2.0-bdwgc`. + +The probe loads the actual 1.6 game/mod/Prepatcher API assemblies and calls the +production `SyncRituals.Init()`. It asserts ten registrations (five normal and five +psychic), four role serializers, and that `SyncMethod` implements the Prepatcher +interface. It then calls the real `PrepatcherPrefix` directly for normal and psychic +widgets. A Harmony test prefix stubs only `SyncMethod.DoSync`'s result so it can verify +exact-type dispatch, one call per matching handler, continuation when synchronization +is inactive, and suppression when the simulated active sync succeeds. + +This invokes the prefix directly. It does not run Prepatcher's Cecil rewrite/injection, +load a full RimWorld session, or exercise real network serialization/transport. Output +is saved to `Source/RuntimeTests/bin/prepatcher-prefix.log`. + +## RimWorld 1.7: reference and metadata audit + +Run this against the versioned mod root after building or packaging the 1.7 variant: + +```sh +bash Source/RuntimeTests/audit-rimworld-1.7-reference.sh +# Or pass a packaged mod root: +bash Source/RuntimeTests/audit-rimworld-1.7-reference.sh "/path/to/Multiplayer" +``` + +The audit uses `System.Reflection.Metadata` through Docker's .NET 10 SDK to read the +assemblies' target-framework attributes and AssemblyRef/TypeRef tables without loading +or executing the mod or game assemblies. It checks .NET 10, the RimWorld 1.7 +`Assembly-CSharp` reference, Harmony's external AssemblyRef, and the absence of +Prepatcher/Cecil references and bundled Harmony/game DLLs. This is a metadata/reference +check; there is no live RimWorld 1.7 CoreCLR runtime available here, so it does not +establish successful in-game loading or host/join behavior. diff --git a/Source/RuntimeTests/RitualPrepatcherMonoProbe.cs b/Source/RuntimeTests/RitualPrepatcherMonoProbe.cs new file mode 100644 index 000000000..018c7ccc8 --- /dev/null +++ b/Source/RuntimeTests/RitualPrepatcherMonoProbe.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections; +using System.IO; +using System.Reflection; +using System.Runtime.Serialization; +using HarmonyLib; + +// Standalone managed probe for the RimWorld 1.6 Prepatcher registration path. +// This calls production Init and PrepatcherPrefix directly; it does not run Cecil rewriting. +public static class RitualPrepatcherMonoProbe +{ + static string mod, game, prepatcher, harmonyDir; + static bool simulatedDoSyncResult; + static int doSyncCalls; + static readonly Harmony fixtures = new Harmony("Tests.RitualPrepatcherMonoProbe"); + + public static int Main(string[] args) + { + if (args.Length != 4) + { + Console.Error.WriteLine("Usage: RitualPrepatcherMonoProbe "); + return 2; + } + + mod = args[0]; + game = args[1]; + prepatcher = args[2]; + harmonyDir = args[3]; + AppDomain.CurrentDomain.AssemblyResolve += Resolve; + + try + { + Run(); + return 0; + } + catch (Exception exception) + { + Console.Error.WriteLine("FAIL " + exception); + return 1; + } + } + + static void Run() + { + if (Type.GetType("Mono.Runtime") == null) + throw new Exception("This probe requires Mono."); + if (typeof(Harmony).Assembly.GetName().Version.Major != 2) + throw new Exception("RimWorld 1.6 probe requires Harmony 2; got " + typeof(Harmony).Assembly.GetName().Version); + + var gameAssembly = Assembly.LoadFrom(Path.Combine(game, "Assembly-CSharp.dll")); + var gameVersion = gameAssembly.GetName().Version; + if (gameVersion.Major != 1 || gameVersion.Minor != 6) + throw new Exception("Expected RimWorld 1.6 game assemblies; got " + gameVersion + "."); + var prepatcherApi = Assembly.LoadFrom(Path.Combine(prepatcher, "0PrepatcherAPI.dll")); + if (prepatcherApi.GetName().Name != "0PrepatcherAPI") + throw new Exception("The supplied Prepatcher API assembly has an unexpected identity."); + var loader = Assembly.LoadFrom(Path.Combine(mod, "1.6/Assemblies/MultiplayerLoader.dll")); + var client = Assembly.LoadFrom(Path.Combine(mod, "1.6/AssembliesCustom/Multiplayer.dll")); + Assembly.LoadFrom(Path.Combine(mod, "1.6/AssembliesCustom/MultiplayerCommon.dll")); + var prepatchInterface = loader.GetType("MultiplayerLoader.ISyncMethodForPrepatcher", true); + var syncMethodType = client.GetType("Multiplayer.Client.SyncMethod", true); + if (!prepatchInterface.IsAssignableFrom(syncMethodType)) + throw new Exception("1.6 SyncMethod does not implement ISyncMethodForPrepatcher."); + + Console.WriteLine("Mono runtime detected."); + Console.WriteLine("Harmony=" + typeof(Harmony).Assembly.GetName().Version + "; Prepatcher API=" + prepatcherApi.GetName().Version + "; Game=" + gameAssembly.GetName().Version); + + // Prevent Unity texture access while loading the production serializer registrations. + var unityCore = Assembly.LoadFrom(Path.Combine(game, "UnityEngine.CoreModule.dll")); + var loadTexture = unityCore.GetType("UnityEngine.Resources", true) + .GetMethod("Load", new[] { typeof(string), typeof(Type) }); + if (loadTexture == null) + throw new Exception("Could not find UnityEngine.Resources.Load(string, Type)."); + fixtures.Patch(loadTexture, prefix: new HarmonyMethod(typeof(RitualPrepatcherMonoProbe).GetMethod("SkipTexture"))); + + var rituals = loader.GetType("MultiplayerLoader.SyncRituals", true); + rituals.GetMethod("Init", BindingFlags.Public | BindingFlags.Static).Invoke(null, null); + var handlers = (IList)rituals.GetField("syncMethodsWithPrepatches", BindingFlags.NonPublic | BindingFlags.Static).GetValue(null); + if (handlers.Count != 10) + throw new Exception("Expected ten ritual registrations, got " + handlers.Count + "."); + + var normalWidget = gameAssembly.GetType("RimWorld.PawnRitualRoleSelectionWidget", true); + var psychicWidget = gameAssembly.GetType("RimWorld.PawnPsychicRitualRoleSelectionWidget", true); + var targetType = syncMethodType.GetProperty("TargetType"); + var normalCount = 0; + var psychicCount = 0; + var serializerCount = 0; + foreach (var handler in handlers) + { + if (!prepatchInterface.IsInstanceOfType(handler)) + throw new Exception("A registered handler does not implement ISyncMethodForPrepatcher."); + var widgetType = (Type)targetType.GetValue(handler, null); + if (widgetType == normalWidget) normalCount++; + else if (widgetType == psychicWidget) psychicCount++; + else throw new Exception("Unexpected ritual handler target " + widgetType + "."); + + var transformers = (Array)syncMethodType.GetField("argTransformers", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(handler); + if (transformers.Length > 1 && transformers.GetValue(1) != null) + serializerCount++; + } + if (normalCount != 5 || psychicCount != 5 || serializerCount != 4) + throw new Exception("Unexpected registration shape: normal=" + normalCount + ", psychic=" + psychicCount + ", role serializers=" + serializerCount + "."); + Console.WriteLine("PASS SyncRituals.Init: 10 handlers (5 normal, 5 psychic), four role serializers."); + + // Exercise the actual production prefix against its registered handlers. Stub only + // DoSync's return value, so no multiplayer state or network transport is required. + var doSync = syncMethodType.GetMethod("DoSync", BindingFlags.Public | BindingFlags.Instance); + fixtures.Patch(doSync, prefix: new HarmonyMethod(typeof(RitualPrepatcherMonoProbe).GetMethod("StubDoSync"))); + var prefix = rituals.GetMethod("PrepatcherPrefix", BindingFlags.Public | BindingFlags.Static); + var normal = FormatterServices.GetUninitializedObject(normalWidget); + var psychic = FormatterServices.GetUninitializedObject(psychicWidget); + + foreach (bool active in new[] { true, false }) + { + simulatedDoSyncResult = active; + AssertPrefix(prefix, 0, normal, !active, active ? "normal active" : "normal inactive"); + AssertPrefix(prefix, 1, psychic, !active, active ? "psychic active" : "psychic inactive"); + AssertPrefix(prefix, 0, psychic, true, active ? "psychic wrong-type active" : "psychic wrong-type inactive"); + AssertPrefix(prefix, 1, normal, true, active ? "normal wrong-type active" : "normal wrong-type inactive"); + } + + Console.WriteLine("PASS direct PrepatcherPrefix dispatch: normal and psychic each call DoSync exactly once when types match; wrong types continue without dispatch."); + } + + static void AssertPrefix(MethodInfo prefix, int index, object instance, bool expectedContinue, string label) + { + doSyncCalls = 0; + var result = (bool)prefix.Invoke(null, new object[] { index, instance, new object[0] }); + if (result != expectedContinue) + throw new Exception(label + ": expected continue=" + expectedContinue + ", got " + result + "."); + var expectedCalls = label.Contains("wrong-type") ? 0 : 1; + if (doSyncCalls != expectedCalls) + throw new Exception(label + ": expected DoSync calls=" + expectedCalls + ", got " + doSyncCalls + "."); + Console.WriteLine("PASS " + label + ": continue=" + result + ", DoSync calls=" + doSyncCalls + "."); + } + + public static bool SkipTexture() { return false; } + + public static bool StubDoSync(object __instance, ref bool __result) + { + doSyncCalls++; + __result = simulatedDoSyncResult; + return false; + } + + static Assembly Resolve(object sender, ResolveEventArgs args) + { + var name = new AssemblyName(args.Name).Name + ".dll"; + foreach (var folder in new[] { + harmonyDir, prepatcher, game, + Path.Combine(mod, "1.6/Assemblies"), Path.Combine(mod, "1.6/AssembliesCustom") + }) + { + var candidate = Path.Combine(folder, name); + if (File.Exists(candidate)) + return Assembly.LoadFrom(candidate); + } + return null; + } +} diff --git a/Source/RuntimeTests/audit-rimworld-1.7-reference.sh b/Source/RuntimeTests/audit-rimworld-1.7-reference.sh new file mode 100755 index 000000000..841009c25 --- /dev/null +++ b/Source/RuntimeTests/audit-rimworld-1.7-reference.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail +PROBE_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$PROBE_DIR/../.." && pwd)" +MOD_ROOT="${1:-$REPO_ROOT}" + +for file in \ + "$MOD_ROOT/1.7/AssembliesCustom/Multiplayer.dll" \ + "$MOD_ROOT/1.7/AssembliesCustom/MultiplayerCommon.dll" \ + "$MOD_ROOT/1.7/Assemblies/MultiplayerLoader.dll"; do + if [[ ! -f "$file" ]]; then + echo "Required 1.7 audit input is missing: $file" >&2 + exit 1 + fi +done + +docker run --rm \ + -e DOTNET_CLI_TELEMETRY_OPTOUT=1 \ + -e DOTNET_NOLOGO=1 \ + -v "$MOD_ROOT":/mod:ro \ + -v "$PROBE_DIR":/probe:ro \ + -w /tmp mcr.microsoft.com/dotnet/sdk:10.0 bash -lc ' + set -euo pipefail + mkdir -p /tmp/mp-reference-audit + cp /probe/MetadataAudit.csproj /probe/MetadataAudit.cs /tmp/mp-reference-audit/ + dotnet run --project /tmp/mp-reference-audit/MetadataAudit.csproj --configuration Release -- /mod/1.7 + ' diff --git a/Source/RuntimeTests/run-ritual-probe.sh b/Source/RuntimeTests/run-ritual-probe.sh new file mode 100755 index 000000000..e6b0ed26b --- /dev/null +++ b/Source/RuntimeTests/run-ritual-probe.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail +if [[ "$#" -lt 3 || "$#" -gt 4 ]]; then + echo "Usage: bash $0 [mod-root]" >&2 + exit 2 +fi + +PROBE_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$PROBE_DIR/../.." && pwd)" +GAME_MANAGED_DIR="$1" +PREPATCHER_DIR="$2" +HARMONY_DIR="$3" +MOD_ROOT="${4:-$REPO_ROOT}" + +for file in \ + "$GAME_MANAGED_DIR/Assembly-CSharp.dll" \ + "$GAME_MANAGED_DIR/UnityEngine.CoreModule.dll" \ + "$PREPATCHER_DIR/0PrepatcherAPI.dll" \ + "$HARMONY_DIR/0Harmony.dll" \ + "$MOD_ROOT/1.6/Assemblies/MultiplayerLoader.dll" \ + "$MOD_ROOT/1.6/AssembliesCustom/Multiplayer.dll" \ + "$MOD_ROOT/1.6/AssembliesCustom/MultiplayerCommon.dll"; do + if [[ ! -f "$file" ]]; then + echo "Required 1.6 probe input is missing: $file" >&2 + exit 1 + fi +done + +python3 - "$MOD_ROOT/1.6/AssembliesCustom/Multiplayer.dll" <<'PY' +from pathlib import Path +import sys + +data = Path(sys.argv[1]).read_bytes() +if b".NETFramework,Version=v4.8" not in data or b".NETCoreApp,Version=v10.0" in data: + raise SystemExit("The 1.6 probe requires a net48 Multiplayer.dll; build with RimWorldVersion=1.6.") +PY + +mkdir -p "$PROBE_DIR/bin" +rm -f "$PROBE_DIR/bin/prepatcher-prefix.log" +docker run --rm \ + -v "$MOD_ROOT":/mod:ro \ + -v "$GAME_MANAGED_DIR":/game:ro \ + -v "$PREPATCHER_DIR":/prepatcher:ro \ + -v "$HARMONY_DIR":/harmony:ro \ + -v "$PROBE_DIR":/probe \ + -w /probe mono:6.12 bash -lc ' + set -euo pipefail + mcs -r:/harmony/0Harmony.dll -out:bin/RitualPrepatcherMonoProbe.exe RitualPrepatcherMonoProbe.cs + export MONO_PATH=/harmony:/prepatcher:/mod/1.6/Assemblies:/mod/1.6/AssembliesCustom + if ! mono --debug bin/RitualPrepatcherMonoProbe.exe /mod /game /prepatcher /harmony > bin/prepatcher-prefix.log 2>&1; then + cat bin/prepatcher-prefix.log + exit 1 + fi + cat bin/prepatcher-prefix.log + ' diff --git a/Source/Server/Server.csproj b/Source/Server/Server.csproj index 2a3b83e2e..76cd730b1 100644 --- a/Source/Server/Server.csproj +++ b/Source/Server/Server.csproj @@ -2,7 +2,7 @@ Exe - net8.0 + $(RimWorldTestTargetFramework) enable enable 12 diff --git a/Source/Tests/Tests.csproj b/Source/Tests/Tests.csproj index 7242ba550..456e53fa0 100644 --- a/Source/Tests/Tests.csproj +++ b/Source/Tests/Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + $(RimWorldTestTargetFramework) enable enable 12 diff --git a/Source/TestsOnMono/README.md b/Source/TestsOnMono/README.md index 3f890daaf..89ac19e1c 100644 --- a/Source/TestsOnMono/README.md +++ b/Source/TestsOnMono/README.md @@ -7,6 +7,9 @@ Build it explicitly with the .NET 10 SDK: dotnet build Source/TestsOnMono/TestsOnMono.csproj -c Release -p:RimWorldVersion=1.6 ``` -This uses Harmony 2.4.2, matching Prepatcher on RimWorld 1.6. +This uses Harmony 2.4.2, matching the 1.6 runtime dependency. It checks Harmony behavior +under Mono; it does not replace an in-game Prepatcher load test. Run the resulting `TestsOnMono.exe` under Windows Mono. + +For the portable ritual patch probe, see [RuntimeTests](../RuntimeTests/README.md). diff --git a/Source/TestsOnMono/TestsOnMono.csproj b/Source/TestsOnMono/TestsOnMono.csproj index 3e5193e80..187ca8c74 100644 --- a/Source/TestsOnMono/TestsOnMono.csproj +++ b/Source/TestsOnMono/TestsOnMono.csproj @@ -10,7 +10,7 @@ - + From cad0a8b61ef6885cf0340cda94180284b748acc3 Mon Sep 17 00:00:00 2001 From: roman_gr Date: Tue, 27 Oct 2026 20:38:30 +0100 Subject: [PATCH 11/19] Resolve reflection-only Unity dependencies on RimWorld 1.6 --- .../Multiplayer.ReflectionOnlyUnity.cs | 10 ++ .../ReflectionOnlyUnityResolver.cs | 54 +++++++++++ .../Tests/ReflectionOnlyUnityResolverTest.cs | 91 +++++++++++++++++++ Source/Directory.Build.targets | 4 +- Source/MultiplayerLoader/MultiplayerLoader.cs | 2 + Source/RuntimeTests/README.md | 14 +++ .../ReflectionOnlyUnityFixture.cs | 5 + .../RuntimeTests/ReflectionOnlyUnityProbe.cs | 55 +++++++++++ .../RuntimeTests/run-reflection-only-probe.sh | 29 ++++++ Source/Tests/Tests.csproj | 1 + 10 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 Source/1.6/MultiplayerLoader/Multiplayer.ReflectionOnlyUnity.cs create mode 100644 Source/1.6/MultiplayerLoader/ReflectionOnlyUnityResolver.cs create mode 100644 Source/1.6/Tests/ReflectionOnlyUnityResolverTest.cs create mode 100644 Source/RuntimeTests/ReflectionOnlyUnityFixture.cs create mode 100644 Source/RuntimeTests/ReflectionOnlyUnityProbe.cs create mode 100644 Source/RuntimeTests/run-reflection-only-probe.sh diff --git a/Source/1.6/MultiplayerLoader/Multiplayer.ReflectionOnlyUnity.cs b/Source/1.6/MultiplayerLoader/Multiplayer.ReflectionOnlyUnity.cs new file mode 100644 index 000000000..9ad563eb7 --- /dev/null +++ b/Source/1.6/MultiplayerLoader/Multiplayer.ReflectionOnlyUnity.cs @@ -0,0 +1,10 @@ +using System.IO; +using UnityEngine; + +namespace MultiplayerLoader; + +public partial class Multiplayer +{ + partial void InitializeVersionRuntime() => + ReflectionOnlyUnityResolver.Install(Path.GetDirectoryName(typeof(Application).Assembly.Location)); +} diff --git a/Source/1.6/MultiplayerLoader/ReflectionOnlyUnityResolver.cs b/Source/1.6/MultiplayerLoader/ReflectionOnlyUnityResolver.cs new file mode 100644 index 000000000..99b5b4eaa --- /dev/null +++ b/Source/1.6/MultiplayerLoader/ReflectionOnlyUnityResolver.cs @@ -0,0 +1,54 @@ +#nullable enable +using System; +using System.IO; +using System.Reflection; + +namespace MultiplayerLoader; + +internal sealed class ReflectionOnlyUnityResolver +{ + private static bool installed; + private readonly string managedDirectory; + + internal ReflectionOnlyUnityResolver(string managedDirectory) + { + this.managedDirectory = managedDirectory; + } + + internal static void Install(string managedDirectory) + { + if (installed) + return; + + var resolver = new ReflectionOnlyUnityResolver(managedDirectory); + AppDomain.CurrentDomain.ReflectionOnlyAssemblyResolve += resolver.Resolve; + installed = true; + } + + internal Assembly? Resolve(object? sender, ResolveEventArgs args) + { + var requested = new AssemblyName(args.Name); + var name = requested.Name; + if (name == null || (name != "UnityEngine" && !name.StartsWith("UnityEngine.", StringComparison.Ordinal))) + return null; + + // Prepatcher leaves retired assemblies in Mono's reflection-only context. + // Unity's later scene cleanup may inspect types whose input-module dependency + // was never bound there. Reuse the normal Unity type identity, not a second copy. + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + if (!assembly.ReflectionOnly && Matches(requested, assembly.GetName())) + return assembly; + + if (Path.GetFileName(name) != name) + return null; + + var path = Path.Combine(managedDirectory, name + ".dll"); + if (!File.Exists(path) || !Matches(requested, AssemblyName.GetAssemblyName(path))) + return null; + + return Assembly.LoadFrom(path); + } + + private static bool Matches(AssemblyName requested, AssemblyName candidate) => + string.Equals(requested.FullName, candidate.FullName, StringComparison.OrdinalIgnoreCase); +} diff --git a/Source/1.6/Tests/ReflectionOnlyUnityResolverTest.cs b/Source/1.6/Tests/ReflectionOnlyUnityResolverTest.cs new file mode 100644 index 000000000..fbf640a8a --- /dev/null +++ b/Source/1.6/Tests/ReflectionOnlyUnityResolverTest.cs @@ -0,0 +1,91 @@ +using System.Reflection; +using System.Reflection.Emit; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using MultiplayerLoader; + +namespace Tests; + +[NonParallelizable] +public class ReflectionOnlyUnityResolverTest +{ + [Test] + public void ReusesAnExistingUnityAssembly() + { + var assembly = AssemblyBuilder.DefineDynamicAssembly( + new AssemblyName("UnityEngine.Existing" + Guid.NewGuid().ToString("N")) { Version = new Version(1, 0) }, + AssemblyBuilderAccess.Run); + var resolver = new ReflectionOnlyUnityResolver(Path.GetTempPath()); + // CoreCLR exposes a runtime wrapper rather than the AssemblyBuilder itself. + var loaded = AppDomain.CurrentDomain.GetAssemblies().Single(candidate => candidate.FullName == assembly.FullName); + + Assert.That(resolver.Resolve(null, new ResolveEventArgs(assembly.FullName!)), Is.SameAs(loaded)); + } + + [Test] + public void LeavesNonUnityAssembliesToTheirOwnResolver() + { + var resolver = new ReflectionOnlyUnityResolver(Path.GetTempPath()); + + Assert.That(resolver.Resolve(null, new ResolveEventArgs(typeof(object).Assembly.FullName!)), Is.Null); + Assert.That(resolver.Resolve(null, new ResolveEventArgs("Unknown.Mod, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null")), Is.Null); + } + + [Test] + public void MissingUnityAssemblyIsNotSubstituted() + { + var resolver = new ReflectionOnlyUnityResolver(Path.GetTempPath()); + + Assert.That(resolver.Resolve(null, new ResolveEventArgs( + "UnityEngine.Missing" + Guid.NewGuid().ToString("N") + ", Version=1.0.0.0, Culture=neutral, PublicKeyToken=null")), Is.Null); + } + + [Test] + public void LoadsALateUnityDependencyFromTheManagedDirectory() + { + using var fixture = new UnityAssemblyFixture(); + var resolver = new ReflectionOnlyUnityResolver(fixture.Directory); + + var result = resolver.Resolve(null, new ResolveEventArgs(fixture.Identity.FullName)); + + Assert.That(result, Is.Not.Null); + Assert.That(result!.GetName().FullName, Is.EqualTo(fixture.Identity.FullName)); + Assert.That(result.ReflectionOnly, Is.False); + Assert.That(resolver.Resolve(null, new ResolveEventArgs(fixture.Identity.FullName)), Is.SameAs(result)); + } + + [Test] + public void RejectsAnAssemblyWithADifferentVersion() + { + using var fixture = new UnityAssemblyFixture(); + var resolver = new ReflectionOnlyUnityResolver(fixture.Directory); + var requested = new AssemblyName(fixture.Identity.FullName) { Version = new Version(9, 0) }; + + Assert.That(resolver.Resolve(null, new ResolveEventArgs(requested.FullName)), Is.Null); + } + + private sealed class UnityAssemblyFixture : IDisposable + { + public string Directory { get; } = Path.Combine(Path.GetTempPath(), "mp-refonly-" + Guid.NewGuid().ToString("N")); + public AssemblyName Identity { get; } + + public UnityAssemblyFixture() + { + System.IO.Directory.CreateDirectory(Directory); + var name = "UnityEngine.Late" + Guid.NewGuid().ToString("N"); + var tree = CSharpSyntaxTree.ParseText("[assembly: System.Reflection.AssemblyVersion(\"1.0.0.0\")] public class FixtureType { }"); + var compilation = CSharpCompilation.Create(name, [tree], + [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var file = Path.Combine(Directory, name + ".dll"); + using (var output = File.Create(file)) + { + var result = compilation.Emit(output); + Assert.That(result.Success, Is.True, string.Join(Environment.NewLine, result.Diagnostics)); + } + Identity = AssemblyName.GetAssemblyName(file); + } + + public void Dispose() => System.IO.Directory.Delete(Directory, recursive: true); + } +} diff --git a/Source/Directory.Build.targets b/Source/Directory.Build.targets index c2102902f..fc3e1f6c2 100644 --- a/Source/Directory.Build.targets +++ b/Source/Directory.Build.targets @@ -3,8 +3,10 @@ <_RimWorldVersionSourceDirectory Condition="'$(MSBuildProjectName)' == 'Multiplayer'">Client <_RimWorldVersionSourceDirectory Condition="'$(MSBuildProjectName)' == 'MultiplayerLoader'">MultiplayerLoader + <_RimWorldVersionSourceDirectory Condition="'$(MSBuildProjectName)' == 'Tests'">Tests <_RimWorldVersionSourcePath Condition="'$(_RimWorldVersionSourceDirectory)' != ''">$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)$(RimWorldVersion)/$(_RimWorldVersionSourceDirectory)'))/ - $(PathMap),$(_RimWorldVersionSourcePath)=Versions/$(RimWorldVersion)/$(_RimWorldVersionSourceDirectory)/ + <_RimWorldPathMapPrefix Condition="'$(PathMap)' != ''">$(PathMap), + $(_RimWorldPathMapPrefix)$(_RimWorldVersionSourcePath)=Versions/$(RimWorldVersion)/$(_RimWorldVersionSourceDirectory)/ diff --git a/Source/MultiplayerLoader/MultiplayerLoader.cs b/Source/MultiplayerLoader/MultiplayerLoader.cs index ab80eb29f..84e2eb211 100644 --- a/Source/MultiplayerLoader/MultiplayerLoader.cs +++ b/Source/MultiplayerLoader/MultiplayerLoader.cs @@ -17,6 +17,7 @@ public partial class Multiplayer : Mod public Multiplayer(ModContentPack content) : base(content) { CheckHarmonyVersion(); + InitializeVersionRuntime(); instance = this; LoadAssembliesCustom(); @@ -25,6 +26,7 @@ public Multiplayer(ModContentPack content) : base(content) } partial void CheckHarmonyVersion(); + partial void InitializeVersionRuntime(); public static Type? FindTypeInAppDomain(string typeFullName) => AppDomain.CurrentDomain.GetAssemblies() diff --git a/Source/RuntimeTests/README.md b/Source/RuntimeTests/README.md index 70c8913bf..863d9b19d 100644 --- a/Source/RuntimeTests/README.md +++ b/Source/RuntimeTests/README.md @@ -49,3 +49,17 @@ or executing the mod or game assemblies. It checks .NET 10, the RimWorld 1.7 Prepatcher/Cecil references and bundled Harmony/game DLLs. This is a metadata/reference check; there is no live RimWorld 1.7 CoreCLR runtime available here, so it does not establish successful in-game loading or host/join behavior. + +## RimWorld 1.6: late reflection-only Unity dependencies + +```sh +bash Source/RuntimeTests/run-reflection-only-probe.sh "/path/to/RimWorld-1.6/Managed" +``` + +An optional second argument selects a mod root with the updated 1.6 loader. This +Mono probe reproduces the missing `UnityEngine.InputLegacyModule` metadata lookup +with a null reflection-only resolver, then verifies Multiplayer's scoped resolver +both reuses an already loaded Unity assembly and resolves a late dependency from +that game's Managed directory. It uses the real Unity input DLL without running +Unity methods. No Prepatcher files are changed; full live scene cleanup is not +simulated. Logs are written to `bin/reflection-only-*.log`. diff --git a/Source/RuntimeTests/ReflectionOnlyUnityFixture.cs b/Source/RuntimeTests/ReflectionOnlyUnityFixture.cs new file mode 100644 index 000000000..79a52378d --- /dev/null +++ b/Source/RuntimeTests/ReflectionOnlyUnityFixture.cs @@ -0,0 +1,5 @@ +// Compiled only by run-reflection-only-probe.sh against the actual game DLLs. +public class ReflectionOnlyInputSignature +{ + public UnityEngine.Touch ReadTouch() { return default(UnityEngine.Touch); } +} diff --git a/Source/RuntimeTests/ReflectionOnlyUnityProbe.cs b/Source/RuntimeTests/ReflectionOnlyUnityProbe.cs new file mode 100644 index 000000000..7d65d078b --- /dev/null +++ b/Source/RuntimeTests/ReflectionOnlyUnityProbe.cs @@ -0,0 +1,55 @@ +using System; +using System.IO; +using System.Reflection; + +public static class ReflectionOnlyUnityProbe +{ + public static int Main(string[] args) + { + string loaderPath = args[0], game = args[1], mode = args[2]; + try + { + Assembly existing = null; + if (mode == "existing") + existing = Assembly.LoadFrom(Path.Combine(game, "UnityEngine.InputLegacyModule.dll")); + + // Match the stock preloader's handler: it observes the request but returns null. + AppDomain.CurrentDomain.ReflectionOnlyAssemblyResolve += (sender, eventArgs) => null; + if (mode != "baseline") + { + var loader = Assembly.LoadFrom(loaderPath); + var resolver = loader.GetType("MultiplayerLoader.ReflectionOnlyUnityResolver", true); + resolver.GetMethod("Install", BindingFlags.NonPublic | BindingFlags.Static) + .Invoke(null, new object[] { game }); + } + + var fixture = Assembly.ReflectionOnlyLoadFrom("bin/ReflectionOnlyUnityFixture.dll"); + var type = fixture.GetType("ReflectionOnlyInputSignature").GetMethod("ReadTouch").ReturnType; + if (mode == "baseline") + throw new Exception("Baseline unexpectedly resolved the dependency"); + if (type.FullName != "UnityEngine.Touch" || type.Assembly.ReflectionOnly) + throw new Exception("Expected Unity's normal execution type; got " + type); + if (existing != null && !ReferenceEquals(existing, type.Assembly)) + throw new Exception("Existing Unity assembly identity was not reused"); + if (!Path.GetFullPath(type.Assembly.Location).Equals(Path.GetFullPath(Path.Combine(game, "UnityEngine.InputLegacyModule.dll")))) + throw new Exception("Unity assembly was loaded from an unexpected location"); + Console.WriteLine("PASS " + mode + ": resolved UnityEngine.Touch through the production Multiplayer resolver; reflectionOnly=False"); + return 0; + } + catch (FileNotFoundException exception) + { + if (mode == "baseline" && exception.FileName.StartsWith("UnityEngine.InputLegacyModule,")) + { + Console.WriteLine("PASS baseline: reproduced the original reflection-only UnityEngine.InputLegacyModule failure"); + return 0; + } + Console.WriteLine("FAIL " + exception); + return 1; + } + catch (Exception exception) + { + Console.WriteLine("FAIL " + exception); + return 1; + } + } +} diff --git a/Source/RuntimeTests/run-reflection-only-probe.sh b/Source/RuntimeTests/run-reflection-only-probe.sh new file mode 100644 index 000000000..d5fc0ba38 --- /dev/null +++ b/Source/RuntimeTests/run-reflection-only-probe.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail +if [[ "$#" -lt 1 || "$#" -gt 2 ]]; then + echo "Usage: bash $0 [mod-root]" >&2 + exit 2 +fi +PROBE_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +MOD_ROOT="${2:-$(cd -- "$PROBE_DIR/../.." && pwd)}" +GAME_MANAGED_DIR="$1" +for file in "$GAME_MANAGED_DIR/UnityEngine.InputLegacyModule.dll" "$GAME_MANAGED_DIR/netstandard.dll" "$MOD_ROOT/1.6/Assemblies/MultiplayerLoader.dll"; do + [[ -f "$file" ]] || { echo "Missing probe input: $file" >&2; exit 1; } +done +mkdir -p "$PROBE_DIR/bin" +docker run --rm \ + -v "$MOD_ROOT":/mod:ro \ + -v "$GAME_MANAGED_DIR":/game:ro \ + -v "$PROBE_DIR":/probe -w /probe mono:6.12 bash -lc ' + set -euo pipefail + mcs -target:library -r:/game/netstandard.dll -r:/game/UnityEngine.CoreModule.dll \ + -r:/game/UnityEngine.InputLegacyModule.dll -out:bin/ReflectionOnlyUnityFixture.dll ReflectionOnlyUnityFixture.cs + mcs -out:bin/ReflectionOnlyUnityProbe.exe ReflectionOnlyUnityProbe.cs + for mode in baseline existing late; do + if ! mono bin/ReflectionOnlyUnityProbe.exe /mod/1.6/Assemblies/MultiplayerLoader.dll /game "$mode" > "bin/reflection-only-$mode.log" 2>&1; then + cat "bin/reflection-only-$mode.log" + exit 1 + fi + cat "bin/reflection-only-$mode.log" + done + ' diff --git a/Source/Tests/Tests.csproj b/Source/Tests/Tests.csproj index 456e53fa0..e8ab3ba38 100644 --- a/Source/Tests/Tests.csproj +++ b/Source/Tests/Tests.csproj @@ -24,6 +24,7 @@ + From d938e4066e479ecb4fd4f7ca8a6e870e5c14eb1a Mon Sep 17 00:00:00 2001 From: roman_gr Date: Wed, 28 Oct 2026 00:22:17 +0100 Subject: [PATCH 12/19] Build versioned mod and server artifacts for pull requests --- .github/scripts/stage-multiplayer-beta.sh | 12 +- .github/workflows/pr-check.yml | 164 +++++++++++++++++++--- README.md | 7 + 3 files changed, 159 insertions(+), 24 deletions(-) diff --git a/.github/scripts/stage-multiplayer-beta.sh b/.github/scripts/stage-multiplayer-beta.sh index a3985e0e9..59a082f39 100755 --- a/.github/scripts/stage-multiplayer-beta.sh +++ b/.github/scripts/stage-multiplayer-beta.sh @@ -3,6 +3,7 @@ set -euo pipefail repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" destination="${1:-output/Multiplayer}" +display_name="${2:-Multiplayer [Continuous]}" if [[ "$destination" != /* ]]; then destination="$repo_root/$destination" fi @@ -12,8 +13,15 @@ rm -rf "$destination" mkdir -p "$destination" cp -R "$repo_root/About" "$repo_root/Defs" "$repo_root/Languages" "$repo_root/Textures" "$destination" cp "$repo_root/LoadFolders.xml" "$destination/LoadFolders.xml" -rm -f "$destination/Languages/.git" -sed -i 's#Multiplayer#Multiplayer [Continuous]#' "$destination/About/About.xml" +rm -rf "$destination/Languages/.git" +python3 - "$destination/About/About.xml" "$display_name" <<'PYTHON' +import sys +import xml.etree.ElementTree as ET + +metadata = ET.parse(sys.argv[1]) +metadata.getroot().find('name').text = sys.argv[2] +metadata.write(sys.argv[1], encoding='utf-8', xml_declaration=True) +PYTHON for version in 1.6 1.7; do build="$repo_root/Source/Client/bin/$version/Release" diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index aa1f99f99..1293c21cb 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -2,46 +2,166 @@ name: Validate Pull Request +on: pull_request + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: - SLN_PATH: Source/ DOTNET_ROLL_FORWARD: Major - -on: - pull_request + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true jobs: builds: name: Builds runs-on: ubuntu-latest + timeout-minutes: 15 strategy: + fail-fast: false matrix: rimworld: ['1.6', '1.7'] steps: - - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Both game variants need SDK 10 for the source generator. No SDK matrix is needed. - name: Set up .NET - uses: actions/setup-dotnet@v4 + id: dotnet + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - dotnet-version: 10.x + dotnet-version: 10.0.x + cache: true + cache-dependency-path: | + Source/**/*.csproj + Source/Directory.Build.props + Source/Directory.Build.targets + + # Hosted runners can contain newer SDKs; use the exact SDK selected above. + - name: Pin resolved SDK env: - DOTNET_NOLOGO: true - DOTNET_CLI_TELEMETRY_OPTOUT: true - + SDK_VERSION: ${{ steps.dotnet.outputs.dotnet-version }} + run: | + printf '{"sdk":{"version":"%s","rollForward":"latestPatch"}}\n' "$SDK_VERSION" > global.json + dotnet --version + - name: Restore dependencies - run: dotnet restore Source/Client/Multiplayer.csproj -p:RimWorldVersion=${{ matrix.rimworld }} + run: dotnet restore Source/Multiplayer.sln -p:RimWorldVersion=${{ matrix.rimworld }} - name: Build debug - run: | - dotnet build Source/Client/Multiplayer.csproj -c Debug --no-restore -p:RimWorldVersion=${{ matrix.rimworld }} - dotnet build Source/Server/Server.csproj -c Debug -p:RimWorldVersion=${{ matrix.rimworld }} - dotnet build Source/Tests/Tests.csproj -c Debug -p:RimWorldVersion=${{ matrix.rimworld }} - + run: dotnet build Source/Multiplayer.sln -c Debug --no-restore -p:RimWorldVersion=${{ matrix.rimworld }} + - name: Build release - run: | - dotnet build Source/Client/Multiplayer.csproj -c Release --no-restore -p:RimWorldVersion=${{ matrix.rimworld }} - dotnet build Source/Server/Server.csproj -c Release -p:RimWorldVersion=${{ matrix.rimworld }} - dotnet build Source/Tests/Tests.csproj -c Release -p:RimWorldVersion=${{ matrix.rimworld }} + run: dotnet build Source/Multiplayer.sln -c Release --no-restore -p:RimWorldVersion=${{ matrix.rimworld }} - name: Run tests - run: dotnet test Source/Tests/Tests.csproj -c Release --no-build --no-restore -p:RimWorldVersion=${{ matrix.rimworld }} + run: >- + dotnet test Source/Tests/Tests.csproj -c Release --no-build --no-restore + -p:RimWorldVersion=${{ matrix.rimworld }} + --logger "trx;LogFileName=TestResults.trx" + --results-directory artifacts/test-results + + - name: Upload test results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: test-results-rimworld-${{ matrix.rimworld }} + path: artifacts/test-results/ + retention-days: 7 + if-no-files-found: warn + overwrite: true + + # Independent of test results so a buildable PR can still supply a reproduction package. + package-artifacts: + name: Package mod and server artifacts + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + submodules: recursive + + - name: Compute short commit SHA + id: vars + run: echo "short_sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + + - name: Set up .NET + id: dotnet + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 10.0.x + cache: true + cache-dependency-path: | + Source/**/*.csproj + Source/Directory.Build.props + Source/Directory.Build.targets + + - name: Pin resolved SDK + env: + SDK_VERSION: ${{ steps.dotnet.outputs.dotnet-version }} + run: | + printf '{"sdk":{"version":"%s","rollForward":"latestPatch"}}\n' "$SDK_VERSION" > global.json + dotnet --version + + - name: Build mod variants (Release) + run: | + for version in 1.6 1.7; do + dotnet restore Source/Client/Multiplayer.csproj -p:RimWorldVersion="$version" + dotnet build Source/Client/Multiplayer.csproj -c Release --no-restore -p:RimWorldVersion="$version" + done + + # Keep the existing 1.6 server paths; put the .NET 10 / 1.7 servers in their own folder. + - name: Publish servers (Windows) + run: | + dotnet publish Source/Server/Server.csproj -c Release --runtime win-x64 --self-contained false -p:UseAppHost=true -p:RimWorldVersion=1.6 -o artifacts/server/Server/Windows + dotnet publish Source/Server/Server.csproj -c Release --runtime win-x64 --self-contained false -p:UseAppHost=true -p:RimWorldVersion=1.7 -o artifacts/server/Server/1.7/Windows + + - name: Publish servers (Linux) + run: | + dotnet publish Source/Server/Server.csproj -c Release --runtime linux-x64 --self-contained false -p:UseAppHost=true -p:RimWorldVersion=1.6 -o artifacts/server/Server/Linux + dotnet publish Source/Server/Server.csproj -c Release --runtime linux-x64 --self-contained false -p:UseAppHost=true -p:RimWorldVersion=1.7 -o artifacts/server/Server/1.7/Linux + + - name: Add server launchers + run: | + for directory in artifacts/server/Server/Linux artifacts/server/Server/1.7/Linux; do + cat > "$directory/Server.sh" <<'EOF' + #!/usr/bin/env bash + set -euo pipefail + SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + exec dotnet "$SCRIPT_DIR/Server.dll" "$@" + EOF + chmod +x "$directory/Server.sh" + done + + - name: Stage versioned mod + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + bash .github/scripts/stage-multiplayer-beta.sh artifacts/mod/Multiplayer "Multiplayer [PR #$PR_NUMBER]" + + # Upload the parent directories so downloaded ZIPs have Multiplayer/ or Server/ at root. + - name: Upload mod artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: Multiplayer-mod-pr${{ github.event.pull_request.number }}-${{ steps.vars.outputs.short_sha }} + path: artifacts/mod/ + retention-days: 7 + if-no-files-found: error + overwrite: true + + - name: Upload server artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: Multiplayer-server-pr${{ github.event.pull_request.number }}-${{ steps.vars.outputs.short_sha }} + path: artifacts/server/ + retention-days: 7 + if-no-files-found: error + overwrite: true diff --git a/README.md b/README.md index e174705d2..83b2a693c 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,13 @@ full game session. The 1.7 check reads .NET 10, RimWorld, and Harmony assembly m without running the game assemblies. Live 1.7 CoreCLR and host/join testing remain unavailable. +Pull requests build both game versions using the .NET 10 SDK. Download the +`Multiplayer-mod-pr-` and `Multiplayer-server-pr-` +artifacts from the **Validate Pull Request** Actions run; downloads are retained +for seven days. The mod artifact extracts to `Multiplayer/` with both version +folders. The server artifact includes Windows/Linux builds for both game versions, +with the .NET 10 / 1.7 servers under `Server/1.7/`. Test results are uploaded separately. + ## Donations If you’re feeling generous these are people who have contributed greatly to the mod’s development and upkeep. From 3c4604ac0b29a40da982881ec38f8a85213cb6f7 Mon Sep 17 00:00:00 2001 From: roman_gr Date: Wed, 28 Oct 2026 00:47:46 +0100 Subject: [PATCH 13/19] Add local runtime test runner and centralize .NET SDK setup --- .github/scripts/stage-multiplayer-beta.sh | 54 --------- .github/workflows/build-beta.yml | 30 ++++- .github/workflows/build-workshop.yml | 10 +- .github/workflows/pr-check.yml | 50 ++++---- README.md | 85 +++++++++++++- Source/Client/Multiplayer.csproj | 15 +++ Source/RuntimeTests/README.md | 33 +++++- .../audit-rimworld-1.7-reference.sh | 38 ++++++- .../RuntimeTests/run-reflection-only-probe.sh | 1 + Source/RuntimeTests/run-ritual-probe.sh | 1 + global.json | 7 ++ run-local-tests.sh | 107 ++++++++++++++++++ workshop_bundler.sh | 1 + 13 files changed, 329 insertions(+), 103 deletions(-) delete mode 100755 .github/scripts/stage-multiplayer-beta.sh create mode 100644 global.json create mode 100755 run-local-tests.sh diff --git a/.github/scripts/stage-multiplayer-beta.sh b/.github/scripts/stage-multiplayer-beta.sh deleted file mode 100755 index 59a082f39..000000000 --- a/.github/scripts/stage-multiplayer-beta.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" -destination="${1:-output/Multiplayer}" -display_name="${2:-Multiplayer [Continuous]}" -if [[ "$destination" != /* ]]; then - destination="$repo_root/$destination" -fi - -# Package only shared assets and the resolved outputs from each selector build. -rm -rf "$destination" -mkdir -p "$destination" -cp -R "$repo_root/About" "$repo_root/Defs" "$repo_root/Languages" "$repo_root/Textures" "$destination" -cp "$repo_root/LoadFolders.xml" "$destination/LoadFolders.xml" -rm -rf "$destination/Languages/.git" -python3 - "$destination/About/About.xml" "$display_name" <<'PYTHON' -import sys -import xml.etree.ElementTree as ET - -metadata = ET.parse(sys.argv[1]) -metadata.getroot().find('name').text = sys.argv[2] -metadata.write(sys.argv[1], encoding='utf-8', xml_declaration=True) -PYTHON - -for version in 1.6 1.7; do - build="$repo_root/Source/Client/bin/$version/Release" - [[ -f "$build/Multiplayer.dll" ]] || { echo "Missing $version Client output: $build/Multiplayer.dll" >&2; exit 1; } - [[ -f "$build/MultiplayerCommon.dll" ]] || { echo "Missing $version Common output: $build/MultiplayerCommon.dll" >&2; exit 1; } - - mkdir -p "$destination/$version/Assemblies" "$destination/$version/AssembliesCustom" - cp "$build/Multiplayer.dll" "$build/MultiplayerCommon.dll" "$destination/$version/AssembliesCustom/" - - found_loader=false - for dll in "$build"/*.dll; do - [[ -f "$dll" ]] || continue - name="${dll##*/}" - case "$name" in - Multiplayer.dll|MultiplayerCommon.dll|0Harmony.dll|0Prepatcher*.dll|Prepatcher*.dll|Mono.Cecil*.dll|UnityStubs.dll|UnityEngine*.dll|Assembly-CSharp*.dll|Verse.dll) - continue - ;; - esac - cp "$dll" "$destination/$version/Assemblies/" - [[ "$name" != MultiplayerLoader.dll ]] || found_loader=true - done - - [[ "$found_loader" == true ]] || { echo "Missing resolved MultiplayerLoader.dll for RimWorld $version" >&2; exit 1; } -done - -# Harmony and Prepatcher are supplied by the dependencies declared in About.xml. -if find "$destination/1.7" -type f \( -iname '*prepatcher*.dll' -o -iname '0Harmony.dll' -o -iname 'Mono.Cecil*.dll' \) -print -quit | grep -q .; then - echo "Unexpected Harmony or Prepatcher implementation in the 1.7 package" >&2 - exit 1 -fi diff --git a/.github/workflows/build-beta.yml b/.github/workflows/build-beta.yml index 49260352a..0d2d64d91 100644 --- a/.github/workflows/build-beta.yml +++ b/.github/workflows/build-beta.yml @@ -20,10 +20,12 @@ jobs: with: submodules: recursive - - name: Setup Dotnet - uses: actions/setup-dotnet@v4 + - name: Set up .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - dotnet-version: 10.x + global-json-file: global.json + cache: true + cache-dependency-path: '**/*.csproj' - name: Restore and build RimWorld 1.6 run: | @@ -50,6 +52,7 @@ jobs: for directory in output/Server/Linux output/Server/1.7/Linux; do cat > "$directory/Server.sh" <<'EOF' #!/usr/bin/env bash + # Launch the published server DLL beside this script with the given arguments. set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" exec dotnet "$SCRIPT_DIR/Server.dll" "$@" @@ -58,7 +61,26 @@ jobs: done - name: Stage versioned Multiplayer mod - run: bash .github/scripts/stage-multiplayer-beta.sh output/Multiplayer + run: | + destination=output/Multiplayer + mkdir -p "$destination" + cp -R About Defs Languages Textures LoadFolders.xml "$destination/" + rm -rf "$destination/Languages/.git" + python3 - "$destination/About/About.xml" <<'PYTHON' + import sys + import xml.etree.ElementTree as ET + + metadata = ET.parse(sys.argv[1]) + metadata.getroot().find('name').text = 'Multiplayer [Continuous]' + metadata.write(sys.argv[1], encoding='utf-8', xml_declaration=True) + PYTHON + + for version in 1.6 1.7; do + test -f "$version/Assemblies/MultiplayerLoader.dll" + test -f "$version/AssembliesCustom/Multiplayer.dll" + test -f "$version/AssembliesCustom/MultiplayerCommon.dll" + cp -R "$version" "$destination/" + done - name: Package files run: | diff --git a/.github/workflows/build-workshop.yml b/.github/workflows/build-workshop.yml index 1698600cb..15002a6a5 100644 --- a/.github/workflows/build-workshop.yml +++ b/.github/workflows/build-workshop.yml @@ -17,10 +17,12 @@ jobs: fetch-depth: 0 submodules: recursive # Updates the Languages submodule - - name: Setup Dotnet - uses: actions/setup-dotnet@v4 + - name: Set up .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - dotnet-version: 10.x + global-json-file: global.json + cache: true + cache-dependency-path: '**/*.csproj' - name: Run workshop bundler run: ./workshop_bundler.sh @@ -31,7 +33,7 @@ jobs: run: mv Multiplayer/ output/ - id: get_version - run: echo ::set-output name=VERSION::$(echo ${{ github.ref }} | cut -d / -f 3) # github.ref is refs/tags/ here + run: echo "VERSION=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT" - name: Upload mod artifacts uses: actions/upload-artifact@v4 diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 1293c21cb..eaf589eb3 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -31,25 +31,12 @@ jobs: with: persist-credentials: false - # Both game variants need SDK 10 for the source generator. No SDK matrix is needed. - name: Set up .NET - id: dotnet uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - dotnet-version: 10.0.x + global-json-file: global.json cache: true - cache-dependency-path: | - Source/**/*.csproj - Source/Directory.Build.props - Source/Directory.Build.targets - - # Hosted runners can contain newer SDKs; use the exact SDK selected above. - - name: Pin resolved SDK - env: - SDK_VERSION: ${{ steps.dotnet.outputs.dotnet-version }} - run: | - printf '{"sdk":{"version":"%s","rollForward":"latestPatch"}}\n' "$SDK_VERSION" > global.json - dotnet --version + cache-dependency-path: '**/*.csproj' - name: Restore dependencies run: dotnet restore Source/Multiplayer.sln -p:RimWorldVersion=${{ matrix.rimworld }} @@ -94,22 +81,11 @@ jobs: run: echo "short_sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" - name: Set up .NET - id: dotnet uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - dotnet-version: 10.0.x + global-json-file: global.json cache: true - cache-dependency-path: | - Source/**/*.csproj - Source/Directory.Build.props - Source/Directory.Build.targets - - - name: Pin resolved SDK - env: - SDK_VERSION: ${{ steps.dotnet.outputs.dotnet-version }} - run: | - printf '{"sdk":{"version":"%s","rollForward":"latestPatch"}}\n' "$SDK_VERSION" > global.json - dotnet --version + cache-dependency-path: '**/*.csproj' - name: Build mod variants (Release) run: | @@ -134,6 +110,7 @@ jobs: for directory in artifacts/server/Server/Linux artifacts/server/Server/1.7/Linux; do cat > "$directory/Server.sh" <<'EOF' #!/usr/bin/env bash + # Launch the published server DLL beside this script with the given arguments. set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" exec dotnet "$SCRIPT_DIR/Server.dll" "$@" @@ -141,11 +118,24 @@ jobs: chmod +x "$directory/Server.sh" done - - name: Stage versioned mod + - name: Package mod files env: PR_NUMBER: ${{ github.event.pull_request.number }} run: | - bash .github/scripts/stage-multiplayer-beta.sh artifacts/mod/Multiplayer "Multiplayer [PR #$PR_NUMBER]" + destination=artifacts/mod/Multiplayer + mkdir -p "$destination" + cp -R About Defs Languages Textures LoadFolders.xml "$destination/" + rm -rf "$destination/Languages/.git" + python3 - "$destination/About/About.xml" "$PR_NUMBER" <<'PYTHON' + import sys + import xml.etree.ElementTree as ET + + metadata = ET.parse(sys.argv[1]) + metadata.getroot().find('name').text = f'Multiplayer [PR #{sys.argv[2]}]' + metadata.write(sys.argv[1], encoding='utf-8', xml_declaration=True) + PYTHON + + cp -R 1.6 1.7 "$destination/" # Upload the parent directories so downloaded ZIPs have Multiplayer/ or Server/ at root. - name: Upload mod artifact diff --git a/README.md b/README.md index 83b2a693c..09e0a755b 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,12 @@ Please do all pull requests to the [dev](https://github.com/rwmt/Multiplayer/tre ## Building for RimWorld 1.6 and 1.7 -Use the .NET 10 SDK and select the matching game references with `RimWorldVersion`. -The selector defaults to `1.6`; only `1.6` and `1.7` are accepted. It separates -restore assets and outputs under version-specific `bin/` and `obj/` directories. +The root `global.json` sets SDK 10.0.302 as the minimum and allows roll-forward +to newer installed .NET 10 minor versions, feature bands, and patches. Select game +references with `RimWorldVersion`; the selector defaults to `1.6` and accepts +`1.6` or `1.7`. It separates restore assets and outputs under version-specific +`bin/` and `obj/` directories. The 1.6 test project targets .NET 8, so the test +commands use `DOTNET_ROLL_FORWARD=Major` separately to run it on the .NET 10 runtime. ```sh dotnet build Source/Multiplayer.sln -c Release -p:RimWorldVersion=1.6 @@ -46,15 +49,87 @@ Version-specific C# implementations live under `Source/1.6/Client/`, put shared code in the existing project folders and version-only code in the matching tree. +For example, a client implementation can keep the same namespace, class name, +and internal API in both version trees while the shared caller stays in +`Source/Client/`: + +```csharp +// Source/1.6/Client/RuntimeHooks.cs +namespace Multiplayer; +internal static class RuntimeHooks +{ + internal static bool RequiresPrepatcher => true; +} +``` + +```csharp +// Source/1.7/Client/RuntimeHooks.cs +namespace Multiplayer; +internal static class RuntimeHooks +{ + internal static bool RequiresPrepatcher => false; +} +``` + +```csharp +// Source/Client/RuntimeSetup.cs +namespace Multiplayer; +internal static class RuntimeSetup +{ + internal static void Configure() + { + if (RuntimeHooks.RequiresPrepatcher) InstallPrepatcherHooks(); + else InstallRuntimeHooks(); + } + + private static void InstallPrepatcherHooks() { /* shared setup */ } + private static void InstallRuntimeHooks() { /* shared setup */ } +} +``` + +`Source/Directory.Build.targets` maps the `Multiplayer` client project to +`Client/` and includes only `Source//Client/**/*.cs`. The same +mapping selects `MultiplayerLoader` from `MultiplayerLoader/` and `Tests` from +`Tests/`. No extra `.csproj` entries or preprocessor directives are needed; +keep the version-specific class out of shared `Source/Client/` so the selected +implementation is defined once. Build the client for either version with: + +```sh +dotnet build Source/Client/Multiplayer.csproj -c Release -p:RimWorldVersion=1.6 +dotnet build Source/Client/Multiplayer.csproj -c Release -p:RimWorldVersion=1.7 +``` + The workshop bundler builds both variants and retains the historical 1.5 package. +### Local runtime probes + +To run the assembly-dependent local probes for RimWorld 1.6 and 1.7, use +`run-local-tests.sh` with the installed 1.6 game `Managed` directory, Prepatcher +`Assemblies` directory, Harmony 1.6 `Assemblies` directory, and optionally a +mod root containing already-built `1.6/` and `1.7/` assemblies: + +```sh +./run-local-tests.sh \ + "/path/to/RimWorld-1.6/Managed" \ + "/path/to/Prepatcher/Assemblies" \ + "/path/to/Harmony/1.6/Assemblies" \ + "/path/to/Multiplayer" +``` + +The runner requires Docker and Python 3 and does not build or deploy the mod +DLLs. Build both variants before running, or pass a mod root containing both +built variants. It runs the 1.6 ritual-prefix and reflection-only Mono probes +and the 1.7 metadata audit. These do not launch RimWorld or verify a full +host/join session. Windows Mono `TestsOnMono.exe` remains a separate manual test; see +[TestsOnMono](Source/TestsOnMono/README.md). + The [version-specific ritual checks](Source/RuntimeTests/README.md) cover the 1.6 registrar and direct `PrepatcherPrefix` dispatch under Mono/Harmony 2, including normal and psychic handlers and serializer registration. The probe stubs `DoSync` and calls the prefix directly; it does not exercise Prepatcher's Cecil injection or a full game session. The 1.7 check reads .NET 10, RimWorld, and Harmony assembly metadata -without running the game assemblies. Live 1.7 CoreCLR and host/join testing remain -unavailable. +without running the game assemblies. Neither probe tests live RimWorld 1.7 CoreCLR +loading, host/join networking, or gameplay. Pull requests build both game versions using the .NET 10 SDK. Download the `Multiplayer-mod-pr-` and `Multiplayer-server-pr-` diff --git a/Source/Client/Multiplayer.csproj b/Source/Client/Multiplayer.csproj index 10a1bfb91..61b5cce79 100644 --- a/Source/Client/Multiplayer.csproj +++ b/Source/Client/Multiplayer.csproj @@ -60,6 +60,7 @@ bin\$(RimWorldVersion)\$(Configuration)\ + ..\..\ @@ -67,6 +68,20 @@ + + + + + + + + + + + + diff --git a/Source/RuntimeTests/README.md b/Source/RuntimeTests/README.md index 863d9b19d..18622705b 100644 --- a/Source/RuntimeTests/README.md +++ b/Source/RuntimeTests/README.md @@ -3,6 +3,28 @@ The 1.6 and 1.7 runtime paths differ, so these checks are deliberately separate. They are standalone probes, not full gameplay or multiplayer-session tests. +## Run all local runtime probes + +From the repository root, run: + +```sh +./run-local-tests.sh \ + "/path/to/RimWorld-1.6/Managed" \ + "/path/to/Prepatcher/Assemblies" \ + "/path/to/Harmony/1.6/Assemblies" \ + "/path/to/Multiplayer" +``` + +The optional final argument defaults to this repository. It must contain +already-built assemblies for both `1.6/` and `1.7/`; the runner does not build +or deploy them. It validates all required files before invoking Docker and +then runs the two 1.6 probes below and the 1.7 metadata audit. Paths may contain +spaces, and the runner can be invoked from another working directory. Docker +and Python 3 must be installed and available on `PATH`. + +This does not run RimWorld, exercise full host/join gameplay, or run the manual +Windows Mono `TestsOnMono.exe` described in [its README](../TestsOnMono/README.md). + ## RimWorld 1.6: Mono and Harmony 2 Build the 1.6 mod first. Then pass the actual 1.6 `Managed` directory, Prepatcher @@ -42,13 +64,14 @@ bash Source/RuntimeTests/audit-rimworld-1.7-reference.sh bash Source/RuntimeTests/audit-rimworld-1.7-reference.sh "/path/to/Multiplayer" ``` -The audit uses `System.Reflection.Metadata` through Docker's .NET 10 SDK to read the -assemblies' target-framework attributes and AssemblyRef/TypeRef tables without loading -or executing the mod or game assemblies. It checks .NET 10, the RimWorld 1.7 +The audit uses `System.Reflection.Metadata` in Docker with the SDK version selected by +the repository's root `global.json` to read the assemblies' target-framework +attributes and AssemblyRef/TypeRef tables without loading or executing the mod or +game assemblies. It checks the .NET 10 target, the RimWorld 1.7 `Assembly-CSharp` reference, Harmony's external AssemblyRef, and the absence of Prepatcher/Cecil references and bundled Harmony/game DLLs. This is a metadata/reference -check; there is no live RimWorld 1.7 CoreCLR runtime available here, so it does not -establish successful in-game loading or host/join behavior. +check only; it does not load the assemblies into a live RimWorld 1.7 CoreCLR process +or test in-game loading, host/join behavior, or gameplay. ## RimWorld 1.6: late reflection-only Unity dependencies diff --git a/Source/RuntimeTests/audit-rimworld-1.7-reference.sh b/Source/RuntimeTests/audit-rimworld-1.7-reference.sh index 841009c25..f74ca680e 100755 --- a/Source/RuntimeTests/audit-rimworld-1.7-reference.sh +++ b/Source/RuntimeTests/audit-rimworld-1.7-reference.sh @@ -1,8 +1,42 @@ #!/usr/bin/env bash +# Audit built RimWorld 1.7 assemblies for target framework and references. set -euo pipefail PROBE_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd -- "$PROBE_DIR/../.." && pwd)" MOD_ROOT="${1:-$REPO_ROOT}" +GLOBAL_JSON="$REPO_ROOT/global.json" + +if ! MOD_ROOT="$(cd -- "$MOD_ROOT" 2>/dev/null && pwd)"; then + echo "Mod root directory does not exist: ${1:-$REPO_ROOT}" >&2 + exit 1 +fi + +if ! command -v python3 >/dev/null 2>&1; then + echo "python3 is required to read the SDK version from $GLOBAL_JSON, but was not found in PATH." >&2 + exit 1 +fi + +SDK_VERSION="$(python3 - "$GLOBAL_JSON" <<'PY' +import json +import re +import sys + +path = sys.argv[1] +try: + with open(path, encoding="utf-8") as config_file: + config = json.load(config_file) +except OSError as exc: + raise SystemExit(f"Cannot read required SDK config {path}: {exc}") +except json.JSONDecodeError as exc: + raise SystemExit(f"Malformed SDK config {path}: {exc}") + +sdk = config.get("sdk") if isinstance(config, dict) else None +version = sdk.get("version") if isinstance(sdk, dict) else None +if not isinstance(version, str) or not re.fullmatch(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", version): + raise SystemExit(f"SDK config {path} must contain a valid sdk.version string") +print(version) +PY +)" for file in \ "$MOD_ROOT/1.7/AssembliesCustom/Multiplayer.dll" \ @@ -19,9 +53,11 @@ docker run --rm \ -e DOTNET_NOLOGO=1 \ -v "$MOD_ROOT":/mod:ro \ -v "$PROBE_DIR":/probe:ro \ - -w /tmp mcr.microsoft.com/dotnet/sdk:10.0 bash -lc ' + -v "$GLOBAL_JSON":/tmp/global.json:ro \ + -w /tmp "mcr.microsoft.com/dotnet/sdk:$SDK_VERSION" bash -lc ' set -euo pipefail mkdir -p /tmp/mp-reference-audit cp /probe/MetadataAudit.csproj /probe/MetadataAudit.cs /tmp/mp-reference-audit/ + dotnet --version dotnet run --project /tmp/mp-reference-audit/MetadataAudit.csproj --configuration Release -- /mod/1.7 ' diff --git a/Source/RuntimeTests/run-reflection-only-probe.sh b/Source/RuntimeTests/run-reflection-only-probe.sh index d5fc0ba38..03166f8c4 100644 --- a/Source/RuntimeTests/run-reflection-only-probe.sh +++ b/Source/RuntimeTests/run-reflection-only-probe.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# Probe 1.6 Unity reflection-only dependency resolution under Mono. set -euo pipefail if [[ "$#" -lt 1 || "$#" -gt 2 ]]; then echo "Usage: bash $0 [mod-root]" >&2 diff --git a/Source/RuntimeTests/run-ritual-probe.sh b/Source/RuntimeTests/run-ritual-probe.sh index e6b0ed26b..b847900ac 100755 --- a/Source/RuntimeTests/run-ritual-probe.sh +++ b/Source/RuntimeTests/run-ritual-probe.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# Probe 1.6 ritual registration and Prepatcher prefix dispatch under Mono. set -euo pipefail if [[ "$#" -lt 3 || "$#" -gt 4 ]]; then echo "Usage: bash $0 [mod-root]" >&2 diff --git a/global.json b/global.json new file mode 100644 index 000000000..0b82a988b --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.302", + "rollForward": "latestMinor", + "allowPrerelease": false + } +} diff --git a/run-local-tests.sh b/run-local-tests.sh new file mode 100755 index 000000000..36a6188d7 --- /dev/null +++ b/run-local-tests.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Run the local game-assembly-dependent probes for RimWorld 1.6 and 1.7. +set -euo pipefail + +usage() { + cat >&2 < [mod-root] + +Runs the 1.6 ritual-prefix and reflection-only Mono probes plus the 1.7 +assembly-metadata audit. All inputs must already exist; the mod root must +contain built 1.6/ and 1.7/ assemblies. Docker and Python 3 are required. + +Paths may be absolute or relative to the current directory. The default mod +root is this repository. Invoke from any working directory. +EOF + exit "${1:-2}" +} + +if [[ "$#" -eq 1 && ( "$1" == "--help" || "$1" == "-h" ) ]]; then + usage 0 +fi + +if [[ "$#" -lt 3 || "$#" -gt 4 ]]; then + usage +fi + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$SCRIPT_DIR" + +resolve_dir() { + local label="$1" path="$2" + if [[ ! -d "$path" ]]; then + echo "Required $label directory does not exist: $path" >&2 + exit 1 + fi + (cd -- "$path" && pwd) +} + +GAME_MANAGED_DIR="$(resolve_dir 'RimWorld 1.6 Managed' "$1")" +PREPATCHER_DIR="$(resolve_dir 'Prepatcher Assemblies' "$2")" +HARMONY_DIR="$(resolve_dir 'Harmony 1.6 Assemblies' "$3")" +MOD_ROOT="$(resolve_dir 'mod root' "${4:-$REPO_ROOT}")" + +required_files=( + "$GAME_MANAGED_DIR/Assembly-CSharp.dll" + "$GAME_MANAGED_DIR/UnityEngine.CoreModule.dll" + "$GAME_MANAGED_DIR/UnityEngine.InputLegacyModule.dll" + "$GAME_MANAGED_DIR/netstandard.dll" + "$PREPATCHER_DIR/0PrepatcherAPI.dll" + "$HARMONY_DIR/0Harmony.dll" + "$MOD_ROOT/1.6/Assemblies/MultiplayerLoader.dll" + "$MOD_ROOT/1.6/AssembliesCustom/Multiplayer.dll" + "$MOD_ROOT/1.6/AssembliesCustom/MultiplayerCommon.dll" + "$MOD_ROOT/1.7/Assemblies/MultiplayerLoader.dll" + "$MOD_ROOT/1.7/AssembliesCustom/Multiplayer.dll" + "$MOD_ROOT/1.7/AssembliesCustom/MultiplayerCommon.dll" +) +for file in "${required_files[@]}"; do + if [[ ! -f "$file" ]]; then + echo "Required local test input is missing: $file" >&2 + exit 1 + fi +done + +if ! command -v python3 >/dev/null 2>&1; then + echo "python3 is required by the RimWorld 1.6 ritual probe, but was not found in PATH." >&2 + exit 1 +fi + +python3 - "$REPO_ROOT/global.json" <<'PY' +import json +import re +import sys + +path = sys.argv[1] +try: + with open(path, encoding="utf-8") as config_file: + config = json.load(config_file) +except OSError as exc: + raise SystemExit(f"Cannot read required SDK config {path}: {exc}") +except json.JSONDecodeError as exc: + raise SystemExit(f"Malformed SDK config {path}: {exc}") + +sdk = config.get("sdk") if isinstance(config, dict) else None +version = sdk.get("version") if isinstance(sdk, dict) else None +if not isinstance(version, str) or not re.fullmatch(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", version): + raise SystemExit(f"SDK config {path} must contain a valid sdk.version string") +PY + +if ! command -v docker >/dev/null 2>&1; then + echo "Docker is required to run these local probes, but was not found in PATH." >&2 + exit 1 +fi + +echo "== RimWorld 1.6 ritual prefix probe ==" +bash "$SCRIPT_DIR/Source/RuntimeTests/run-ritual-probe.sh" \ + "$GAME_MANAGED_DIR" "$PREPATCHER_DIR" "$HARMONY_DIR" "$MOD_ROOT" + +echo "== RimWorld 1.6 reflection-only probe ==" +bash "$SCRIPT_DIR/Source/RuntimeTests/run-reflection-only-probe.sh" \ + "$GAME_MANAGED_DIR" "$MOD_ROOT" + +echo "== RimWorld 1.7 metadata audit ==" +bash "$SCRIPT_DIR/Source/RuntimeTests/audit-rimworld-1.7-reference.sh" \ + "$MOD_ROOT" + +echo "All local runtime probes passed." diff --git a/workshop_bundler.sh b/workshop_bundler.sh index 1569547db..93a2e45ef 100755 --- a/workshop_bundler.sh +++ b/workshop_bundler.sh @@ -1,4 +1,5 @@ #!/bin/bash +# Build and package the RimWorld 1.5, 1.6, and 1.7 Multiplayer mod releases. VERSION=$(grep -Po '(?<=SimpleVersion = ")[0-9\.]+' Source/Common/Version.cs) From 30db9b3112bf9f83a93d9b634c00c412748d4b07 Mon Sep 17 00:00:00 2001 From: roman_gr Date: Wed, 28 Oct 2026 00:50:02 +0100 Subject: [PATCH 14/19] Remove workshop bundler and package supported versions in CI --- .github/workflows/build-workshop.yml | 39 ++++++++++++++--- About/About.xml | 2 +- LoadFolders.xml | 4 -- MajorReleaseProcess.txt | 14 +++--- README.md | 2 +- workshop_bundler.sh | 65 ---------------------------- 6 files changed, 42 insertions(+), 84 deletions(-) delete mode 100755 workshop_bundler.sh diff --git a/.github/workflows/build-workshop.yml b/.github/workflows/build-workshop.yml index 15002a6a5..4bb64cf5d 100644 --- a/.github/workflows/build-workshop.yml +++ b/.github/workflows/build-workshop.yml @@ -24,16 +24,43 @@ jobs: cache: true cache-dependency-path: '**/*.csproj' - - name: Run workshop bundler - run: ./workshop_bundler.sh + - name: Build versioned mod + run: | + for version in 1.6 1.7; do + dotnet build Source/Client/Multiplayer.csproj --configuration Release -p:RimWorldVersion="$version" + done - - run: mkdir -p output + - name: Package mod files + run: | + destination=output/Multiplayer + mkdir -p "$destination" + cp -R About Defs Languages Textures LoadFolders.xml "$destination/" + rm -rf "$destination/Languages/.git" - - name: Move files - run: mv Multiplayer/ output/ + for version in 1.6 1.7; do + test -f "$version/Assemblies/MultiplayerLoader.dll" + test -f "$version/AssembliesCustom/Multiplayer.dll" + test -f "$version/AssembliesCustom/MultiplayerCommon.dll" + cp -R "$version" "$destination/" + done + + python3 - "$destination/About/About.xml" "$(git rev-parse --short HEAD)" <<'PYTHON' + from pathlib import Path + import re + import sys + import xml.etree.ElementTree as ET + + source = Path('Source/Common/Version.cs').read_text(encoding='utf-8') + version = re.search(r'\bSimpleVersion\s*=\s*"([^"]+)"', source) + if version is None: + raise SystemExit('Cannot find SimpleVersion in Source/Common/Version.cs') + metadata = ET.parse(sys.argv[1]) + metadata.getroot().find('modVersion').text = f'{version[1]} ({sys.argv[2]})' + metadata.write(sys.argv[1], encoding='utf-8', xml_declaration=True) + PYTHON - id: get_version - run: echo "VERSION=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT" + run: echo "VERSION=${GITHUB_REF_NAME//\//-}" >> "$GITHUB_OUTPUT" - name: Upload mod artifacts uses: actions/upload-artifact@v4 diff --git a/About/About.xml b/About/About.xml index 298f81db1..b02a2fd1b 100644 --- a/About/About.xml +++ b/About/About.xml @@ -8,7 +8,7 @@
    RimWorld Multiplayer Team https://github.com/rwmt/Multiplayer - + <b>Important: This mod should be placed right below Core and expansions in the mod list to work properly! Requires RimWorld >= 1.6.4491</b>\n Multiplayer mod for RimWorld. diff --git a/LoadFolders.xml b/LoadFolders.xml index f1a1ba882..be5dd2fb7 100644 --- a/LoadFolders.xml +++ b/LoadFolders.xml @@ -8,8 +8,4 @@
  • /
  • 1.7
  • - -
  • /
  • -
  • 1.5
  • -
    diff --git a/MajorReleaseProcess.txt b/MajorReleaseProcess.txt index 120a5726c..e2b1d95c1 100644 --- a/MajorReleaseProcess.txt +++ b/MajorReleaseProcess.txt @@ -1,7 +1,7 @@ -1. Check mod version in Source/Common/Version.cs -2. Check RW version in About.xml (Multiplayer version is added automatically by workshop_bundler.sh) -3. Check Preview.png -4. Make a branch to freeze a release for old RW version, remember to compile and push assemblies, edit workshop_bundler.sh -5. Run workshop_bundler.sh -6. Github release zip generated by bundler -7. Steam workshop upload folder Multiplayer/ generated by bundler \ No newline at end of file +1. Check the mod version in Source/Common/Version.cs. +2. Check supported RimWorld versions in About/About.xml and LoadFolders.xml. +3. Check About/Preview.png. +4. Preserve a release branch for older RimWorld versions when dropping support. +5. Push the release tag (v*) or manually run the Build workshop workflow. +6. Download the Multiplayer- artifact from the workflow run. It contains Multiplayer/ with both supported version folders and a generated modVersion. +7. Use the downloaded ZIP for GitHub releases and the extracted Multiplayer/ folder for the Steam Workshop upload. diff --git a/README.md b/README.md index 09e0a755b..e888f5831 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ dotnet build Source/Client/Multiplayer.csproj -c Release -p:RimWorldVersion=1.6 dotnet build Source/Client/Multiplayer.csproj -c Release -p:RimWorldVersion=1.7 ``` -The workshop bundler builds both variants and retains the historical 1.5 package. +The **Build workshop** workflow builds and packages both supported game versions directly. ### Local runtime probes diff --git a/workshop_bundler.sh b/workshop_bundler.sh deleted file mode 100755 index 93a2e45ef..000000000 --- a/workshop_bundler.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/bin/bash -# Build and package the RimWorld 1.5, 1.6, and 1.7 Multiplayer mod releases. - -VERSION=$(grep -Po '(?<=SimpleVersion = ")[0-9\.]+' Source/Common/Version.cs) - -git submodule update --init --recursive || { echo 'git submodule update FAILED' ; exit 1; } - -cd Source || exit -dotnet build --configuration Release || { echo 'dotnet build FAILED' ; exit 1; } -cd .. - -rm -rf Multiplayer/ -mkdir -p Multiplayer -cd Multiplayer || exit - -# About/ and Textures/ are shared between all versions -cp -r ../About ../Textures . - -cat < LoadFolders.xml - - -
  • /
  • -
  • 1.6
  • -
    - -
  • /
  • -
  • 1.6
  • -
    - -
  • /
  • -
  • 1.5
  • -
    -
    -EOF - -GIT_COMMIT=$(git rev-parse --short HEAD 2>&1) -GIT_COMMIT_STATUS=$? -if [ $GIT_COMMIT_STATUS -eq 0 ]; then - FULL_VERSION="${VERSION} (${GIT_COMMIT})" -else - FULL_VERSION="${VERSION}" - echo "WARN: Failed to check git commit: ${GIT_COMMIT}" -fi -sed -i "// a \ \ \ \
  • 1.5
  • " About/About.xml -sed -i "s/.*<\/modVersion>.*\$/${FULL_VERSION}<\/modVersion>/" About/About.xml - -# Shared 1.6/1.7 build; keep the folder name for existing installations. -mkdir -p 1.6 -cp -r ../Assemblies ../AssembliesCustom ../Defs ../Languages 1.6/ -rm -f 1.6/Languages/.git 1.6/Languages/LICENSE 1.6/Languages/README.md - -# Past versions -git clone -b rw-1.5 --depth=1 --single-branch --recurse-submodules https://github.com/rwmt/Multiplayer.git 1.5 || { echo 'Git cloning 1.5 FAILED' ; exit 1; } -shopt -s extglob -shopt -s dotglob -rm -rf -- 1.5/!(Languages|Assemblies|AssembliesCustom|Defs) -rm -f 1.5/Languages/.git 1.5/Languages/LICENSE 1.5/Languages/README.md - -cd .. - -# Zip for Github releases -rm -f Multiplayer-v$VERSION.zip -zip -r -q Multiplayer-v$VERSION.zip Multiplayer - -echo "Ok, $PWD/Multiplayer-v$VERSION.zip ready for uploading to Workshop" From b4f32b53631c6fa2d909430bf15dc090a278d381 Mon Sep 17 00:00:00 2001 From: roman_gr Date: Wed, 28 Oct 2026 00:56:41 +0100 Subject: [PATCH 15/19] Share mod packaging across GitHub Actions workflows --- .github/actions/package-mod/action.yml | 63 ++++++++++++++++++++++++++ .github/workflows/build-beta.yml | 24 ++-------- .github/workflows/build-workshop.yml | 31 ++----------- .github/workflows/pr-check.yml | 21 ++------- 4 files changed, 75 insertions(+), 64 deletions(-) create mode 100644 .github/actions/package-mod/action.yml diff --git a/.github/actions/package-mod/action.yml b/.github/actions/package-mod/action.yml new file mode 100644 index 000000000..cccd2c04a --- /dev/null +++ b/.github/actions/package-mod/action.yml @@ -0,0 +1,63 @@ +name: Package Multiplayer mod +description: Stage shared assets and versioned assemblies, with optional package metadata. + +inputs: + destination: + description: Output mod directory, relative to the repository root or absolute. + required: true + display-name: + description: Optional replacement for the mod's display name. + default: '' + stamp-version: + description: Fill modVersion from SimpleVersion and the current commit hash. + default: 'false' + +runs: + using: composite + steps: + - name: Stage mod files + shell: bash + working-directory: ${{ github.workspace }} + env: + DESTINATION: ${{ inputs.destination }} + run: | + mkdir -p "$DESTINATION" + cp -R About Defs Languages Textures LoadFolders.xml "$DESTINATION/" + rm -rf "$DESTINATION/Languages/.git" + + for version in 1.6 1.7; do + test -f "$version/Assemblies/MultiplayerLoader.dll" + test -f "$version/AssembliesCustom/Multiplayer.dll" + test -f "$version/AssembliesCustom/MultiplayerCommon.dll" + cp -R "$version" "$DESTINATION/" + done + + - name: Update package metadata + if: ${{ inputs.display-name != '' || inputs.stamp-version == 'true' }} + shell: bash + working-directory: ${{ github.workspace }} + env: + DESTINATION: ${{ inputs.destination }} + DISPLAY_NAME: ${{ inputs.display-name }} + STAMP_VERSION: ${{ inputs.stamp-version }} + run: | + python3 - <<'PYTHON' + from pathlib import Path + import os + import re + import subprocess + import xml.etree.ElementTree as ET + + path = Path(os.environ['DESTINATION']) / 'About/About.xml' + metadata = ET.parse(path) + if os.environ['DISPLAY_NAME']: + metadata.getroot().find('name').text = os.environ['DISPLAY_NAME'] + if os.environ['STAMP_VERSION'] == 'true': + source = Path('Source/Common/Version.cs').read_text(encoding='utf-8') + version = re.search(r'\bSimpleVersion\s*=\s*"([^"]+)"', source) + if version is None: + raise SystemExit('Cannot find SimpleVersion in Source/Common/Version.cs') + commit = subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD'], text=True).strip() + metadata.getroot().find('modVersion').text = f'{version[1]} ({commit})' + metadata.write(path, encoding='utf-8', xml_declaration=True) + PYTHON diff --git a/.github/workflows/build-beta.yml b/.github/workflows/build-beta.yml index 0d2d64d91..e14ed035a 100644 --- a/.github/workflows/build-beta.yml +++ b/.github/workflows/build-beta.yml @@ -61,26 +61,10 @@ jobs: done - name: Stage versioned Multiplayer mod - run: | - destination=output/Multiplayer - mkdir -p "$destination" - cp -R About Defs Languages Textures LoadFolders.xml "$destination/" - rm -rf "$destination/Languages/.git" - python3 - "$destination/About/About.xml" <<'PYTHON' - import sys - import xml.etree.ElementTree as ET - - metadata = ET.parse(sys.argv[1]) - metadata.getroot().find('name').text = 'Multiplayer [Continuous]' - metadata.write(sys.argv[1], encoding='utf-8', xml_declaration=True) - PYTHON - - for version in 1.6 1.7; do - test -f "$version/Assemblies/MultiplayerLoader.dll" - test -f "$version/AssembliesCustom/Multiplayer.dll" - test -f "$version/AssembliesCustom/MultiplayerCommon.dll" - cp -R "$version" "$destination/" - done + uses: ./.github/actions/package-mod + with: + destination: output/Multiplayer + display-name: Multiplayer [Continuous] - name: Package files run: | diff --git a/.github/workflows/build-workshop.yml b/.github/workflows/build-workshop.yml index 4bb64cf5d..4b79bbcdd 100644 --- a/.github/workflows/build-workshop.yml +++ b/.github/workflows/build-workshop.yml @@ -31,33 +31,10 @@ jobs: done - name: Package mod files - run: | - destination=output/Multiplayer - mkdir -p "$destination" - cp -R About Defs Languages Textures LoadFolders.xml "$destination/" - rm -rf "$destination/Languages/.git" - - for version in 1.6 1.7; do - test -f "$version/Assemblies/MultiplayerLoader.dll" - test -f "$version/AssembliesCustom/Multiplayer.dll" - test -f "$version/AssembliesCustom/MultiplayerCommon.dll" - cp -R "$version" "$destination/" - done - - python3 - "$destination/About/About.xml" "$(git rev-parse --short HEAD)" <<'PYTHON' - from pathlib import Path - import re - import sys - import xml.etree.ElementTree as ET - - source = Path('Source/Common/Version.cs').read_text(encoding='utf-8') - version = re.search(r'\bSimpleVersion\s*=\s*"([^"]+)"', source) - if version is None: - raise SystemExit('Cannot find SimpleVersion in Source/Common/Version.cs') - metadata = ET.parse(sys.argv[1]) - metadata.getroot().find('modVersion').text = f'{version[1]} ({sys.argv[2]})' - metadata.write(sys.argv[1], encoding='utf-8', xml_declaration=True) - PYTHON + uses: ./.github/actions/package-mod + with: + destination: output/Multiplayer + stamp-version: 'true' - id: get_version run: echo "VERSION=${GITHUB_REF_NAME//\//-}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index eaf589eb3..a61c9c833 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -119,23 +119,10 @@ jobs: done - name: Package mod files - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - destination=artifacts/mod/Multiplayer - mkdir -p "$destination" - cp -R About Defs Languages Textures LoadFolders.xml "$destination/" - rm -rf "$destination/Languages/.git" - python3 - "$destination/About/About.xml" "$PR_NUMBER" <<'PYTHON' - import sys - import xml.etree.ElementTree as ET - - metadata = ET.parse(sys.argv[1]) - metadata.getroot().find('name').text = f'Multiplayer [PR #{sys.argv[2]}]' - metadata.write(sys.argv[1], encoding='utf-8', xml_declaration=True) - PYTHON - - cp -R 1.6 1.7 "$destination/" + uses: ./.github/actions/package-mod + with: + destination: artifacts/mod/Multiplayer + display-name: "Multiplayer [PR #${{ github.event.pull_request.number }}]" # Upload the parent directories so downloaded ZIPs have Multiplayer/ or Server/ at root. - name: Upload mod artifact From ad89d64e8bc8cce09ceb284c55661369cff2b335 Mon Sep 17 00:00:00 2001 From: roman_gr Date: Wed, 28 Oct 2026 18:59:02 +0100 Subject: [PATCH 16/19] Target RimWorld 1.7 only and bundle legacy builds in CI --- .github/actions/package-mod/action.yml | 97 +++-- .github/workflows/alpha-notes.md | 38 +- .github/workflows/build-beta.yml | 103 ++--- .github/workflows/build-packages.yml | 209 ++++++++++ .github/workflows/build-workshop.yml | 49 +-- .github/workflows/pr-check.yml | 96 +---- About/About.xml | 16 +- LoadFolders.xml | 9 +- MajorReleaseProcess.txt | 4 +- README.md | 145 ++----- Source/1.6/Client/MonoCompatibility.cs | 50 --- Source/1.6/Client/SyncMethod.Prepatcher.cs | 5 - .../ISyncMethodForPrepatcher.cs | 9 - .../Multiplayer.ReflectionOnlyUnity.cs | 10 - Source/1.6/MultiplayerLoader/Prepatches.cs | 83 ---- .../ReflectionOnlyUnityResolver.cs | 54 --- .../SyncRituals.Prepatcher.cs | 46 --- .../Tests/ReflectionOnlyUnityResolverTest.cs | 91 ----- Source/Client/Debug/DebugActions.cs | 38 +- Source/Client/Desyncs/ClientSyncOpinion.cs | 2 - Source/Client/Desyncs/DeferredStackTracing.cs | 41 +- Source/Client/Desyncs/JittedMethods.cs | 112 +----- Source/Client/Desyncs/SaveableDesyncInfo.cs | 9 +- Source/Client/Desyncs/StackTraceLogItem.cs | 23 +- .../{1.7 => }/Client/KeepDebugResolution.cs | 0 Source/Client/Multiplayer.cs | 19 +- Source/Client/Multiplayer.csproj | 9 +- Source/Client/MultiplayerStatic.cs | 13 - Source/Client/OnMainThread.cs | 1 - Source/Client/Patches/VanillaTweaks.cs | 4 +- Source/Client/Settings/MpSettings.cs | 2 - Source/Client/Settings/MpSettingsUI.cs | 2 - Source/Client/Syncing/Handler/SyncMethod.cs | 2 +- Source/Client/Syncing/Sync.cs | 1 - Source/Client/UI/DebugPanel/SyncDebugPanel.cs | 2 - Source/Client/UI/IngameDebug.cs | 1 - Source/Client/Util/MpUI.cs | 17 +- Source/Client/Util/MpUtil.cs | 17 +- Source/Client/Windows/ServerBrowser.cs | 4 +- Source/Common/Common.csproj | 1 - Source/Common/DeferredStackTracingImpl.cs | 377 ------------------ Source/Common/Native.cs | 275 ------------- Source/Directory.Build.props | 25 +- Source/Directory.Build.targets | 19 - .../MultiplayerLoader/Multiplayer.Harmony.cs | 2 +- Source/MultiplayerLoader/MultiplayerLoader.cs | 3 - .../MultiplayerLoader.csproj | 2 - .../SyncRituals.RuntimeHarmony.cs | 0 Source/RuntimeTests/README.md | 95 +---- .../ReflectionOnlyUnityFixture.cs | 5 - .../RuntimeTests/ReflectionOnlyUnityProbe.cs | 55 --- .../RuntimeTests/RitualPrepatcherMonoProbe.cs | 160 -------- .../RuntimeTests/run-reflection-only-probe.sh | 30 -- Source/RuntimeTests/run-ritual-probe.sh | 56 --- Source/Server/Server.csproj | 1 - Source/Tests/CoreClrRuntimeTest.cs | 11 +- Source/Tests/Tests.csproj | 2 - Source/TestsOnMono/ExternMethods.cs | 13 - Source/TestsOnMono/Program.cs | 136 ------- Source/TestsOnMono/README.md | 15 - Source/TestsOnMono/TestsOnMono.csproj | 16 - run-local-tests.sh | 113 +----- 62 files changed, 423 insertions(+), 2422 deletions(-) create mode 100644 .github/workflows/build-packages.yml delete mode 100644 Source/1.6/Client/MonoCompatibility.cs delete mode 100644 Source/1.6/Client/SyncMethod.Prepatcher.cs delete mode 100644 Source/1.6/MultiplayerLoader/ISyncMethodForPrepatcher.cs delete mode 100644 Source/1.6/MultiplayerLoader/Multiplayer.ReflectionOnlyUnity.cs delete mode 100644 Source/1.6/MultiplayerLoader/Prepatches.cs delete mode 100644 Source/1.6/MultiplayerLoader/ReflectionOnlyUnityResolver.cs delete mode 100644 Source/1.6/MultiplayerLoader/SyncRituals.Prepatcher.cs delete mode 100644 Source/1.6/Tests/ReflectionOnlyUnityResolverTest.cs rename Source/{1.7 => }/Client/KeepDebugResolution.cs (100%) delete mode 100644 Source/Common/DeferredStackTracingImpl.cs delete mode 100644 Source/Common/Native.cs delete mode 100644 Source/Directory.Build.targets rename Source/{1.7 => }/MultiplayerLoader/Multiplayer.Harmony.cs (92%) rename Source/{1.7 => }/MultiplayerLoader/SyncRituals.RuntimeHarmony.cs (100%) delete mode 100644 Source/RuntimeTests/ReflectionOnlyUnityFixture.cs delete mode 100644 Source/RuntimeTests/ReflectionOnlyUnityProbe.cs delete mode 100644 Source/RuntimeTests/RitualPrepatcherMonoProbe.cs delete mode 100644 Source/RuntimeTests/run-reflection-only-probe.sh delete mode 100755 Source/RuntimeTests/run-ritual-probe.sh delete mode 100644 Source/TestsOnMono/ExternMethods.cs delete mode 100644 Source/TestsOnMono/Program.cs delete mode 100644 Source/TestsOnMono/README.md delete mode 100644 Source/TestsOnMono/TestsOnMono.csproj diff --git a/.github/actions/package-mod/action.yml b/.github/actions/package-mod/action.yml index cccd2c04a..dd3312098 100644 --- a/.github/actions/package-mod/action.yml +++ b/.github/actions/package-mod/action.yml @@ -1,63 +1,96 @@ name: Package Multiplayer mod -description: Stage shared assets and versioned assemblies, with optional package metadata. - +description: Assemble current and independently built legacy game versions. inputs: destination: - description: Output mod directory, relative to the repository root or absolute. + description: Destination mod directory. + required: true + current-root: + description: Downloaded current build, containing About and 1.7. + required: true + legacy-root: + description: Downloaded legacy build, containing About and 1.6. required: true display-name: - description: Optional replacement for the mod's display name. + description: Optional package display name. default: '' stamp-version: - description: Fill modVersion from SimpleVersion and the current commit hash. + description: Stamp the release version and current source commit. default: 'false' - runs: using: composite steps: - - name: Stage mod files - shell: bash - working-directory: ${{ github.workspace }} - env: - DESTINATION: ${{ inputs.destination }} - run: | - mkdir -p "$DESTINATION" - cp -R About Defs Languages Textures LoadFolders.xml "$DESTINATION/" - rm -rf "$DESTINATION/Languages/.git" - - for version in 1.6 1.7; do - test -f "$version/Assemblies/MultiplayerLoader.dll" - test -f "$version/AssembliesCustom/Multiplayer.dll" - test -f "$version/AssembliesCustom/MultiplayerCommon.dll" - cp -R "$version" "$DESTINATION/" - done - - - name: Update package metadata - if: ${{ inputs.display-name != '' || inputs.stamp-version == 'true' }} + - name: Assemble versioned mod shell: bash - working-directory: ${{ github.workspace }} env: DESTINATION: ${{ inputs.destination }} + CURRENT_ROOT: ${{ inputs.current-root }} + LEGACY_ROOT: ${{ inputs.legacy-root }} DISPLAY_NAME: ${{ inputs.display-name }} STAMP_VERSION: ${{ inputs.stamp-version }} run: | python3 - <<'PYTHON' + from copy import deepcopy from pathlib import Path import os import re + import shutil import subprocess import xml.etree.ElementTree as ET - path = Path(os.environ['DESTINATION']) / 'About/About.xml' - metadata = ET.parse(path) + destination = Path(os.environ['DESTINATION']) + sources = {'1.7': Path(os.environ['CURRENT_ROOT']), '1.6': Path(os.environ['LEGACY_ROOT'])} + # Validate both independent inputs before copying any files. + metadata = {} + for version, source in sources.items(): + metadata[version] = ET.parse(source / 'About/About.xml').getroot() + for relative in ('Assemblies/MultiplayerLoader.dll', 'AssembliesCustom/Multiplayer.dll', 'AssembliesCustom/MultiplayerCommon.dll'): + path = source / version / relative + if not path.is_file(): + raise SystemExit(f'Missing {version} build output: {path}') + for dll in (source / version).rglob('*.dll'): + name = dll.name.lower() + legacy_api = version == '1.6' and name == '0prepatcherapi.dll' + if name in ('0harmony.dll', 'assembly-csharp.dll', 'verse.dll', 'unitystubs.dll') or name.startswith(('unityengine', 'mono.cecil')) or (name.startswith(('0prepatcher', 'prepatcher')) and not legacy_api): + raise SystemExit(f'Unexpected bundled game or dependency DLL: {dll}') + destination.mkdir(parents=True, exist_ok=False) + shutil.copytree(sources['1.7'] / 'About', destination / 'About') + root = deepcopy(metadata['1.7']) + for tag in ('supportedVersions', 'modDependencies', 'modDependenciesByVersion'): + for old in list(root.findall(tag)): + root.remove(old) + supported = ET.SubElement(root, 'supportedVersions') + dependencies = ET.SubElement(root, 'modDependenciesByVersion') + folders = ET.Element('loadFolders') + for version in ('1.6', '1.7'): + shutil.copytree(sources[version] / version, destination / version, ignore=shutil.ignore_patterns('.git')) + ET.SubElement(supported, 'li').text = version + selected = metadata[version].find(f'modDependenciesByVersion/v{version}') + if selected is None: + selected = metadata[version].find('modDependencies') + entry = ET.SubElement(dependencies, f'v{version}') + if selected is not None: + entry.extend(deepcopy(list(selected))) + folder = ET.SubElement(folders, f'v{version}') + ET.SubElement(folder, 'li').text = '/' + ET.SubElement(folder, 'li').text = version + # Preserve legacy dependency ordering as well as its declarations. + ordering = root.find('loadAfter') + if ordering is None: + ordering = ET.SubElement(root, 'loadAfter') + existing = {item.text for item in ordering} + for item in metadata['1.6'].findall('loadAfter/li'): + if item.text not in existing: + ordering.append(deepcopy(item)) + existing.add(item.text) if os.environ['DISPLAY_NAME']: - metadata.getroot().find('name').text = os.environ['DISPLAY_NAME'] + root.find('name').text = os.environ['DISPLAY_NAME'] if os.environ['STAMP_VERSION'] == 'true': source = Path('Source/Common/Version.cs').read_text(encoding='utf-8') version = re.search(r'\bSimpleVersion\s*=\s*"([^"]+)"', source) if version is None: - raise SystemExit('Cannot find SimpleVersion in Source/Common/Version.cs') + raise SystemExit('Cannot find SimpleVersion') commit = subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD'], text=True).strip() - metadata.getroot().find('modVersion').text = f'{version[1]} ({commit})' - metadata.write(path, encoding='utf-8', xml_declaration=True) + root.find('modVersion').text = f'{version[1]} ({commit})' + ET.ElementTree(root).write(destination / 'About/About.xml', encoding='utf-8', xml_declaration=True) + ET.ElementTree(folders).write(destination / 'LoadFolders.xml', encoding='utf-8', xml_declaration=True) PYTHON diff --git a/.github/workflows/alpha-notes.md b/.github/workflows/alpha-notes.md index 646c58dc2..c515765bf 100644 --- a/.github/workflows/alpha-notes.md +++ b/.github/workflows/alpha-notes.md @@ -1,35 +1,5 @@ -### This 'alpha' release is an automatically generated snapshot of the current state of development. It is continuously updated with work-in-progress changes that may be broken, incomplete, or incompatible. +Development build for RimWorld 1.7, bundled with a separately built legacy 1.6 version. -#### Supported RimWorld versions: 1.6 and 1.7 - -#### Installation -1. Download the `Multiplayer-beta.zip` file below. -2. Open your RimWorld installation directory - * **Steam**: Right-click RimWorld in your Steam library → `Manage` → `Browse local Files` - * Or navigate directly to: - - Windows: `C:\Program Files (x86)\Steam\steamapps\common\RimWorld\Mods` - - Mac: `~/Library/Application Support/Steam/steamapps/common/RimWorld/RimWorldMac.app/Mods` (Right-click on `RimWorldMac.app`, then click `Show Package Contents` to see the `Mods` folder) - - Linux: `~/.steam/steam/steamapps/common/RimWorld/Mods` -3. Extract the zip file into the `Mods` folder. - * You should have a `Multiplayer` folder in the `Mods` folder (`Mods/Multiplayer`) - * Make sure you do not have this directory structure: `Mods/Multiplayer-beta/Multiplayer`. If you do, move the `Multiplayer` folder to the parent directory. - -#### Standalone server - -Download `Server-beta.zip` if you want to host a dedicated standalone server for testing. - -**Setup** -1. Download and extract `Server-beta.zip`. -2. Open the folder for your platform and game version: - - RimWorld 1.6: `Server/Windows` or `Server/Linux` (targets .NET 8) - - RimWorld 1.7: `Server/1.7/Windows` or `Server/1.7/Linux` (targets .NET 10) -3. Start the server using: - - Windows: `Server.exe` - - Linux: `./Server.sh` -4. The server will start and wait for the first connection. - -**First-time configuration (bootstrap)** -No manual configuration files are required. -The **first player to connect** via the Multiplayer mod client will be prompted to perform the initial setup: -they can configure the game world, scenario, and other options directly from within RimWorld. -Subsequent players connect to the already-running session. +Install the Harmony version matching your game. Legacy 1.6 also requires Prepatcher. +Extract the mod ZIP to Mods; version folders are selected through LoadFolders.xml. +Legacy fixes are backported separately. See README.md for exact source selection. diff --git a/.github/workflows/build-beta.yml b/.github/workflows/build-beta.yml index e14ed035a..8e62a3036 100644 --- a/.github/workflows/build-beta.yml +++ b/.github/workflows/build-beta.yml @@ -1,91 +1,46 @@ name: Build beta - on: workflow_dispatch: push: - branches: - - dev - paths-ignore: - - 'README.md' - + branches: [dev] + paths-ignore: ['README.md'] +permissions: + contents: read jobs: build: - name: Build beta + uses: ./.github/workflows/build-packages.yml + with: + artifact-suffix: beta-${{ github.run_id }} + display-name: Multiplayer [Continuous] + release: + needs: build runs-on: ubuntu-latest + permissions: + contents: write env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} steps: - - name: Checkout Repository - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - submodules: recursive - - - name: Set up .NET - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + persist-credentials: false + - uses: actions/download-artifact@v4 with: - global-json-file: global.json - cache: true - cache-dependency-path: '**/*.csproj' - - - name: Restore and build RimWorld 1.6 - run: | - dotnet restore Source/Multiplayer.sln -p:RimWorldVersion=1.6 - dotnet build Source/Multiplayer.sln --configuration Release --no-restore -p:RimWorldVersion=1.6 - - - name: Restore and build RimWorld 1.7 - run: | - dotnet restore Source/Multiplayer.sln -p:RimWorldVersion=1.7 - dotnet build Source/Multiplayer.sln --configuration Release --no-restore -p:RimWorldVersion=1.7 - - - name: Publish Server 1.6 and 1.7 (Windows) - run: | - dotnet publish Source/Server/Server.csproj --configuration Release --runtime win-x64 --self-contained false -p:UseAppHost=true -p:RimWorldVersion=1.6 -o output/Server/Windows - dotnet publish Source/Server/Server.csproj --configuration Release --runtime win-x64 --self-contained false -p:UseAppHost=true -p:RimWorldVersion=1.7 -o output/Server/1.7/Windows - - - name: Publish Server 1.6 and 1.7 (Linux) - run: | - dotnet publish Source/Server/Server.csproj --configuration Release --runtime linux-x64 --self-contained false -p:UseAppHost=true -p:RimWorldVersion=1.6 -o output/Server/Linux - dotnet publish Source/Server/Server.csproj --configuration Release --runtime linux-x64 --self-contained false -p:UseAppHost=true -p:RimWorldVersion=1.7 -o output/Server/1.7/Linux - - - name: Add Server Launchers - run: | - for directory in output/Server/Linux output/Server/1.7/Linux; do - cat > "$directory/Server.sh" <<'EOF' - #!/usr/bin/env bash - # Launch the published server DLL beside this script with the given arguments. - set -euo pipefail - SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" - exec dotnet "$SCRIPT_DIR/Server.dll" "$@" - EOF - chmod +x "$directory/Server.sh" - done - - - name: Stage versioned Multiplayer mod - uses: ./.github/actions/package-mod + name: ${{ needs.build.outputs.mod-artifact }} + path: output + - uses: actions/download-artifact@v4 with: - destination: output/Multiplayer - display-name: Multiplayer [Continuous] - - - name: Package files + name: ${{ needs.build.outputs.server-artifact }} + path: output + - name: Package release ZIPs + working-directory: output run: | - cd output/ zip -r ../Multiplayer-beta.zip Multiplayer/ zip -r ../Server-beta.zip Server/ - cd ../ - - - name: Upload Mod Artifacts - uses: actions/upload-artifact@v4 - with: - name: Multiplayer-beta - path: output/ - - - name: Clean up last release + - name: Remove previous release continue-on-error: true + run: gh release delete --cleanup-tag --yes continuous + - name: Publish release run: | - gh release delete --cleanup-tag --yes "continuous" - - - name: Upload new release - run: | - gh release create --target "${{ github.sha }}" --title "Continuous" --notes-file ".github/workflows/alpha-notes.md" --draft "continuous" - gh release upload "continuous" Multiplayer-beta.zip Server-beta.zip - gh release edit "continuous" --draft=false + gh release create --target "${{ github.sha }}" --title Continuous --notes-file .github/workflows/alpha-notes.md --draft continuous + gh release upload continuous Multiplayer-beta.zip Server-beta.zip + gh release edit continuous --draft=false diff --git a/.github/workflows/build-packages.yml b/.github/workflows/build-packages.yml new file mode 100644 index 000000000..339925d1b --- /dev/null +++ b/.github/workflows/build-packages.yml @@ -0,0 +1,209 @@ +name: Build release packages +on: + workflow_call: + inputs: + artifact-suffix: + required: true + type: string + display-name: + default: '' + type: string + stamp-version: + default: false + type: boolean + include-servers: + default: true + type: boolean + outputs: + mod-artifact: + value: ${{ jobs.package.outputs.mod-artifact }} + server-artifact: + value: ${{ jobs.package.outputs.server-artifact }} +permissions: + contents: read +jobs: + current: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + submodules: recursive + - name: Set up .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json + cache: true + cache-dependency-path: '**/*.csproj' + - name: Build current mod + run: dotnet build Source/Client/Multiplayer.csproj -c Release + - name: Stage current mod + run: | + mkdir -p artifacts/current/1.7 + cp -R About artifacts/current/ + cp -R Defs Languages Textures 1.7/Assemblies 1.7/AssembliesCustom artifacts/current/1.7/ + rm -rf artifacts/current/1.7/Languages/.git + - name: Audit current assembly references + run: dotnet run --project Source/RuntimeTests/MetadataAudit.csproj -c Release -- artifacts/current/1.7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: current-mod-${{ inputs.artifact-suffix }} + path: artifacts/current/ + if-no-files-found: error + retention-days: 7 + - name: Publish current servers + if: inputs.include-servers + run: | + for os in win linux; do + dotnet publish Source/Server/Server.csproj -c Release -r "$os-x64" --self-contained false -p:UseAppHost=true -o "artifacts/server/Server/1.7/$(if [[ $os == win ]]; then echo Windows; else echo Linux; fi)" + done + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: inputs.include-servers + with: + name: current-server-${{ inputs.artifact-suffix }} + path: artifacts/server/ + retention-days: 7 + if-no-files-found: error + legacy: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Resolve legacy 1.6 source + id: legacy + env: + LEGACY_BRANCH: rw-1.6 + LEGACY_FALLBACK: 9df94bd04ea9a94c00bd39a276e58c4c2f965740 + run: | + if refs=$(git ls-remote --exit-code --heads https://github.com/rwmt/Multiplayer.git "refs/heads/$LEGACY_BRANCH"); then + sha=${refs%%$'\t'*} + echo "Using $LEGACY_BRANCH at $sha" + else + status=$? + if [[ "$status" != 2 ]]; then + echo "Could not check the legacy branch (git exit $status)" >&2 + exit "$status" + fi + sha=$LEGACY_FALLBACK + echo "No $LEGACY_BRANCH branch; using pinned commit $sha" + fi + echo "sha=$sha" >> "$GITHUB_OUTPUT" + - name: Checkout legacy source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: rwmt/Multiplayer + ref: ${{ steps.legacy.outputs.sha }} + path: legacy-src + persist-credentials: false + submodules: recursive + - name: Set up .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json + cache: true + cache-dependency-path: '**/*.csproj' + - name: Build legacy mod + working-directory: legacy-src + run: dotnet build Source/Client/Multiplayer.csproj -c Release + - name: Stage legacy mod + env: + LEGACY_SHA: ${{ steps.legacy.outputs.sha }} + run: | + mkdir -p artifacts/legacy/1.6 + cp -R legacy-src/About artifacts/legacy/ + for directory in Assemblies AssembliesCustom Defs Languages Textures; do + cp -R "legacy-src/$directory" artifacts/legacy/1.6/ + done + rm -rf artifacts/legacy/1.6/Languages/.git + echo "$LEGACY_SHA" > artifacts/legacy/1.6/source-commit.txt + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: legacy-mod-${{ inputs.artifact-suffix }} + path: artifacts/legacy/ + retention-days: 7 + if-no-files-found: error + - name: Publish legacy servers + if: inputs.include-servers + working-directory: legacy-src + run: | + for os in win linux; do + dotnet publish Source/Server/Server.csproj -c Release -r "$os-x64" --self-contained false -p:UseAppHost=true -o "../artifacts/server/Server/$(if [[ $os == win ]]; then echo Windows; else echo Linux; fi)" + done + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: inputs.include-servers + with: + name: legacy-server-${{ inputs.artifact-suffix }} + path: artifacts/server/ + retention-days: 7 + if-no-files-found: error + package: + needs: [current, legacy] + runs-on: ubuntu-latest + outputs: + mod-artifact: ${{ steps.names.outputs.mod }} + server-artifact: ${{ steps.names.outputs.server }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@v4 + with: + name: current-mod-${{ inputs.artifact-suffix }} + path: bundle/current + - uses: actions/download-artifact@v4 + with: + name: legacy-mod-${{ inputs.artifact-suffix }} + path: bundle/legacy + - name: Package mod + uses: ./.github/actions/package-mod + with: + destination: artifacts/mod/Multiplayer + current-root: bundle/current + legacy-root: bundle/legacy + display-name: ${{ inputs.display-name }} + stamp-version: ${{ inputs.stamp-version }} + - id: names + env: + SUFFIX: ${{ inputs.artifact-suffix }} + run: | + echo "mod=Multiplayer-mod-$SUFFIX" >> "$GITHUB_OUTPUT" + echo "server=Multiplayer-server-$SUFFIX" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ steps.names.outputs.mod }} + path: artifacts/mod/ + retention-days: 7 + if-no-files-found: error + - uses: actions/download-artifact@v4 + if: inputs.include-servers + with: + name: current-server-${{ inputs.artifact-suffix }} + path: artifacts/server + - uses: actions/download-artifact@v4 + if: inputs.include-servers + with: + name: legacy-server-${{ inputs.artifact-suffix }} + path: artifacts/server + - name: Add Linux server launchers + if: inputs.include-servers + run: | + for directory in artifacts/server/Server/Linux artifacts/server/Server/1.7/Linux; do + cat > "$directory/Server.sh" <<'EOF' + #!/usr/bin/env bash + # Run the server beside this script with the supplied arguments. + set -euo pipefail + SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + exec dotnet "$SCRIPT_DIR/Server.dll" "$@" + EOF + chmod +x "$directory/Server.sh" + done + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: inputs.include-servers + with: + name: ${{ steps.names.outputs.server }} + path: artifacts/server/ + retention-days: 7 + if-no-files-found: error diff --git a/.github/workflows/build-workshop.yml b/.github/workflows/build-workshop.yml index 4b79bbcdd..05241feb1 100644 --- a/.github/workflows/build-workshop.yml +++ b/.github/workflows/build-workshop.yml @@ -1,47 +1,14 @@ name: Build workshop - on: workflow_dispatch: push: - tags: - - 'v*' - + tags: ['v*'] +permissions: + contents: read jobs: build: - name: Build workshop - runs-on: ubuntu-latest - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - submodules: recursive # Updates the Languages submodule - - - name: Set up .NET - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - global-json-file: global.json - cache: true - cache-dependency-path: '**/*.csproj' - - - name: Build versioned mod - run: | - for version in 1.6 1.7; do - dotnet build Source/Client/Multiplayer.csproj --configuration Release -p:RimWorldVersion="$version" - done - - - name: Package mod files - uses: ./.github/actions/package-mod - with: - destination: output/Multiplayer - stamp-version: 'true' - - - id: get_version - run: echo "VERSION=${GITHUB_REF_NAME//\//-}" >> "$GITHUB_OUTPUT" - - - name: Upload mod artifacts - uses: actions/upload-artifact@v4 - with: - name: Multiplayer-${{ steps.get_version.outputs.VERSION }} - path: | - output/ + uses: ./.github/workflows/build-packages.yml + with: + artifact-suffix: workshop-${{ github.run_id }} + stamp-version: true + include-servers: false diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index a61c9c833..4718f95b6 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -12,7 +12,6 @@ concurrency: cancel-in-progress: true env: - DOTNET_ROLL_FORWARD: Major DOTNET_NOLOGO: true DOTNET_CLI_TELEMETRY_OPTOUT: true @@ -21,10 +20,6 @@ jobs: name: Builds runs-on: ubuntu-latest timeout-minutes: 15 - strategy: - fail-fast: false - matrix: - rimworld: ['1.6', '1.7'] steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -39,18 +34,17 @@ jobs: cache-dependency-path: '**/*.csproj' - name: Restore dependencies - run: dotnet restore Source/Multiplayer.sln -p:RimWorldVersion=${{ matrix.rimworld }} + run: dotnet restore Source/Multiplayer.sln - name: Build debug - run: dotnet build Source/Multiplayer.sln -c Debug --no-restore -p:RimWorldVersion=${{ matrix.rimworld }} + run: dotnet build Source/Multiplayer.sln -c Debug --no-restore - name: Build release - run: dotnet build Source/Multiplayer.sln -c Release --no-restore -p:RimWorldVersion=${{ matrix.rimworld }} + run: dotnet build Source/Multiplayer.sln -c Release --no-restore - name: Run tests run: >- dotnet test Source/Tests/Tests.csproj -c Release --no-build --no-restore - -p:RimWorldVersion=${{ matrix.rimworld }} --logger "trx;LogFileName=TestResults.trx" --results-directory artifacts/test-results @@ -58,87 +52,15 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: test-results-rimworld-${{ matrix.rimworld }} + name: test-results-rimworld-1.7 path: artifacts/test-results/ retention-days: 7 if-no-files-found: warn overwrite: true - # Independent of test results so a buildable PR can still supply a reproduction package. + # Independent of tests so buildable PRs still supply reproduction packages. package-artifacts: - name: Package mod and server artifacts - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - submodules: recursive - - - name: Compute short commit SHA - id: vars - run: echo "short_sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" - - - name: Set up .NET - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - global-json-file: global.json - cache: true - cache-dependency-path: '**/*.csproj' - - - name: Build mod variants (Release) - run: | - for version in 1.6 1.7; do - dotnet restore Source/Client/Multiplayer.csproj -p:RimWorldVersion="$version" - dotnet build Source/Client/Multiplayer.csproj -c Release --no-restore -p:RimWorldVersion="$version" - done - - # Keep the existing 1.6 server paths; put the .NET 10 / 1.7 servers in their own folder. - - name: Publish servers (Windows) - run: | - dotnet publish Source/Server/Server.csproj -c Release --runtime win-x64 --self-contained false -p:UseAppHost=true -p:RimWorldVersion=1.6 -o artifacts/server/Server/Windows - dotnet publish Source/Server/Server.csproj -c Release --runtime win-x64 --self-contained false -p:UseAppHost=true -p:RimWorldVersion=1.7 -o artifacts/server/Server/1.7/Windows - - - name: Publish servers (Linux) - run: | - dotnet publish Source/Server/Server.csproj -c Release --runtime linux-x64 --self-contained false -p:UseAppHost=true -p:RimWorldVersion=1.6 -o artifacts/server/Server/Linux - dotnet publish Source/Server/Server.csproj -c Release --runtime linux-x64 --self-contained false -p:UseAppHost=true -p:RimWorldVersion=1.7 -o artifacts/server/Server/1.7/Linux - - - name: Add server launchers - run: | - for directory in artifacts/server/Server/Linux artifacts/server/Server/1.7/Linux; do - cat > "$directory/Server.sh" <<'EOF' - #!/usr/bin/env bash - # Launch the published server DLL beside this script with the given arguments. - set -euo pipefail - SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" - exec dotnet "$SCRIPT_DIR/Server.dll" "$@" - EOF - chmod +x "$directory/Server.sh" - done - - - name: Package mod files - uses: ./.github/actions/package-mod - with: - destination: artifacts/mod/Multiplayer - display-name: "Multiplayer [PR #${{ github.event.pull_request.number }}]" - - # Upload the parent directories so downloaded ZIPs have Multiplayer/ or Server/ at root. - - name: Upload mod artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: Multiplayer-mod-pr${{ github.event.pull_request.number }}-${{ steps.vars.outputs.short_sha }} - path: artifacts/mod/ - retention-days: 7 - if-no-files-found: error - overwrite: true - - - name: Upload server artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: Multiplayer-server-pr${{ github.event.pull_request.number }}-${{ steps.vars.outputs.short_sha }} - path: artifacts/server/ - retention-days: 7 - if-no-files-found: error - overwrite: true + uses: ./.github/workflows/build-packages.yml + with: + artifact-suffix: pr${{ github.event.pull_request.number }}-${{ github.sha }} + display-name: "Multiplayer [PR #${{ github.event.pull_request.number }}]" diff --git a/About/About.xml b/About/About.xml index b02a2fd1b..1014caa8e 100644 --- a/About/About.xml +++ b/About/About.xml @@ -3,28 +3,21 @@ rwmt.Multiplayer Multiplayer -
  • 1.6
  • 1.7
  • RimWorld Multiplayer Team https://github.com/rwmt/Multiplayer <b>Important: This mod should be placed right below Core and expansions in the mod list to work properly! -Requires RimWorld >= 1.6.4491</b>\n +RimWorld 1.7 requires Harmony 3</b>\n Multiplayer mod for RimWorld. FAQ - https://hackmd.io/@rimworldmultiplayer/docs/ Discord - https://discord.gg/S4bxXpv - - -
  • zetrith.prepatcherPrepatchersteam://url/CommunityFilePage/2934420800
  • -
  • brrainz.harmonyHarmony 2 (via Prepatcher)steam://url/CommunityFilePage/2009463077
  • -
    - -
  • brrainz.harmonyHarmony 3steam://url/CommunityFilePage/2009463077
  • -
    -
    + +
  • brrainz.harmonyHarmony 3steam://url/CommunityFilePage/2009463077
  • +
  • Ludeon.RimWorld
  • Ludeon.RimWorld.Royalty
  • @@ -32,7 +25,6 @@ Discord - https://discord.gg/S4bxXpv
  • Ludeon.RimWorld.Biotech
  • Ludeon.RimWorld.Anomaly
  • Ludeon.RimWorld.Odyssey
  • -
  • zetrith.prepatcher
  • brrainz.harmony
  • diff --git a/LoadFolders.xml b/LoadFolders.xml index be5dd2fb7..4eef74656 100644 --- a/LoadFolders.xml +++ b/LoadFolders.xml @@ -1,11 +1,4 @@ - -
  • /
  • -
  • 1.6
  • -
    - -
  • /
  • -
  • 1.7
  • -
    +
  • /
  • 1.7
  • diff --git a/MajorReleaseProcess.txt b/MajorReleaseProcess.txt index e2b1d95c1..838fa5a77 100644 --- a/MajorReleaseProcess.txt +++ b/MajorReleaseProcess.txt @@ -1,7 +1,7 @@ 1. Check the mod version in Source/Common/Version.cs. -2. Check supported RimWorld versions in About/About.xml and LoadFolders.xml. +2. Check supported RimWorld versions in About/About.xml and the legacy source ref in .github/workflows/build-packages.yml. 3. Check About/Preview.png. 4. Preserve a release branch for older RimWorld versions when dropping support. 5. Push the release tag (v*) or manually run the Build workshop workflow. -6. Download the Multiplayer- artifact from the workflow run. It contains Multiplayer/ with both supported version folders and a generated modVersion. +6. Download the Multiplayer-mod-workshop- artifact from the workflow run. It contains Multiplayer/ with both supported version folders and a generated modVersion. 7. Use the downloaded ZIP for GitHub releases and the extracted Multiplayer/ folder for the Steam Workshop upload. diff --git a/README.md b/README.md index e888f5831..a3bff9495 100644 --- a/README.md +++ b/README.md @@ -20,123 +20,44 @@ The RimWorld Multiplayer mod allows users to play full games of Rimworld coopera Please do all pull requests to the [dev](https://github.com/rwmt/Multiplayer/tree/dev) branch. -## Building for RimWorld 1.6 and 1.7 +## Building for RimWorld 1.7 -The root `global.json` sets SDK 10.0.302 as the minimum and allows roll-forward -to newer installed .NET 10 minor versions, feature bands, and patches. Select game -references with `RimWorldVersion`; the selector defaults to `1.6` and accepts -`1.6` or `1.7`. It separates restore assets and outputs under version-specific -`bin/` and `obj/` directories. The 1.6 test project targets .NET 8, so the test -commands use `DOTNET_ROLL_FORWARD=Major` separately to run it on the .NET 10 runtime. +This source branch targets RimWorld 1.7, .NET 10, and Harmony 3. SDK selection +comes from `global.json`; later installed .NET 10 SDKs are allowed by `latestMinor`. +Prepatcher is not required. Normal builds have no game-version selector. ```sh -dotnet build Source/Multiplayer.sln -c Release -p:RimWorldVersion=1.6 -dotnet build Source/Multiplayer.sln -c Release -p:RimWorldVersion=1.7 -DOTNET_ROLL_FORWARD=Major dotnet test Source/Tests/Tests.csproj -c Release --no-build -p:RimWorldVersion=1.6 -DOTNET_ROLL_FORWARD=Major dotnet test Source/Tests/Tests.csproj -c Release --no-build -p:RimWorldVersion=1.7 +dotnet build Source/Multiplayer.sln -c Release +dotnet test Source/Tests/Tests.csproj -c Release --no-build +./run-local-tests.sh ``` -RimWorld 1.6 builds target .NET Framework 4.8 and use Harmony 2.4.2 with -Prepatcher. RimWorld 1.7 builds target .NET 10, use Harmony 3.0.0-rc.3, and patch -ritual handlers at runtime without Prepatcher. Install the matching Harmony and -Prepatcher mods. `LoadFolders.xml` selects the corresponding `1.6/` or `1.7/` DLLs; -shared assets remain at the mod root. When migrating from a flat install, remove old -root `Assemblies/` and `AssembliesCustom/` folders after backing up any local changes. - -Version-specific C# implementations live under `Source/1.6/Client/`, -`Source/1.6/MultiplayerLoader/`, `Source/1.7/Client/`, and -`Source/1.7/MultiplayerLoader/`. MSBuild selects exactly one tree for each build; -put shared code in the existing project folders and version-only code in the matching -tree. - -For example, a client implementation can keep the same namespace, class name, -and internal API in both version trees while the shared caller stays in -`Source/Client/`: - -```csharp -// Source/1.6/Client/RuntimeHooks.cs -namespace Multiplayer; -internal static class RuntimeHooks -{ - internal static bool RequiresPrepatcher => true; -} -``` - -```csharp -// Source/1.7/Client/RuntimeHooks.cs -namespace Multiplayer; -internal static class RuntimeHooks -{ - internal static bool RequiresPrepatcher => false; -} -``` - -```csharp -// Source/Client/RuntimeSetup.cs -namespace Multiplayer; -internal static class RuntimeSetup -{ - internal static void Configure() - { - if (RuntimeHooks.RequiresPrepatcher) InstallPrepatcherHooks(); - else InstallRuntimeHooks(); - } - - private static void InstallPrepatcherHooks() { /* shared setup */ } - private static void InstallRuntimeHooks() { /* shared setup */ } -} -``` - -`Source/Directory.Build.targets` maps the `Multiplayer` client project to -`Client/` and includes only `Source//Client/**/*.cs`. The same -mapping selects `MultiplayerLoader` from `MultiplayerLoader/` and `Tests` from -`Tests/`. No extra `.csproj` entries or preprocessor directives are needed; -keep the version-specific class out of shared `Source/Client/` so the selected -implementation is defined once. Build the client for either version with: - -```sh -dotnet build Source/Client/Multiplayer.csproj -c Release -p:RimWorldVersion=1.6 -dotnet build Source/Client/Multiplayer.csproj -c Release -p:RimWorldVersion=1.7 -``` - -The **Build workshop** workflow builds and packages both supported game versions directly. - -### Local runtime probes - -To run the assembly-dependent local probes for RimWorld 1.6 and 1.7, use -`run-local-tests.sh` with the installed 1.6 game `Managed` directory, Prepatcher -`Assemblies` directory, Harmony 1.6 `Assemblies` directory, and optionally a -mod root containing already-built `1.6/` and `1.7/` assemblies: - -```sh -./run-local-tests.sh \ - "/path/to/RimWorld-1.6/Managed" \ - "/path/to/Prepatcher/Assemblies" \ - "/path/to/Harmony/1.6/Assemblies" \ - "/path/to/Multiplayer" -``` - -The runner requires Docker and Python 3 and does not build or deploy the mod -DLLs. Build both variants before running, or pass a mod root containing both -built variants. It runs the 1.6 ritual-prefix and reflection-only Mono probes -and the 1.7 metadata audit. These do not launch RimWorld or verify a full -host/join session. Windows Mono `TestsOnMono.exe` remains a separate manual test; see -[TestsOnMono](Source/TestsOnMono/README.md). - -The [version-specific ritual checks](Source/RuntimeTests/README.md) cover the 1.6 -registrar and direct `PrepatcherPrefix` dispatch under Mono/Harmony 2, including -normal and psychic handlers and serializer registration. The probe stubs `DoSync` -and calls the prefix directly; it does not exercise Prepatcher's Cecil injection or a -full game session. The 1.7 check reads .NET 10, RimWorld, and Harmony assembly metadata -without running the game assemblies. Neither probe tests live RimWorld 1.7 CoreCLR -loading, host/join networking, or gameplay. - -Pull requests build both game versions using the .NET 10 SDK. Download the -`Multiplayer-mod-pr-` and `Multiplayer-server-pr-` -artifacts from the **Validate Pull Request** Actions run; downloads are retained -for seven days. The mod artifact extracts to `Multiplayer/` with both version -folders. The server artifact includes Windows/Linux builds for both game versions, -with the .NET 10 / 1.7 servers under `Server/1.7/`. Test results are uploaded separately. +The client build stages resolved mod dependencies into `1.7/Assemblies` and +`1.7/AssembliesCustom`. Game and Harmony DLLs are supplied by the game/mod +installation and must not be packaged. See [the metadata audit](Source/RuntimeTests/README.md) +for local validation and its limitations. Full host/join, reload/rejoin, stockpile +filter/cache, and ritual scenarios still require live game testing. + +## Legacy versions and CI packages + +Legacy compatibility is a packaging concern. PR, beta, and workshop workflows +call `.github/workflows/build-packages.yml`. Independent jobs build this branch +and upstream's `rw-1.6` branch, when present. Until that branch exists, the legacy +job uses commit `9df94bd04ea9a94c00bd39a276e58c4c2f965740`. A branch-check network +failure fails the job rather than silently selecting the fallback. Its resolved +SHA is recorded in the artifact's `1.6/source-commit.txt`. + +Legacy code is built unchanged with its own references. Backports must be made +explicitly on the legacy source branch; changes here do not automatically affect +1.6. A shared GitHub action assembles independently built version folders, keeping +each version's Defs, Languages, Textures, assemblies, and dependency declarations. +Only release metadata is shared at the package root. `LoadFolders.xml` selects the +correct assets and DLLs. No standalone bundler script is used. + +PR runs upload mod, server, and test-result artifacts for seven days. Server +artifacts retain .NET 8 legacy servers under `Server/Windows` and `Server/Linux`, +with current .NET 10 servers under `Server/1.7/Windows` and `Server/1.7/Linux`. +The workshop workflow stamps the mod version and uploads the combined mod package. ## Donations diff --git a/Source/1.6/Client/MonoCompatibility.cs b/Source/1.6/Client/MonoCompatibility.cs deleted file mode 100644 index 160967173..000000000 --- a/Source/1.6/Client/MonoCompatibility.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Runtime.CompilerServices; -using System.Text; -using HarmonyLib; -using Multiplayer.Common; -using UnityEngine; -using Verse; - -namespace Multiplayer.Client -{ - static partial class UIRootPatch - { - [MethodImpl(MethodImplOptions.NoInlining)] - static partial void SetMonoFontNames() - { - if (!Native.IsMonoRuntime) - return; - - Text.fontStyles[1].font.fontNames = new[] { "arial", "arialbd", "ariali", "arialbi" }; - } - } -} - -namespace Multiplayer.Client.Util -{ - public static partial class MpUI - { - [MethodImpl(MethodImplOptions.NoInlining)] - static partial void UpdateMonoPasswordCursor(TextEditor editor) - { - if (!Native.IsMonoRuntime) - return; - - AccessTools.Field(typeof(TextEditor), "m_RevealCursor").SetValue(editor, true); - editor.UpdateScrollOffsetIfNeeded(new Event()); - } - } -} - -namespace Multiplayer.Client.Desyncs -{ - public partial class SaveableDesyncInfo - { - [MethodImpl(MethodImplOptions.NoInlining)] - partial void SetMonoGraphicsDeviceVendor(StringBuilder gpuFamily) - { - gpuFamily.Clear(); - gpuFamily.Append(SystemInfo.graphicsDeviceVendor); - } - } -} diff --git a/Source/1.6/Client/SyncMethod.Prepatcher.cs b/Source/1.6/Client/SyncMethod.Prepatcher.cs deleted file mode 100644 index 811fe2cfd..000000000 --- a/Source/1.6/Client/SyncMethod.Prepatcher.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Multiplayer.Client; - -public partial class SyncMethod : MultiplayerLoader.ISyncMethodForPrepatcher -{ -} diff --git a/Source/1.6/MultiplayerLoader/ISyncMethodForPrepatcher.cs b/Source/1.6/MultiplayerLoader/ISyncMethodForPrepatcher.cs deleted file mode 100644 index 7146a3f57..000000000 --- a/Source/1.6/MultiplayerLoader/ISyncMethodForPrepatcher.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; -using Multiplayer.API; - -namespace MultiplayerLoader; - -public interface ISyncMethodForPrepatcher : ISyncMethod -{ - Type TargetType { get; } -} diff --git a/Source/1.6/MultiplayerLoader/Multiplayer.ReflectionOnlyUnity.cs b/Source/1.6/MultiplayerLoader/Multiplayer.ReflectionOnlyUnity.cs deleted file mode 100644 index 9ad563eb7..000000000 --- a/Source/1.6/MultiplayerLoader/Multiplayer.ReflectionOnlyUnity.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.IO; -using UnityEngine; - -namespace MultiplayerLoader; - -public partial class Multiplayer -{ - partial void InitializeVersionRuntime() => - ReflectionOnlyUnityResolver.Install(Path.GetDirectoryName(typeof(Application).Assembly.Location)); -} diff --git a/Source/1.6/MultiplayerLoader/Prepatches.cs b/Source/1.6/MultiplayerLoader/Prepatches.cs deleted file mode 100644 index 3797ed4af..000000000 --- a/Source/1.6/MultiplayerLoader/Prepatches.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using Mono.Cecil; -using Mono.Cecil.Cil; -using MonoMod.Utils; -using Prepatcher; -using Multiplayer.Common; -using RimWorld; - -namespace MultiplayerLoader; - -public static class Prepatches -{ - [FreePatch] - static void DontSetResolution(ModuleDefinition module) - { - if (MpVersion.IsDebug) - { - var resolutionUtilityType = module.ImportReference(typeof(ResolutionUtility)).Resolve(); - resolutionUtilityType.FindMethod(nameof(ResolutionUtility.SetResolutionRaw))!.Body.Instructions.Insert( - 0, - Instruction.Create(OpCodes.Ret) - ); - } - } - - [FreePatch] - static void ApplySyncPrepatches(ModuleDefinition module) - { - SyncRituals.ApplyPrepatches(module); - } - - internal static void AddPrepatcherPrefix(ModuleDefinition module, int id, MethodInfo target, MethodInfo prefix) - { - var targetMethod = module.ImportReference(target).Resolve(); - var firstInst = targetMethod.Body.Instructions.First(); - - IEnumerable PrefixInstructions() - { - yield return Instruction.Create(OpCodes.Ldc_I4, id); // Load index - yield return Instruction.Create(OpCodes.Ldarg_0); // Load __instance - - // Create __args array - yield return Instruction.Create(OpCodes.Ldc_I4, target.GetParameters().Length); - yield return Instruction.Create(OpCodes.Newarr, module.TypeSystem.Object); - - int idx = 0; - foreach (var pInfo in target.GetParameters()) - { - var pType = pInfo.ParameterType; - - yield return Instruction.Create(OpCodes.Dup); - yield return Instruction.Create(OpCodes.Ldc_I4, idx); - yield return Instruction.Create(OpCodes.Ldarg, targetMethod.Parameters.ElementAt(idx)); - - if (pType.IsByRef) - throw new Exception("Byrefs aren't handled"); - if (pType.IsValueType) - yield return Instruction.Create(OpCodes.Box, module.ImportReference(pType)); - - yield return Instruction.Create(OpCodes.Stelem_Ref); - - idx++; - } - - yield return Instruction.Create(OpCodes.Call, module.ImportReference(prefix)); - yield return Instruction.Create(OpCodes.Brtrue, firstInst); - - if (target.ReturnType == typeof(bool)) - yield return Instruction.Create(OpCodes.Ldc_I4_0); - else if (target.ReturnType == typeof(void)) - yield return Instruction.Create(OpCodes.Nop); - else - throw new Exception($"Can't handle return type {target.ReturnType}"); - - yield return Instruction.Create(OpCodes.Ret); - } - - targetMethod.Body.Instructions.InsertRange(0, PrefixInstructions()); - } -} diff --git a/Source/1.6/MultiplayerLoader/ReflectionOnlyUnityResolver.cs b/Source/1.6/MultiplayerLoader/ReflectionOnlyUnityResolver.cs deleted file mode 100644 index 99b5b4eaa..000000000 --- a/Source/1.6/MultiplayerLoader/ReflectionOnlyUnityResolver.cs +++ /dev/null @@ -1,54 +0,0 @@ -#nullable enable -using System; -using System.IO; -using System.Reflection; - -namespace MultiplayerLoader; - -internal sealed class ReflectionOnlyUnityResolver -{ - private static bool installed; - private readonly string managedDirectory; - - internal ReflectionOnlyUnityResolver(string managedDirectory) - { - this.managedDirectory = managedDirectory; - } - - internal static void Install(string managedDirectory) - { - if (installed) - return; - - var resolver = new ReflectionOnlyUnityResolver(managedDirectory); - AppDomain.CurrentDomain.ReflectionOnlyAssemblyResolve += resolver.Resolve; - installed = true; - } - - internal Assembly? Resolve(object? sender, ResolveEventArgs args) - { - var requested = new AssemblyName(args.Name); - var name = requested.Name; - if (name == null || (name != "UnityEngine" && !name.StartsWith("UnityEngine.", StringComparison.Ordinal))) - return null; - - // Prepatcher leaves retired assemblies in Mono's reflection-only context. - // Unity's later scene cleanup may inspect types whose input-module dependency - // was never bound there. Reuse the normal Unity type identity, not a second copy. - foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) - if (!assembly.ReflectionOnly && Matches(requested, assembly.GetName())) - return assembly; - - if (Path.GetFileName(name) != name) - return null; - - var path = Path.Combine(managedDirectory, name + ".dll"); - if (!File.Exists(path) || !Matches(requested, AssemblyName.GetAssemblyName(path))) - return null; - - return Assembly.LoadFrom(path); - } - - private static bool Matches(AssemblyName requested, AssemblyName candidate) => - string.Equals(requested.FullName, candidate.FullName, StringComparison.OrdinalIgnoreCase); -} diff --git a/Source/1.6/MultiplayerLoader/SyncRituals.Prepatcher.cs b/Source/1.6/MultiplayerLoader/SyncRituals.Prepatcher.cs deleted file mode 100644 index 05208fcfd..000000000 --- a/Source/1.6/MultiplayerLoader/SyncRituals.Prepatcher.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using HarmonyLib; -using Mono.Cecil; -using Multiplayer.API; -using RimWorld; - -namespace MultiplayerLoader; - -public static partial class SyncRituals -{ - private static List syncMethodsWithPrepatches = new(); - - public static bool PrepatcherPrefix(int index, object __instance, object[] __args) - { - var sync = syncMethodsWithPrepatches[index]; - return sync.TargetType != __instance.GetType() || !sync.DoSync(__instance, __args); - } - - public static void Init() => ApplyPrepatches(null); - - public static void ApplyPrepatches(ModuleDefinition module) - { - void Register(string method, Type widgetType, Action postProcess) - { - if (module != null) - { - Prepatches.AddPrepatcherPrefix( - module, - syncMethodsWithPrepatches.Count, - AccessTools.Method(typeof(PawnRoleSelectionWidgetBase<>), method), - AccessTools.Method(typeof(SyncRituals), nameof(PrepatcherPrefix)) - ); - syncMethodsWithPrepatches.Add(null); - return; - } - - var sync = (ISyncMethodForPrepatcher)syncMethod.Invoke(null, [widgetType, method, null]); - postProcess?.Invoke(sync); - syncMethodsWithPrepatches.Add(sync); - } - - RegisterRitualSyncMethods(Register); - } -} diff --git a/Source/1.6/Tests/ReflectionOnlyUnityResolverTest.cs b/Source/1.6/Tests/ReflectionOnlyUnityResolverTest.cs deleted file mode 100644 index fbf640a8a..000000000 --- a/Source/1.6/Tests/ReflectionOnlyUnityResolverTest.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System.Reflection; -using System.Reflection.Emit; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using MultiplayerLoader; - -namespace Tests; - -[NonParallelizable] -public class ReflectionOnlyUnityResolverTest -{ - [Test] - public void ReusesAnExistingUnityAssembly() - { - var assembly = AssemblyBuilder.DefineDynamicAssembly( - new AssemblyName("UnityEngine.Existing" + Guid.NewGuid().ToString("N")) { Version = new Version(1, 0) }, - AssemblyBuilderAccess.Run); - var resolver = new ReflectionOnlyUnityResolver(Path.GetTempPath()); - // CoreCLR exposes a runtime wrapper rather than the AssemblyBuilder itself. - var loaded = AppDomain.CurrentDomain.GetAssemblies().Single(candidate => candidate.FullName == assembly.FullName); - - Assert.That(resolver.Resolve(null, new ResolveEventArgs(assembly.FullName!)), Is.SameAs(loaded)); - } - - [Test] - public void LeavesNonUnityAssembliesToTheirOwnResolver() - { - var resolver = new ReflectionOnlyUnityResolver(Path.GetTempPath()); - - Assert.That(resolver.Resolve(null, new ResolveEventArgs(typeof(object).Assembly.FullName!)), Is.Null); - Assert.That(resolver.Resolve(null, new ResolveEventArgs("Unknown.Mod, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null")), Is.Null); - } - - [Test] - public void MissingUnityAssemblyIsNotSubstituted() - { - var resolver = new ReflectionOnlyUnityResolver(Path.GetTempPath()); - - Assert.That(resolver.Resolve(null, new ResolveEventArgs( - "UnityEngine.Missing" + Guid.NewGuid().ToString("N") + ", Version=1.0.0.0, Culture=neutral, PublicKeyToken=null")), Is.Null); - } - - [Test] - public void LoadsALateUnityDependencyFromTheManagedDirectory() - { - using var fixture = new UnityAssemblyFixture(); - var resolver = new ReflectionOnlyUnityResolver(fixture.Directory); - - var result = resolver.Resolve(null, new ResolveEventArgs(fixture.Identity.FullName)); - - Assert.That(result, Is.Not.Null); - Assert.That(result!.GetName().FullName, Is.EqualTo(fixture.Identity.FullName)); - Assert.That(result.ReflectionOnly, Is.False); - Assert.That(resolver.Resolve(null, new ResolveEventArgs(fixture.Identity.FullName)), Is.SameAs(result)); - } - - [Test] - public void RejectsAnAssemblyWithADifferentVersion() - { - using var fixture = new UnityAssemblyFixture(); - var resolver = new ReflectionOnlyUnityResolver(fixture.Directory); - var requested = new AssemblyName(fixture.Identity.FullName) { Version = new Version(9, 0) }; - - Assert.That(resolver.Resolve(null, new ResolveEventArgs(requested.FullName)), Is.Null); - } - - private sealed class UnityAssemblyFixture : IDisposable - { - public string Directory { get; } = Path.Combine(Path.GetTempPath(), "mp-refonly-" + Guid.NewGuid().ToString("N")); - public AssemblyName Identity { get; } - - public UnityAssemblyFixture() - { - System.IO.Directory.CreateDirectory(Directory); - var name = "UnityEngine.Late" + Guid.NewGuid().ToString("N"); - var tree = CSharpSyntaxTree.ParseText("[assembly: System.Reflection.AssemblyVersion(\"1.0.0.0\")] public class FixtureType { }"); - var compilation = CSharpCompilation.Create(name, [tree], - [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - var file = Path.Combine(Directory, name + ".dll"); - using (var output = File.Create(file)) - { - var result = compilation.Emit(output); - Assert.That(result.Success, Is.True, string.Join(Environment.NewLine, result.Diagnostics)); - } - Identity = AssemblyName.GetAssemblyName(file); - } - - public void Dispose() => System.IO.Directory.Delete(Directory, recursive: true); - } -} diff --git a/Source/Client/Debug/DebugActions.cs b/Source/Client/Debug/DebugActions.cs index 46d05147b..d1946e298 100644 --- a/Source/Client/Debug/DebugActions.cs +++ b/Source/Client/Debug/DebugActions.cs @@ -214,17 +214,10 @@ public static void SaveGame() File.WriteAllBytes($"game_{Multiplayer.username}.xml", data); } - [DebugAction(MultiplayerLocalCategory, name = "Trigger desync", allowedGameStates = AllowedGameStates.Playing)] - public static void TriggerDesync() - { - var logItem = StackTraceLogItemRaw.GetFromPool(); - var trace = logItem.raw; - int hash = 0; - int depth = DeferredStackTracingImpl.TraceImpl(trace, ref hash); - Multiplayer.game.asyncWorldTimeComp.randState++; - Multiplayer.game.sync.TryAddStackTraceForDesyncLogRaw(logItem, depth, hash); - } + + [DebugAction(MultiplayerLocalCategory, name = "Trigger desync", allowedGameStates = AllowedGameStates.Playing)] + public static void TriggerDesync() => Multiplayer.game.asyncWorldTimeComp.randState++; [DebugAction(MultiplayerLocalCategory, name = "Show desync window", allowedGameStates = AllowedGameStates.Playing)] public static void ShowDesync() @@ -359,19 +352,7 @@ static void MultiplayerMethodCallLogger(MethodBase __originalMethod) Debug.Log(__originalMethod.FullDescription()); } - [DebugAction(MultiplayerLocalCategory, "Dump addr info table", allowedGameStates = /* all */ AllowedGameStates.Invalid)] - public static void DumpAddrInfoTable() - { - Log.Message($"Dumping address info table {DeferredStackTracingImpl.hashTable.Entries}"); - foreach (var addrInfo in DeferredStackTracingImpl.hashTable) - { - if (addrInfo.nameHash == 0) continue; - var normalized = $"`{Native.MethodNameNormalizedFromAddr(addrInfo.addr, true)}`"; - var normal = Native.MethodNameFromAddr(addrInfo.addr, true); - var hash = addrInfo.nameHash; - Log.Message($"NORM: {normalized,-130} H:{hash,14} R:`{normal}`"); - } - } + [DebugAction(MultiplayerLocalCategory, "Show mod compat", allowedGameStates = AllowedGameStates.Playing)] public static void ShowModCompatDialog() @@ -382,17 +363,6 @@ public static void ShowModCompatDialog() #if DEBUG - [DebugOutput] - public static unsafe void PrintInlined() - { - foreach (var m in Harmony.GetAllPatchedMethods()) - { - // method->inline_info - if ((*(byte*)(m.MethodHandle.Value + 32) & 1) == 1) - Log.Warning($"Mono inlined {m.FullDescription()} {*(byte*)(m.MethodHandle.Value + 32) & 1} {*((ushort*)(m.MethodHandle.Value) + 1) & (ushort)MethodImplOptions.NoInlining}"); - } - } - [DebugOutput(true)] public static void PrintThingListers() { diff --git a/Source/Client/Desyncs/ClientSyncOpinion.cs b/Source/Client/Desyncs/ClientSyncOpinion.cs index cbc052ccf..47b725edb 100644 --- a/Source/Client/Desyncs/ClientSyncOpinion.cs +++ b/Source/Client/Desyncs/ClientSyncOpinion.cs @@ -53,8 +53,6 @@ join map2 in other.mapStates on map1.mapId equals map2.mapId if (!commandRandomStates.SequenceEqual(other.commandRandomStates)) return "Random state from commands doesn't match"; - if (Native.IsMonoRuntime && !simulating && !other.simulating && desyncStackTraceHashes.Any() && other.desyncStackTraceHashes.Any() && !desyncStackTraceHashes.SequenceEqual(other.desyncStackTraceHashes)) - return "Trace hashes don't match"; return null; } diff --git a/Source/Client/Desyncs/DeferredStackTracing.cs b/Source/Client/Desyncs/DeferredStackTracing.cs index 4b9ad7464..38c5aeb5f 100644 --- a/Source/Client/Desyncs/DeferredStackTracing.cs +++ b/Source/Client/Desyncs/DeferredStackTracing.cs @@ -33,38 +33,25 @@ static IEnumerable TargetMethods() public static void Postfix() { - if (Native.IsMonoRuntime && Native.LmfPtr == 0) return; if (!ShouldAddStackTraceForDesyncLog()) return; var logItem = StackTraceLogItemRaw.GetFromPool(); - if (!Native.IsMonoRuntime) + // Managed stack capture for the CoreCLR runtime. + var frames = new StackTrace(1, false).GetFrames(); + var text = new StringBuilder(); + int managedHash = 17; + int managedDepth = System.Math.Min(frames?.Length ?? 0, StackTraceLogItemRaw.MaxDepth); + for (int i = 0; i < managedDepth; i++) { - // Managed fallback: never interpret CoreCLR addresses as Mono frames. - var frames = new StackTrace(1, false).GetFrames(); - var text = new StringBuilder(); - int managedHash = 17; - int managedDepth = System.Math.Min(frames?.Length ?? 0, StackTraceLogItemRaw.MaxDepth); - for (int i = 0; i < managedDepth; i++) - { - var method = frames[i].GetMethod(); - var name = $"{method?.DeclaringType?.FullName}.{method}"; - text.AppendLine(name); - // Explicit stable hashing; no process-randomized string hashes. - foreach (char c in name) - managedHash = unchecked(managedHash * 31 + c); - } - logItem.managedTrace = text.ToString(); - Multiplayer.game.sync.TryAddStackTraceForDesyncLogRaw(logItem, managedDepth, managedHash); - acc++; - return; + var method = frames[i].GetMethod(); + var name = $"{method?.DeclaringType?.FullName}.{method}"; + text.AppendLine(name); + // Explicit stable hashing; no process-randomized string hashes. + foreach (char c in name) + managedHash = unchecked(managedHash * 31 + c); } - var trace = logItem.raw; - int hash = 0; - // Skip this (DeferredStackTracing.Postfix) frame. Keeping it in the trace doesn't provide any value. - int depth = DeferredStackTracingImpl.TraceImpl(trace, ref hash, skipFrames: 1); - - Multiplayer.game.sync.TryAddStackTraceForDesyncLogRaw(logItem, depth, hash); - + logItem.managedTrace = text.ToString(); + Multiplayer.game.sync.TryAddStackTraceForDesyncLogRaw(logItem, managedDepth, managedHash); acc++; } diff --git a/Source/Client/Desyncs/JittedMethods.cs b/Source/Client/Desyncs/JittedMethods.cs index 17976caf7..0fb3dcf17 100644 --- a/Source/Client/Desyncs/JittedMethods.cs +++ b/Source/Client/Desyncs/JittedMethods.cs @@ -1,116 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Reflection; -using System.Text; -using HarmonyLib; -using JetBrains.Annotations; -using Verse; - namespace Multiplayer.Client.Desyncs; -public class JittedMethod -{ - [CanBeNull] public MethodBase method; - [CanBeNull] public MethodBase from; - [CanBeNull] public int[] mapTicks; - public int worldTicks; - public int timer; - public bool inInterface; - - public string TimeString() - { - return $"m:{mapTicks?.Join(delimiter: ",") ?? "[]"} w:{worldTicks} t:{timer}"; - } - - public override string ToString() - { - return $"{method?.DeclaringType}.{method?.Name} from {from?.DeclaringType}.{from?.Name} {TimeString()} i:{inInterface}"; - } -} - public static class JittedMethods { - private static Queue methodQueue = new(); - private static IntPtr profiler; - private static bool adding; - - public static void Init() - { - if (!Native.IsMonoRuntime) return; - profiler = Native.mono_profiler_create(IntPtr.Zero); - Native.mono_profiler_set_jit_done_callback(profiler, (_, method, _) => - { - if (adding) return; - - try - { - adding = true; - - if (UnityData.IsInMainThread && Multiplayer.settings != null && Native.mono_method_get_token(method) != 0) - { - var methodBase = Native.GetMethodBaseFromRuntimePointer(method); - methodQueue.Enqueue(new JittedMethod - { - method = methodBase, - from = new StackTrace().GetFrame(1)?.GetMethod(), - mapTicks = Multiplayer.game?.asyncTimeComps.Select(c => c.mapTicks).ToArray(), - worldTicks = Multiplayer.game?.asyncWorldTimeComp?.worldTicks ?? -1, - timer = TickPatch.Timer, - inInterface = Multiplayer.InInterface - }); - - if (methodQueue.Count > Multiplayer.settings.jittedMethodsInDesync) - methodQueue.Dequeue(); - } - } - finally - { - adding = false; - } - }); - } - - public static void OnApplicationQuit() - { - if (!Native.IsMonoRuntime || profiler == IntPtr.Zero) return; - Native.mono_profiler_set_jit_done_callback(profiler, null); - } - - public static string GetJittedMethodsString() - { - if (!Native.IsMonoRuntime) - return "Mono JIT history is unavailable on this runtime."; - // Two ToArrays to prevent a compilation in between from causing a "Collection was modified" exception - var jittedMethods = methodQueue.ToArray().Select((j, i) => (j, i)).ToArray(); - - var builder = new StringBuilder(); - builder.Append("In simulation:"); // This will get a newline from the != comparison below - var timeString = ""; - - foreach (var j in jittedMethods.Where(j => !j.j.inInterface)) - { - if (timeString != j.j.TimeString()) - builder.AppendLine(); - builder.AppendLine($"{j.i} {j.j}"); - timeString = j.j.TimeString(); - } - - builder.AppendLine(); - builder.AppendLine(); - - timeString = ""; - - builder.Append("In interface:"); - foreach (var j in jittedMethods.Where(j => j.j.inInterface)) - { - if (timeString != j.j.TimeString()) - builder.AppendLine(); - builder.AppendLine($"{j.i} {j.j}"); - timeString = j.j.TimeString(); - } - - return builder.ToString(); - } + public static string GetJittedMethodsString() => "JIT history is unavailable on CoreCLR."; } diff --git a/Source/Client/Desyncs/SaveableDesyncInfo.cs b/Source/Client/Desyncs/SaveableDesyncInfo.cs index 280c0cc84..42ff3547b 100644 --- a/Source/Client/Desyncs/SaveableDesyncInfo.cs +++ b/Source/Client/Desyncs/SaveableDesyncInfo.cs @@ -17,7 +17,7 @@ namespace Multiplayer.Client.Desyncs; -public partial class SaveableDesyncInfo( +public class SaveableDesyncInfo( SyncCoordinator coordinator, ClientSyncOpinion local, ClientSyncOpinion remote, @@ -105,9 +105,6 @@ private string GetLocalTraces() private string GetDesyncDetails() { var desyncInfo = new StringBuilder(); - var gpuFamily = new StringBuilder("Unavailable"); - if (Native.IsMonoRuntime) - SetMonoGraphicsDeviceVendor(gpuFamily); desyncInfo .AppendLine("###Tick Data###") @@ -131,7 +128,7 @@ private string GetDesyncDetails() .AppendLine($"Processor Speed (MHz)|||{SystemInfo.processorFrequency}") .AppendLine($"Thread Count|||{SystemInfo.processorCount}") .AppendLine("\n###GPU Info###") - .AppendLine($"GPU Family|||{gpuFamily}") + .AppendLine("GPU Family|||Unavailable") .AppendLine($"GPU Type|||{SystemInfo.graphicsDeviceType}") .AppendLine($"GPU Name|||{SystemInfo.graphicsDeviceName}") .AppendLine($"GPU VRAM|||{SystemInfo.graphicsMemorySize}") @@ -144,8 +141,6 @@ private string GetDesyncDetails() return desyncInfo.ToString(); } - partial void SetMonoGraphicsDeviceVendor(StringBuilder gpuFamily); - private static string FindFileNameForNextDesyncFile() { const string FilePrefix = "Desync-"; diff --git a/Source/Client/Desyncs/StackTraceLogItem.cs b/Source/Client/Desyncs/StackTraceLogItem.cs index 43d60ef0f..e0c6ec0b6 100644 --- a/Source/Client/Desyncs/StackTraceLogItem.cs +++ b/Source/Client/Desyncs/StackTraceLogItem.cs @@ -38,7 +38,6 @@ public class StackTraceLogItemObj : StackTraceLogItem public class StackTraceLogItemRaw : StackTraceLogItem { public const int MaxDepth = 32; - public long[] raw = new long[MaxDepth]; public int depth; public string managedTrace; @@ -51,27 +50,7 @@ public class StackTraceLogItemRaw : StackTraceLogItem public override string AdditionalInfo => $"{ticksGame} {thingDef}{thingId} {factionName} {depth} {rngState} {moreInfo}"; - private static Dictionary methodNameCache = new(); - - public override string StackTraceString - { - get - { - if (managedTrace != null) return managedTrace; - var builder = new StringBuilder(); - for (int i = 0; i < depth; i++) - { - var addr = raw[i]; - - if (!methodNameCache.TryGetValue(addr, out string method)) - methodNameCache[addr] = method = Native.MethodNameFromAddr(raw[i], false); - - builder.AppendLine(method != null ? SyncCoordinator.MethodNameWithIL(method) : "Null"); - } - - return builder.ToString(); - } - } + public override string StackTraceString => managedTrace ?? string.Empty; public static StackTraceLogItemRaw GetFromPool() { diff --git a/Source/1.7/Client/KeepDebugResolution.cs b/Source/Client/KeepDebugResolution.cs similarity index 100% rename from Source/1.7/Client/KeepDebugResolution.cs rename to Source/Client/KeepDebugResolution.cs diff --git a/Source/Client/Multiplayer.cs b/Source/Client/Multiplayer.cs index c4ccd2794..337a57cc3 100644 --- a/Source/Client/Multiplayer.cs +++ b/Source/Client/Multiplayer.cs @@ -95,18 +95,6 @@ public static void InitMultiplayer(ModContentPack content) #endif modContentPack = content; - Native.EarlyInit( - Application.platform switch - { - RuntimePlatform.LinuxEditor => Native.NativeOS.Linux, - RuntimePlatform.LinuxPlayer => Native.NativeOS.Linux, - RuntimePlatform.OSXEditor => Native.NativeOS.OSX, - RuntimePlatform.OSXPlayer => Native.NativeOS.OSX, - _ => Native.NativeOS.Windows - }); - - DisableOmitFramePointer(); - JittedMethods.Init(); MultiplayerLoader.Multiplayer.settingsWindowDrawer = rect => MpSettingsUI.DoSettingsWindowContents(settings, rect); @@ -148,12 +136,7 @@ public static void InitMultiplayer(ModContentPack content) VersionChecker.Init(); } - [MethodImpl(MethodImplOptions.NoInlining)] - private static void DisableOmitFramePointer() - { - if (!Native.IsMonoRuntime) return; - Native.mini_parse_debug_option("disable_omit_fp"); - } + private static void CheckInterfaceVersions() { diff --git a/Source/Client/Multiplayer.csproj b/Source/Client/Multiplayer.csproj index 61b5cce79..b00e072b1 100644 --- a/Source/Client/Multiplayer.csproj +++ b/Source/Client/Multiplayer.csproj @@ -1,7 +1,6 @@ - $(RimWorldTargetFramework) true true 12 @@ -59,7 +58,7 @@ - bin\$(RimWorldVersion)\$(Configuration)\ + bin\$(Configuration)\ ..\..\ @@ -77,9 +76,9 @@
    - - - + + + diff --git a/Source/Client/MultiplayerStatic.cs b/Source/Client/MultiplayerStatic.cs index 41a6f244f..132074cdc 100644 --- a/Source/Client/MultiplayerStatic.cs +++ b/Source/Client/MultiplayerStatic.cs @@ -48,19 +48,6 @@ public static class MultiplayerStatic static MultiplayerStatic() { - Native.InitLmfPtr( - Application.platform switch - { - RuntimePlatform.LinuxEditor => Native.NativeOS.Linux, - RuntimePlatform.LinuxPlayer => Native.NativeOS.Linux, - RuntimePlatform.OSXEditor => Native.NativeOS.OSX, - RuntimePlatform.OSXPlayer => Native.NativeOS.OSX, - _ => Native.NativeOS.Windows - } - ); - - if (Native.IsMonoRuntime) - Native.HarmonyOriginalGetter = MpUtil.GetOriginalFromHarmonyReplacement; // UnityEngine.Debug.Log instead of Verse.Log.Message because the server runs on its own thread ServerLog.info = str => Debug.Log($"MpServerLog: {str}"); diff --git a/Source/Client/OnMainThread.cs b/Source/Client/OnMainThread.cs index 866c50248..ac082e114 100644 --- a/Source/Client/OnMainThread.cs +++ b/Source/Client/OnMainThread.cs @@ -62,7 +62,6 @@ public void Update() public void OnApplicationQuit() { - JittedMethods.OnApplicationQuit(); Multiplayer.StopMultiplayer(); } diff --git a/Source/Client/Patches/VanillaTweaks.cs b/Source/Client/Patches/VanillaTweaks.cs index 310b4c2c3..441b19f4a 100644 --- a/Source/Client/Patches/VanillaTweaks.cs +++ b/Source/Client/Patches/VanillaTweaks.cs @@ -11,7 +11,7 @@ namespace Multiplayer.Client // Fixes a lag spike when opening debug tools [HarmonyPatch(typeof(UIRoot))] [HarmonyPatch(nameof(UIRoot.UIRootOnGUI))] - static partial class UIRootPatch + static class UIRootPatch { static bool done; @@ -19,11 +19,9 @@ static void Prefix() { if (done) return; GUI.skin.font = Text.fontStyles[1].font; - SetMonoFontNames(); done = true; } - static partial void SetMonoFontNames(); } // Fix window focus handling diff --git a/Source/Client/Settings/MpSettings.cs b/Source/Client/Settings/MpSettings.cs index 03845c5dd..31ae7fdbb 100644 --- a/Source/Client/Settings/MpSettings.cs +++ b/Source/Client/Settings/MpSettings.cs @@ -18,7 +18,6 @@ public class MpSettings : ModSettings public int autosaveSlots = 5; public bool showDevInfo; public bool includeReplayInDesync = VersionChecker.IsContinuousRelease; - public int jittedMethodsInDesync = 1500; public int desyncTracesRadius = 40; public string serverAddress = "127.0.0.1"; public bool appendNameToAutosave; @@ -65,7 +64,6 @@ public override void ExposeData() Scribe_Values.Look(ref autosaveSlots, "autosaveSlots", 5); Scribe_Values.Look(ref showDevInfo, "showDevInfo"); Scribe_Values.Look(ref includeReplayInDesync, "includeReplayInDesync", VersionChecker.IsContinuousRelease); - Scribe_Values.Look(ref jittedMethodsInDesync, "jittedMethodsInDesync", 1500); Scribe_Values.Look(ref desyncTracesRadius, "desyncTracesRadius", 40); Scribe_Values.Look(ref serverAddress, "serverAddress", "127.0.0.1"); Scribe_Values.Look(ref showModCompatibility, "showModCompatibility", true); diff --git a/Source/Client/Settings/MpSettingsUI.cs b/Source/Client/Settings/MpSettingsUI.cs index 4c857ca10..541ae1cd5 100644 --- a/Source/Client/Settings/MpSettingsUI.cs +++ b/Source/Client/Settings/MpSettingsUI.cs @@ -13,7 +13,6 @@ public static class MpSettingsUI { private static string slotsBuffer; private static string desyncRadiusBuffer; - private static string jittedMethodsBuffer; private static Vector2 scrollPosition = Vector2.zero; private static SettingsPage currentPage = SettingsPage.General; @@ -113,7 +112,6 @@ public static void DoGeneralSettings(MpSettings settings, Rect inRect, Rect page listing.CheckboxLabeled("Show debug info", ref settings.showDevInfo); listing.TextFieldNumericLabeled("Desync radius: ", ref settings.desyncTracesRadius, ref desyncRadiusBuffer, 1f, 200f); - listing.TextFieldNumericLabeled("Jitted methods: ", ref settings.jittedMethodsInDesync, ref jittedMethodsBuffer); if (MpVersion.IsDebug && FileAssoc.IsSupported()) { diff --git a/Source/Client/Syncing/Handler/SyncMethod.cs b/Source/Client/Syncing/Handler/SyncMethod.cs index 19c41f0f9..0dd4b308a 100644 --- a/Source/Client/Syncing/Handler/SyncMethod.cs +++ b/Source/Client/Syncing/Handler/SyncMethod.cs @@ -20,7 +20,7 @@ public class SyncTransformer(Type networkType, Delegate writer, Delegate reader) public delegate void SyncMethodWriter(object obj, SyncType type, string debugInfo); - public partial class SyncMethod : SyncHandler, ISyncMethod + public class SyncMethod : SyncHandler, ISyncMethod { public readonly Type targetType; public readonly MethodInfo method; diff --git a/Source/Client/Syncing/Sync.cs b/Source/Client/Syncing/Sync.cs index 50b7cc7be..5ef381126 100644 --- a/Source/Client/Syncing/Sync.cs +++ b/Source/Client/Syncing/Sync.cs @@ -263,7 +263,6 @@ public static SyncMethod RegisterSyncMethod(MethodInfo method, SyncType[] argTyp return (SyncMethod)internalIdToSyncMethod[id]; } - MpUtil.MarkNoInlining(method); var handler = new SyncMethod(method.IsStatic ? null : method.DeclaringType, null, method, argTypes); methodBaseToInternalId[method] = internalIdToSyncMethod.Count; diff --git a/Source/Client/UI/DebugPanel/SyncDebugPanel.cs b/Source/Client/UI/DebugPanel/SyncDebugPanel.cs index 8c5d644f1..c623f86d3 100644 --- a/Source/Client/UI/DebugPanel/SyncDebugPanel.cs +++ b/Source/Client/UI/DebugPanel/SyncDebugPanel.cs @@ -530,8 +530,6 @@ private static float DrawRngDebugSection(float x, float y, float width) DebugLine[] rngLines = [ new("Rand Calls:", $"{DeferredStackTracing.acc}", Color.white), new("Max Trace Depth:", $"{DeferredStackTracing.maxTraceDepth}", Color.white), - new("Hash Entries:", $"{DeferredStackTracingImpl.hashTable.Entries}/{DeferredStackTracingImpl.hashTable.Size}", Color.white), - new("Hash Collisions:", $"{DeferredStackTracingImpl.hashTable.Collisions}", Color.white) ]; return DrawSection(x, y, width, new("RNG & DEBUG", rngLines)); diff --git a/Source/Client/UI/IngameDebug.cs b/Source/Client/UI/IngameDebug.cs index 9f76da958..fa00065e0 100644 --- a/Source/Client/UI/IngameDebug.cs +++ b/Source/Client/UI/IngameDebug.cs @@ -60,7 +60,6 @@ internal static void DoDebugPrintout() text.AppendLine($"\nBuffered Changes: {SyncFieldUtil.bufferedChanges.Sum(kv => kv.Value.Count)}"); text.AppendLine($"Map Cmds: {async.cmds.Count}{separator} World Cmds: {Multiplayer.AsyncWorldTime.cmds.Count}"); text.AppendLine($"Stack Trace: {DeferredStackTracing.acc}{separator} Max Depth: {DeferredStackTracing.maxTraceDepth}"); - text.AppendLine($"Hash: {DeferredStackTracingImpl.hashTable.Entries}/{DeferredStackTracingImpl.hashTable.Size} ({DeferredStackTracingImpl.hashTable.Collisions} collisions)"); text.AppendLine($"\nIdeology: {Find.IdeoManager.classicMode}{separator} Ideo ID: {Find.IdeoManager.IdeosInViewOrder.FirstOrDefault()?.id ?? 0}"); text.AppendLine($"Haul Dest: {Find.CurrentMap.haulDestinationManager.AllHaulDestinationsListForReading.Count}{separator} Designations: {Find.CurrentMap.designationManager.designationsByDef.Count}"); diff --git a/Source/Client/Util/MpUI.cs b/Source/Client/Util/MpUI.cs index b69c6a479..941fad4b6 100644 --- a/Source/Client/Util/MpUI.cs +++ b/Source/Client/Util/MpUI.cs @@ -8,12 +8,8 @@ namespace Multiplayer.Client.Util { - public static partial class MpUI + public static class MpUI { - // RimWorld 1.6 exposes this as a field; Waylith exposes it as a property. - private static readonly FieldInfo editorScrollOffsetField = AccessTools.Field(typeof(TextEditor), "scrollOffset"); - private static readonly PropertyInfo editorScrollOffsetProperty = AccessTools.Property(typeof(TextEditor), "scrollOffset"); - public static Vector2 Resolution => new Vector2(UI.screenWidth, UI.screenHeight); public static string FixedEllipsis() @@ -247,9 +243,7 @@ public static void DoPasswordField(Rect rect, string controlName, ref string pas Vector2 scrollOffset = default; bool keyDown = Event.current.type == EventType.KeyDown; if (te != null) - scrollOffset = (Vector2)(editorScrollOffsetField != null - ? editorScrollOffsetField.GetValue(te) - : editorScrollOffsetProperty.GetValue(te)); + scrollOffset = te.scrollOffset; GUI.SetNextControlName(controlName); @@ -268,16 +262,11 @@ public static void DoPasswordField(Rect rect, string controlName, ref string pas if (te != null && keyDown) { - if (editorScrollOffsetField != null) - editorScrollOffsetField.SetValue(te, scrollOffset); - else - editorScrollOffsetProperty.SetValue(te, scrollOffset); + te.scrollOffset = scrollOffset; te.text = new string(PassChar, password.Length); - UpdateMonoPasswordCursor(te); } } - static partial void UpdateMonoPasswordCursor(TextEditor editor); } public enum ElementOrder diff --git a/Source/Client/Util/MpUtil.cs b/Source/Client/Util/MpUtil.cs index 447ae078c..95ed4c43f 100644 --- a/Source/Client/Util/MpUtil.cs +++ b/Source/Client/Util/MpUtil.cs @@ -16,14 +16,7 @@ namespace Multiplayer.Client { public static class MpUtil { - public static unsafe void MarkNoInlining(MethodBase method) - { - // This offset is a MonoMethod layout, not a CoreCLR MethodDesc. - // Harmony handles detouring on CoreCLR; writing here corrupts memory. - if (!Native.IsMonoRuntime) return; - ushort* iflags = (ushort*)(method.MethodHandle.Value) + 1; - *iflags |= (ushort)MethodImplOptions.NoInlining; - } + public static object NewObjectNoCtor(Type type) { @@ -87,14 +80,6 @@ public static string DelegateMethodInfo(MethodBase m) .Replace("<", "[").Replace(">", "]"); } - public static MethodBase GetOriginalFromHarmonyReplacement(long replacementAddr) - { - // Todo: this is using a non-public API of Harmony, we should refactor to use https://harmony.pardeike.net/api/HarmonyLib.Harmony.html#HarmonyLib_Harmony_GetOriginalMethod_System_Reflection_MethodInfo_ - // as pardeike suggested in https://github.com/rwmt/Multiplayer/pull/270#issuecomment-1003298289 - return HarmonySharedState.originals - .FirstOrDefault(kv => kv.Key.MethodHandle.GetFunctionPointer().ToInt64() == replacementAddr).Value; - } - public static string JoinStringsAtMost(this IEnumerable strs, int atMost = 3) { return strs.Take(atMost).Join() + (strs.Count() > atMost ? "..." : ""); diff --git a/Source/Client/Windows/ServerBrowser.cs b/Source/Client/Windows/ServerBrowser.cs index 81267de58..c2b144838 100644 --- a/Source/Client/Windows/ServerBrowser.cs +++ b/Source/Client/Windows/ServerBrowser.cs @@ -135,11 +135,11 @@ bool Button(Texture2D icon, string labelKey, string tip, Color baseIconColor, fl Application.OpenURL(Multiplayer.DiscordLink); x += 10; - Widgets.Label(new Rect(x, 0, 400, 24), "Note: Multiplayer for 1.6 is a work in progress."); + Widgets.Label(new Rect(x, 0, 400, 24), "Note: Multiplayer for 1.7 is a work in progress."); const string v15Notice = """ - 1.6 and Odyssey compatibility is a work-in-progress. Compatibility with other mods is likely going to take the longest to flesh out. + 1.7 compatibility is a work-in-progress. Compatibility with other mods is likely going to take the longest to flesh out. Downgrade RimWorld to 1.5 if you want to continue playing a stable version. """; diff --git a/Source/Common/Common.csproj b/Source/Common/Common.csproj index e44c44b53..51022113a 100644 --- a/Source/Common/Common.csproj +++ b/Source/Common/Common.csproj @@ -1,7 +1,6 @@ - $(RimWorldCommonTargetFrameworks) true true enable diff --git a/Source/Common/DeferredStackTracingImpl.cs b/Source/Common/DeferredStackTracingImpl.cs deleted file mode 100644 index 92c3a71a0..000000000 --- a/Source/Common/DeferredStackTracingImpl.cs +++ /dev/null @@ -1,377 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Text; -using HarmonyLib; -using Multiplayer.Common; - -namespace Multiplayer.Client.Desyncs; - -public struct AddrTable() : IEnumerable -{ - private const int StartingN = 10; // 1024 - private const int StartingShift = 64 - StartingN; - private const int StartingSize = 1 << StartingN; - private const float LoadFactor = 0.5f; - - private AddrInfo[] hashtable = new AddrInfo[StartingSize]; - - public int Size => hashtable.Length; - public int Entries { get; private set; } = 0; - public int Collisions { get; private set; } = 0; - - private int shift = StartingShift; - - public ref AddrInfo GetOrCreateAddrInfo(long ret) - { - int indexmask = Size - 1; - int index = (int)(HashAddr((ulong)ret) >> shift); - ref var info = ref hashtable[index]; - int colls = 0; - - // Open addressing - while (info.addr != 0 && info.addr != ret) - { - index = (index + 1) & indexmask; - info = ref hashtable[index]; - colls++; - } - if (colls > Collisions) Collisions = colls; - - // When returning an unpopulated AddrInfo, assume it's going to get populated shortly and consider it used - // immediately. - if (info.addr == 0 && Entries++ > Size * LoadFactor) ResizeHashtable(); - return ref info; - } - - private static ulong HashAddr(ulong addr) => ((addr >> 4) | addr << 60) * 11400714819323198485; - - private void ResizeHashtable() - { - var oldTable = hashtable; - - hashtable = new AddrInfo[Size * 2]; - shift--; - Collisions = 0; - - int indexmask = Size - 1; - - for (int i = 0; i < oldTable.Length; i++) - { - ref var oldInfo = ref oldTable[i]; - if (oldInfo.addr != 0) - { - int index = (int)(HashAddr((ulong)oldInfo.addr) >> shift); - - while (hashtable[index].addr != 0) - index = (index + 1) & indexmask; - - ref var newInfo = ref hashtable[index]; - newInfo.addr = oldInfo.addr; - newInfo.stackUsage = oldInfo.stackUsage; - newInfo.nameHash = oldInfo.nameHash; - } - } - } - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - public IEnumerator GetEnumerator() - { - // AsEnumerable needed to get a generic IEnumerable - using var enumerator = hashtable.AsEnumerable().GetEnumerator(); - while (enumerator.MoveNext()) - { - var addr = enumerator.Current; - if (addr.addr == 0) continue; - yield return addr; - } - } -} - -public struct AddrInfo -{ - public long addr; - public long stackUsage; - public long nameHash; -} - -public static class DeferredStackTracingImpl -{ - const long NotJit = long.MaxValue; - const long RbpBased = long.MaxValue - 1; - - const long UsesRbpAsGpr = 1L << 50; - const long UsesRbx = 1L << 51; - const long RbpInfoClearMask = ~(UsesRbpAsGpr | UsesRbx); - - public const int HashInfluence = 6; - - public static AddrTable hashTable = new(); - - public static unsafe int TraceImpl(long[] traceIn, ref int hash, int skipFrames = 0) - { - if (Native.LmfPtr == 0) - return 0; - - long rbp = GetRbp(); - - long stck = rbp; - rbp = *(long*)rbp; - - long lmfPtr = *(long*)Native.LmfPtr; - - int depth = 0; // frames seen - int index = 0; // frames returned through long[] traceIn - while (true) - { - var ret = *(long*)(stck + 8); - ref var info = ref hashTable.GetOrCreateAddrInfo(ret); - if (info.addr == 0) UpdateNewElement(ref info, ret); - - long stackUsage = info.stackUsage; - if (stackUsage == NotJit) - { - // LMF (Last Managed Frame) layout on x64: - // previous - // rbp - // rsp - - lmfPtr = *(long*)lmfPtr; - var lmfRbp = *(long*)(lmfPtr + 8); - - if (lmfPtr == 0 || lmfRbp == 0) - break; - - rbp = lmfRbp; - stck = *(long*)(lmfPtr + 16) - 16; - - continue; - } - - if (depth >= skipFrames) - { - traceIn[index] = ret; - - // info.nameHash == 0 marks methods to skip - if (index < HashInfluence && info.nameHash != 0) hash = HashCombineInt(hash, (int)info.nameHash); - - index++; - } - - // traceIn length limits above all how many frames are visited, not how many are populated. - if (info.nameHash != 0 && ++depth == traceIn.Length) - break; - - if (stackUsage == RbpBased) - { - stck = rbp; - rbp = *(long*)rbp; - continue; - } - - stck += 8; - - if ((stackUsage & UsesRbpAsGpr) != 0) - { - if ((stackUsage & UsesRbx) != 0) - rbp = *(long*)(stck + 16); - else - rbp = *(long*)(stck + 8); - - stackUsage &= RbpInfoClearMask; - } - - stck += stackUsage; - } - - return index; - } - - - private static void UpdateNewElement(ref AddrInfo info, long ret) - { - info.addr = ret; - info.stackUsage = GetStackUsage(ret); - - var normalizedMethodNameBetweenOS = Native.MethodNameNormalizedFromAddr(ret, true); - - info.nameHash = - normalizedMethodNameBetweenOS == null ? 1 : - Native.GetMethodAggressiveInlining(ret) ? 0 : - StableStringHash(normalizedMethodNameBetweenOS); - } - - private static unsafe long GetStackUsage(long addr) - { - var ji = Native.mono_jit_info_table_find(Native.DomainPtr, (IntPtr)addr); - - if (ji == IntPtr.Zero) - return NotJit; - - var start = (uint*)Native.mono_jit_info_get_code_start(ji); - long usage = 0; - - // Emitted at: https://github.com/Unity-Technologies/mono/blob/2022.3.35f1/mono/mini/mini-amd64.c#L7652 - // - diverges into: https://github.com/Unity-Technologies/mono/blob/2022.3.35f1/mono/arch/amd64/amd64-codegen.h#L190-L193 - if ((*start & 0xFFFFFF) == 0xEC8348) // sub rsp,XX (4883EC XX) - { - usage = *start >> 24; - start += 1; - } - // - diverges into: https://github.com/Unity-Technologies/mono/blob/2022.3.35f1/mono/arch/amd64/amd64-codegen.h#L199-L202 - // basically just a long form of the above branch. - else if ((*start & 0xFFFFFF) == 0xEC8148) // sub rsp,XXXXXXXX (4881EC XXXXXXXX) - { - usage = *(uint*)((long)start + 3); - start = (uint*)((long)start + 7); - } - - if (usage != 0) - { - CheckRbpUsage(start, ref usage); - return usage; - } - - // https://github.com/Unity-Technologies/mono/blob/2022.3.35f1/mono/mini/mini-amd64.c#L7559 - // push rbp (55) - if (*(byte*)start == 0x55) - return RbpBased; - - throw new Exception($"Deferred stack tracing: Unknown function header {*start} {Native.MethodNameFromAddr(addr, false)}"); - } - - private static unsafe void CheckRbpUsage(uint* at, ref long stackUsage) - { - // If rbp is used as a gp reg then the prologue looks like (after frame alloc): - // mov [rsp],rbp (48892C24) - // or: - // mov [rsp],rbx (48891C24) - // mov [rsp+8],rbp (48896C2408) - // (The callee saved registers are always in the same order - // and are saved at the bottom of the frame) - - if (*at == 0x242C8948) - { - stackUsage |= UsesRbpAsGpr; - } - else if (*at == 0x241C8948 && *(at + 1) == 0x246C8948) - { - stackUsage |= UsesRbpAsGpr; - stackUsage |= UsesRbx; - } - } - - static DeferredStackTracingImpl() - { - // All of this code assumes that the rbp pointer offset stays the same throughout the method invocations. - // Mono is generally allowed to recompile code, which could cause issues for us, but GetRpb is annotated as - // NoInline and NoOptimization to heavily discourage any changes and avoid breaking. - - var method = AccessTools.DeclaredMethod(typeof(DeferredStackTracingImpl), nameof(GetRbp))!; - var rbpCodeStart = Native.mono_compile_method(method.MethodHandle.Value); - // Add 1 byte because Mono recognizes the jit_info to be just after the code start address returned by - // the compile method. - var jitInfo = Native.mono_jit_info_table_find(Native.DomainPtr, rbpCodeStart + 1); - var instStart = Native.mono_jit_info_get_code_start(jitInfo); - var instLen = Native.mono_jit_info_get_code_size(jitInfo); - // Search for the following instruction: - // mov rax, imm (48b8 ) - // It should directly precede: - // mov [rbp-XX], rax - // From which we can extract the offset from rbp. - byte[] magicBytes = [0x48, 0xb8, ..BitConverter.GetBytes(MagicNumber)]; - unsafe - { - // Make sure we don't access out-of-bounds memory. - // magicBytes.Length -- mov rax, - // sizeof(uint) -- mov [rbp-XX], rax - var maxLen = instLen - magicBytes.Length - sizeof(uint); - for (int i = 0; i < maxLen; i++) - { - byte* at = (byte*)instStart + i; - var matches = ByteSpanEqual(at, magicBytes.Length, magicBytes); - if (!matches) continue; - - uint* match = (uint*)(at + magicBytes.Length); - // mov [rbp-XX], rax (488945XX) - if ((*match & 0xFFFFFF) == 0x458948) - { - offsetFromRbp = (sbyte)(*match >> 24); - return; - } - } - - // To analyze the assembly dump, remove the offset prefixes at the start of each line and paste the hex to - // a site like https://defuse.ca/online-x86-assembler.htm#disassembly2. Choose x64. Search for the - // magic number and compare the code with the loop above. - var asm = HexDump((byte*)instStart, instLen); - ServerLog.Error( - $"Unexpected GetRpb asm structure. Couldn't find a magic bytes match. " + - $"Using fallback offset ({offsetFromRbp}). " + - $"Asm dump for the method: \n{asm}"); - } - } - - private static unsafe bool ByteSpanEqual(byte* start, int len, byte[] arr) - { - for (var i = 0; i < len; i++) - if (*(start + i) != arr[i]) - return false; - return true; - } - - private static unsafe string HexDump(byte* start, int len, int bytesPerLine = 16) - { - var sb = new StringBuilder(); - - for (int i = 0; i < len; i += bytesPerLine) - { - sb.Append($"{i:X4}: "); - - for (int j = 0; j < bytesPerLine && i + j < len; j++) sb.Append($"{start[i + j]:X2} "); - - sb.AppendLine(); - } - - return sb.ToString(); - } - - // Magic number is used to locate the relevant method code. - private const long MagicNumber = 0x0123456789ABCDEF; - private static sbyte offsetFromRbp = -8; - - [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] - private static unsafe long GetRbp() - { - // This variable declaration compiles down to the following IL: - // ldc.i8 - // stloc.0 - // In turn, the second IL instruction compiles to amd64 as: - // mov qword ptr [rbp-XX], rax - // From which we can extract the offset (XX) to reliably calculate the rbp address - long register = MagicNumber; - - return *(long*)((byte*)®ister - offsetFromRbp); - } - - private static int HashCombineInt(int seed, int value) => - (int)(seed ^ (value + 2654435769u + (seed << 6) + (seed >> 2))); - - private static int StableStringHash(string? str) - { - if (str == null) - { - return 0; - } - int num = 23; - int length = str.Length; - for (int i = 0; i < length; i++) - { - num = num * 31 + str[i]; - } - return num; - } -} diff --git a/Source/Common/Native.cs b/Source/Common/Native.cs deleted file mode 100644 index e9c993bc1..000000000 --- a/Source/Common/Native.cs +++ /dev/null @@ -1,275 +0,0 @@ -using System; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using System.Threading; -using HarmonyLib; - -namespace Multiplayer.Client -{ - public static class Native - { - // Waylith uses CoreCLR. Never load Mono libraries or dereference Mono - // thread structures on that runtime, even when running old mod assemblies. - public static bool IsMonoRuntime => Type.GetType("Mono.Runtime") != null; - - public enum NativeOS - { - Windows, OSX, Linux, Dummy - } - - public static IntPtr DomainPtr { get; private set; } - - // LMF is Last Managed Frame - // current_thread->internal_thread->thread_info->tls[4] - public static long LmfPtr { get; private set; } - - public static Func? HarmonyOriginalGetter { get; set; } - - public static void EarlyInit(NativeOS os) - { - if (!IsMonoRuntime) return; - if (os == NativeOS.Linux) - TheLinuxWay(); - if (os == NativeOS.OSX) - TheOSXWay(); - - EarlyInitInternal(); - } - - [MethodImpl(MethodImplOptions.NoInlining)] - static void TheLinuxWay() => mono_dllmap_insert_linux(IntPtr.Zero, MonoWindows, null, MonoLinux, null); - - [MethodImpl(MethodImplOptions.NoInlining)] - static void TheOSXWay() => mono_dllmap_insert_osx(IntPtr.Zero, MonoWindows, null, MonoOSX, null); - - [MethodImpl(MethodImplOptions.NoInlining)] - private static void EarlyInitInternal() - { - DomainPtr = mono_domain_get(); - } - - public static unsafe void InitLmfPtr(NativeOS os) - { - if (!IsMonoRuntime) return; - // Don't bother on 32 bit runtimes - if (IntPtr.Size == 4) - return; - - const BindingFlags all = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | - BindingFlags.NonPublic; - - var internalThreadField = typeof(Thread).GetField("internal_thread", all); - var threadInfoField = internalThreadField.FieldType.GetField("runtime_thread_info", all); - var threadInfoPtr = (long)(IntPtr)threadInfoField.GetValue(internalThreadField.GetValue(Thread.CurrentThread)); - - // Struct offset found manually - // Navigate by string: "Handle Stack" - // Updated for RimWorld 1.6 (Unity 2022.3.35f1, Mono 6.13.0) - if (os == NativeOS.Linux) - LmfPtr = threadInfoPtr + 0x450; // Updated: 1.5 was 0x460, -16 bytes = 0x450 (following Windows pattern) - else if (os == NativeOS.Windows) - LmfPtr = threadInfoPtr + 0x418; // Updated for 1.6. Seems to work so far. - else if (os == NativeOS.OSX) - LmfPtr = threadInfoPtr + 0x3E8; // Updated: 1.5 was 0x3F8, -16 bytes = 0x3E8 (following Windows pattern) - else if (os == NativeOS.Dummy) - { - LmfPtr = (long)Marshal.AllocHGlobal(3 * 8); - *(long*)LmfPtr = LmfPtr; - *(long*)(LmfPtr + 8) = 0; - } - } - - private static IntPtr MaybeFindHarmonyOriginalMethod(long addr, bool harmonyOriginals) - { - var ji = mono_jit_info_table_find(DomainPtr, (IntPtr)addr); - if (ji == IntPtr.Zero) return IntPtr.Zero; - - var ptr = mono_jit_info_get_method(ji); - var codeStart = (long)mono_jit_info_get_code_start(ji); - - if (harmonyOriginals && HarmonyOriginalGetter != null) - { - var original = HarmonyOriginalGetter(codeStart); - if (original != null) - ptr = original.MethodHandle.Value; - } - - return ptr; - } - - public static string? MethodNameFromAddr(long addr, bool harmonyOriginals) - { - var ptr = MaybeFindHarmonyOriginalMethod(addr, harmonyOriginals); - if (ptr == IntPtr.Zero) return null; - var name = mono_debug_print_stack_frame(ptr, -1, DomainPtr); - return string.IsNullOrEmpty(name) ? null : name; - } - - public static string? MethodNameNormalizedFromAddr(long addr, bool harmonyOriginals) - { - var ptr = MaybeFindHarmonyOriginalMethod(addr, harmonyOriginals); - return ptr == IntPtr.Zero ? null : mono_method_get_reflection_name(ptr); - } - - private static ConstructorInfo runtimeMethodHandleCtor = AccessTools.Constructor(typeof(RuntimeMethodHandle), [typeof(IntPtr)]); - - public static bool GetMethodAggressiveInlining(long addr) - { - var domain = DomainPtr; - var ji = mono_jit_info_table_find(domain, (IntPtr)addr); - - if (ji == IntPtr.Zero) return false; - - var methodHandle = mono_jit_info_get_method(ji); - var methodBase = GetMethodBaseFromRuntimePointer(methodHandle); - - if (methodBase == null) return false; - - return (methodBase.MethodImplementationFlags & MethodImplAttributes.AggressiveInlining) != 0; - } - - public static MethodBase? GetMethodBaseFromRuntimePointer(IntPtr methodHandle) - { - if (methodHandle == IntPtr.Zero) - return null; - var rmh = (RuntimeMethodHandle)runtimeMethodHandleCtor.Invoke([methodHandle]); - var rth = new RuntimeTypeHandle(); - var methodBase = MethodBase.GetMethodFromHandle(rmh, rth); - return methodBase; - } - - // const string MonoWindows = "mono-2.0-sgen.dll"; - const string MonoWindows = "mono-2.0-bdwgc"; - const string MonoLinux = "libmonobdwgc-2.0.so"; - const string MonoOSX = "libmonobdwgc-2.0.dylib"; - - [DllImport(MonoLinux, EntryPoint = "mono_dllmap_insert")] - private static extern void mono_dllmap_insert_linux(IntPtr assembly, string? dll, string? func, string? tdll, string? tfunc); - - [DllImport(MonoOSX, EntryPoint = "mono_dllmap_insert")] - private static extern void mono_dllmap_insert_osx(IntPtr assembly, string? dll, string? func, string? tdll, string? tfunc); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_jit_info_table_find(IntPtr domain, IntPtr addr); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_jit_info_get_method(IntPtr ji); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_jit_info_get_code_start(IntPtr ji); - - [DllImport(MonoWindows)] - public static extern int mono_jit_info_get_code_size(IntPtr ji); - - [DllImport(MonoWindows)] - public static extern string mono_debug_print_stack_frame(IntPtr method, int nativeOffset, IntPtr domain); - - [DllImport(MonoWindows)] - public static extern int mini_parse_debug_option(string option); - - [DllImport(MonoWindows)] - public static extern void mono_set_defaults(int verboseLevel, int opts); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_domain_get(); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_compile_method(IntPtr method); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_class_from_mono_type(IntPtr type); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_class_vtable(IntPtr domain, IntPtr klass); - - [DllImport(MonoWindows)] - public static extern int mono_method_get_token(IntPtr method); - - [DllImport(MonoWindows)] - public static extern string mono_method_get_reflection_name(IntPtr method); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_method_get_class(IntPtr method); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_class_get_image(IntPtr klass); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_profiler_create(IntPtr profiler); - - public delegate void JitDoneCallback(IntPtr profiler, IntPtr method, IntPtr jinfo); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_profiler_set_jit_done_callback(IntPtr profiler, JitDoneCallback? cb); - - public static unsafe bool CctorRan(Type t) - { - return *((byte*)mono_class_vtable(mono_domain_get(), mono_class_from_mono_type(t.TypeHandle.Value)) + 45) != 0; - } - } - - /*public static class IcedDisasm - { - public unsafe static void DisasmMethod(long codeStart) - { - DisasmMethod(codeStart, Native.mono_jit_info_get_code_size(Native.mono_jit_info_table_find(Native.DomainPtr, (IntPtr)codeStart))); - } - - public unsafe static void DisasmMethod(long codeStart, long codeSize) - { - //Log.Message($"code {Convert.ToString(*(uint*)codeStart, 16)}"); - //return; - - var codeRip = (ulong)codeStart; - var codeReader = new UnsafeCodeReader() { start = (byte*)codeStart }; - var decoder = Decoder.Create(64, codeReader); - decoder.IP = codeRip; - ulong endRip = decoder.IP + (uint)codeSize + 8; - - var instructions = new InstructionList(); - while (decoder.IP < endRip) - decoder.Decode(out instructions.AllocUninitializedElement()); - - var formatter = new NasmFormatter(); - formatter.Options.DigitSeparator = "`"; - formatter.Options.FirstOperandCharIndex = 10; - var output = new StringOutput(); - - bool cond = (*(uint*)codeStart & 0xFFFFFF) != 0xEC8348 && *(byte*)codeStart != 0x55; - - Log.Message("Code size: " + codeSize, true); - - foreach (ref var instr in instructions) - { - var stringb = new StringBuilder(); - formatter.Format(instr, output); - stringb.Append(instr.IP.ToString("X16")); - stringb.Append(" "); - int instrLen = instr.Length; - int byteBaseIndex = (int)(instr.IP - codeRip); - for (int i = 0; i < instrLen; i++) - stringb.Append(((byte*)codeStart)[byteBaseIndex + i].ToString("X2")); - int missingBytes = 10 - instrLen; - for (int i = 0; i < missingBytes; i++) - stringb.Append(" "); - stringb.Append(" "); - stringb.Append(output.ToStringAndReset()); - //if (cond) - Log.Message(stringb.ToString(), ignoreStopLoggingLimit: true); - } - } - - public unsafe class UnsafeCodeReader : CodeReader - { - public byte* start; - - public unsafe override int ReadByte() - { - var val = *start; - start++; - return val; - } - } - }*/ -} diff --git a/Source/Directory.Build.props b/Source/Directory.Build.props index 1ebec3c7d..86b55129a 100644 --- a/Source/Directory.Build.props +++ b/Source/Directory.Build.props @@ -1,27 +1,8 @@ - 1.6 - net48 - net10.0 - net8.0 - net10.0 - net48;net8.0 - net10.0 - - net48 - net8.0 - net48;net8.0 - 1.6.4850 - 2.4.2 - 1.6.4850 - 1.7.4985-unstable - 2.4.2 - 3.0.0-rc.3 - obj/$(RimWorldVersion)/ - bin/$(RimWorldVersion)/ + net10.0 + 1.7.4985-unstable + 3.0.0-rc.3 $(DefaultItemExcludesInProjectFolder);**/bin/**;**/obj/** - - - diff --git a/Source/Directory.Build.targets b/Source/Directory.Build.targets deleted file mode 100644 index fc3e1f6c2..000000000 --- a/Source/Directory.Build.targets +++ /dev/null @@ -1,19 +0,0 @@ - - - - <_RimWorldVersionSourceDirectory Condition="'$(MSBuildProjectName)' == 'Multiplayer'">Client - <_RimWorldVersionSourceDirectory Condition="'$(MSBuildProjectName)' == 'MultiplayerLoader'">MultiplayerLoader - <_RimWorldVersionSourceDirectory Condition="'$(MSBuildProjectName)' == 'Tests'">Tests - <_RimWorldVersionSourcePath Condition="'$(_RimWorldVersionSourceDirectory)' != ''">$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)$(RimWorldVersion)/$(_RimWorldVersionSourceDirectory)'))/ - <_RimWorldPathMapPrefix Condition="'$(PathMap)' != ''">$(PathMap), - $(_RimWorldPathMapPrefix)$(_RimWorldVersionSourcePath)=Versions/$(RimWorldVersion)/$(_RimWorldVersionSourceDirectory)/ - - - - - - Versions/$(RimWorldVersion)/$(_RimWorldVersionSourceDirectory)/%(RecursiveDir)%(Filename)%(Extension) - - - diff --git a/Source/1.7/MultiplayerLoader/Multiplayer.Harmony.cs b/Source/MultiplayerLoader/Multiplayer.Harmony.cs similarity index 92% rename from Source/1.7/MultiplayerLoader/Multiplayer.Harmony.cs rename to Source/MultiplayerLoader/Multiplayer.Harmony.cs index e9e6eaa9f..196b8886c 100644 --- a/Source/1.7/MultiplayerLoader/Multiplayer.Harmony.cs +++ b/Source/MultiplayerLoader/Multiplayer.Harmony.cs @@ -5,7 +5,7 @@ namespace MultiplayerLoader; public partial class Multiplayer { - partial void CheckHarmonyVersion() + private void CheckHarmonyVersion() { var harmonyVersion = typeof(Harmony).Assembly.GetName().Version; if (harmonyVersion.Major < 3) diff --git a/Source/MultiplayerLoader/MultiplayerLoader.cs b/Source/MultiplayerLoader/MultiplayerLoader.cs index 84e2eb211..57a36ed04 100644 --- a/Source/MultiplayerLoader/MultiplayerLoader.cs +++ b/Source/MultiplayerLoader/MultiplayerLoader.cs @@ -17,7 +17,6 @@ public partial class Multiplayer : Mod public Multiplayer(ModContentPack content) : base(content) { CheckHarmonyVersion(); - InitializeVersionRuntime(); instance = this; LoadAssembliesCustom(); @@ -25,8 +24,6 @@ public Multiplayer(ModContentPack content) : base(content) FindTypeInAppDomain("Multiplayer.Client.Multiplayer")!.GetMethod("InitMultiplayer")!.Invoke(null, [content]); } - partial void CheckHarmonyVersion(); - partial void InitializeVersionRuntime(); public static Type? FindTypeInAppDomain(string typeFullName) => AppDomain.CurrentDomain.GetAssemblies() diff --git a/Source/MultiplayerLoader/MultiplayerLoader.csproj b/Source/MultiplayerLoader/MultiplayerLoader.csproj index 5df5ed0af..cbe5c2f76 100644 --- a/Source/MultiplayerLoader/MultiplayerLoader.csproj +++ b/Source/MultiplayerLoader/MultiplayerLoader.csproj @@ -1,7 +1,6 @@ - $(RimWorldTargetFramework) true 12 false @@ -16,7 +15,6 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/Source/1.7/MultiplayerLoader/SyncRituals.RuntimeHarmony.cs b/Source/MultiplayerLoader/SyncRituals.RuntimeHarmony.cs similarity index 100% rename from Source/1.7/MultiplayerLoader/SyncRituals.RuntimeHarmony.cs rename to Source/MultiplayerLoader/SyncRituals.RuntimeHarmony.cs diff --git a/Source/RuntimeTests/README.md b/Source/RuntimeTests/README.md index 18622705b..72e5b529c 100644 --- a/Source/RuntimeTests/README.md +++ b/Source/RuntimeTests/README.md @@ -1,88 +1,11 @@ -# Version-specific ritual checks +# RimWorld 1.7 metadata audit -The 1.6 and 1.7 runtime paths differ, so these checks are deliberately separate. -They are standalone probes, not full gameplay or multiplayer-session tests. +Build the current mod, then run `./run-local-tests.sh [built-mod-root]`. +The root defaults to this checkout and must contain `1.7/Assemblies` and +`1.7/AssembliesCustom`. Docker and Python 3 are required; no game installation is +needed. The Docker SDK is selected from the repository's `global.json`. -## Run all local runtime probes - -From the repository root, run: - -```sh -./run-local-tests.sh \ - "/path/to/RimWorld-1.6/Managed" \ - "/path/to/Prepatcher/Assemblies" \ - "/path/to/Harmony/1.6/Assemblies" \ - "/path/to/Multiplayer" -``` - -The optional final argument defaults to this repository. It must contain -already-built assemblies for both `1.6/` and `1.7/`; the runner does not build -or deploy them. It validates all required files before invoking Docker and -then runs the two 1.6 probes below and the 1.7 metadata audit. Paths may contain -spaces, and the runner can be invoked from another working directory. Docker -and Python 3 must be installed and available on `PATH`. - -This does not run RimWorld, exercise full host/join gameplay, or run the manual -Windows Mono `TestsOnMono.exe` described in [its README](../TestsOnMono/README.md). - -## RimWorld 1.6: Mono and Harmony 2 - -Build the 1.6 mod first. Then pass the actual 1.6 `Managed` directory, Prepatcher -`Assemblies` directory, and installed Harmony mod's 1.6 `Assemblies` directory: - -```sh -bash Source/RuntimeTests/run-ritual-probe.sh \ - "/path/to/RimWorld-1.6/Managed" \ - "/path/to/Prepatcher/Assemblies" \ - "/path/to/Harmony/1.6/Assemblies" -``` - -An optional fourth argument selects a mod root containing `1.6/Assemblies` and -`1.6/AssembliesCustom`; by default the repository root is used. The script uses -Docker's `mono:6.12` and the supplied Harmony 2 runtime. It does not call Multiplayer's -native Mono profiler and does not require `mono-2.0-bdwgc`. - -The probe loads the actual 1.6 game/mod/Prepatcher API assemblies and calls the -production `SyncRituals.Init()`. It asserts ten registrations (five normal and five -psychic), four role serializers, and that `SyncMethod` implements the Prepatcher -interface. It then calls the real `PrepatcherPrefix` directly for normal and psychic -widgets. A Harmony test prefix stubs only `SyncMethod.DoSync`'s result so it can verify -exact-type dispatch, one call per matching handler, continuation when synchronization -is inactive, and suppression when the simulated active sync succeeds. - -This invokes the prefix directly. It does not run Prepatcher's Cecil rewrite/injection, -load a full RimWorld session, or exercise real network serialization/transport. Output -is saved to `Source/RuntimeTests/bin/prepatcher-prefix.log`. - -## RimWorld 1.7: reference and metadata audit - -Run this against the versioned mod root after building or packaging the 1.7 variant: - -```sh -bash Source/RuntimeTests/audit-rimworld-1.7-reference.sh -# Or pass a packaged mod root: -bash Source/RuntimeTests/audit-rimworld-1.7-reference.sh "/path/to/Multiplayer" -``` - -The audit uses `System.Reflection.Metadata` in Docker with the SDK version selected by -the repository's root `global.json` to read the assemblies' target-framework -attributes and AssemblyRef/TypeRef tables without loading or executing the mod or -game assemblies. It checks the .NET 10 target, the RimWorld 1.7 -`Assembly-CSharp` reference, Harmony's external AssemblyRef, and the absence of -Prepatcher/Cecil references and bundled Harmony/game DLLs. This is a metadata/reference -check only; it does not load the assemblies into a live RimWorld 1.7 CoreCLR process -or test in-game loading, host/join behavior, or gameplay. - -## RimWorld 1.6: late reflection-only Unity dependencies - -```sh -bash Source/RuntimeTests/run-reflection-only-probe.sh "/path/to/RimWorld-1.6/Managed" -``` - -An optional second argument selects a mod root with the updated 1.6 loader. This -Mono probe reproduces the missing `UnityEngine.InputLegacyModule` metadata lookup -with a null reflection-only resolver, then verifies Multiplayer's scoped resolver -both reuses an already loaded Unity assembly and resolves a late dependency from -that game's Managed directory. It uses the real Unity input DLL without running -Unity methods. No Prepatcher files are changed; full live scene cleanup is not -simulated. Logs are written to `bin/reflection-only-*.log`. +The audit reads assembly metadata without loading game code. It checks .NET 10, +the RimWorld 1.7 reference, external Harmony 3, and absence of Prepatcher/Cecil or +bundled game DLLs. It does not verify live game loading or multiplayer gameplay. +Mono/Prepatcher probes belong with the frozen legacy source, not this branch. diff --git a/Source/RuntimeTests/ReflectionOnlyUnityFixture.cs b/Source/RuntimeTests/ReflectionOnlyUnityFixture.cs deleted file mode 100644 index 79a52378d..000000000 --- a/Source/RuntimeTests/ReflectionOnlyUnityFixture.cs +++ /dev/null @@ -1,5 +0,0 @@ -// Compiled only by run-reflection-only-probe.sh against the actual game DLLs. -public class ReflectionOnlyInputSignature -{ - public UnityEngine.Touch ReadTouch() { return default(UnityEngine.Touch); } -} diff --git a/Source/RuntimeTests/ReflectionOnlyUnityProbe.cs b/Source/RuntimeTests/ReflectionOnlyUnityProbe.cs deleted file mode 100644 index 7d65d078b..000000000 --- a/Source/RuntimeTests/ReflectionOnlyUnityProbe.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.IO; -using System.Reflection; - -public static class ReflectionOnlyUnityProbe -{ - public static int Main(string[] args) - { - string loaderPath = args[0], game = args[1], mode = args[2]; - try - { - Assembly existing = null; - if (mode == "existing") - existing = Assembly.LoadFrom(Path.Combine(game, "UnityEngine.InputLegacyModule.dll")); - - // Match the stock preloader's handler: it observes the request but returns null. - AppDomain.CurrentDomain.ReflectionOnlyAssemblyResolve += (sender, eventArgs) => null; - if (mode != "baseline") - { - var loader = Assembly.LoadFrom(loaderPath); - var resolver = loader.GetType("MultiplayerLoader.ReflectionOnlyUnityResolver", true); - resolver.GetMethod("Install", BindingFlags.NonPublic | BindingFlags.Static) - .Invoke(null, new object[] { game }); - } - - var fixture = Assembly.ReflectionOnlyLoadFrom("bin/ReflectionOnlyUnityFixture.dll"); - var type = fixture.GetType("ReflectionOnlyInputSignature").GetMethod("ReadTouch").ReturnType; - if (mode == "baseline") - throw new Exception("Baseline unexpectedly resolved the dependency"); - if (type.FullName != "UnityEngine.Touch" || type.Assembly.ReflectionOnly) - throw new Exception("Expected Unity's normal execution type; got " + type); - if (existing != null && !ReferenceEquals(existing, type.Assembly)) - throw new Exception("Existing Unity assembly identity was not reused"); - if (!Path.GetFullPath(type.Assembly.Location).Equals(Path.GetFullPath(Path.Combine(game, "UnityEngine.InputLegacyModule.dll")))) - throw new Exception("Unity assembly was loaded from an unexpected location"); - Console.WriteLine("PASS " + mode + ": resolved UnityEngine.Touch through the production Multiplayer resolver; reflectionOnly=False"); - return 0; - } - catch (FileNotFoundException exception) - { - if (mode == "baseline" && exception.FileName.StartsWith("UnityEngine.InputLegacyModule,")) - { - Console.WriteLine("PASS baseline: reproduced the original reflection-only UnityEngine.InputLegacyModule failure"); - return 0; - } - Console.WriteLine("FAIL " + exception); - return 1; - } - catch (Exception exception) - { - Console.WriteLine("FAIL " + exception); - return 1; - } - } -} diff --git a/Source/RuntimeTests/RitualPrepatcherMonoProbe.cs b/Source/RuntimeTests/RitualPrepatcherMonoProbe.cs deleted file mode 100644 index 018c7ccc8..000000000 --- a/Source/RuntimeTests/RitualPrepatcherMonoProbe.cs +++ /dev/null @@ -1,160 +0,0 @@ -using System; -using System.Collections; -using System.IO; -using System.Reflection; -using System.Runtime.Serialization; -using HarmonyLib; - -// Standalone managed probe for the RimWorld 1.6 Prepatcher registration path. -// This calls production Init and PrepatcherPrefix directly; it does not run Cecil rewriting. -public static class RitualPrepatcherMonoProbe -{ - static string mod, game, prepatcher, harmonyDir; - static bool simulatedDoSyncResult; - static int doSyncCalls; - static readonly Harmony fixtures = new Harmony("Tests.RitualPrepatcherMonoProbe"); - - public static int Main(string[] args) - { - if (args.Length != 4) - { - Console.Error.WriteLine("Usage: RitualPrepatcherMonoProbe "); - return 2; - } - - mod = args[0]; - game = args[1]; - prepatcher = args[2]; - harmonyDir = args[3]; - AppDomain.CurrentDomain.AssemblyResolve += Resolve; - - try - { - Run(); - return 0; - } - catch (Exception exception) - { - Console.Error.WriteLine("FAIL " + exception); - return 1; - } - } - - static void Run() - { - if (Type.GetType("Mono.Runtime") == null) - throw new Exception("This probe requires Mono."); - if (typeof(Harmony).Assembly.GetName().Version.Major != 2) - throw new Exception("RimWorld 1.6 probe requires Harmony 2; got " + typeof(Harmony).Assembly.GetName().Version); - - var gameAssembly = Assembly.LoadFrom(Path.Combine(game, "Assembly-CSharp.dll")); - var gameVersion = gameAssembly.GetName().Version; - if (gameVersion.Major != 1 || gameVersion.Minor != 6) - throw new Exception("Expected RimWorld 1.6 game assemblies; got " + gameVersion + "."); - var prepatcherApi = Assembly.LoadFrom(Path.Combine(prepatcher, "0PrepatcherAPI.dll")); - if (prepatcherApi.GetName().Name != "0PrepatcherAPI") - throw new Exception("The supplied Prepatcher API assembly has an unexpected identity."); - var loader = Assembly.LoadFrom(Path.Combine(mod, "1.6/Assemblies/MultiplayerLoader.dll")); - var client = Assembly.LoadFrom(Path.Combine(mod, "1.6/AssembliesCustom/Multiplayer.dll")); - Assembly.LoadFrom(Path.Combine(mod, "1.6/AssembliesCustom/MultiplayerCommon.dll")); - var prepatchInterface = loader.GetType("MultiplayerLoader.ISyncMethodForPrepatcher", true); - var syncMethodType = client.GetType("Multiplayer.Client.SyncMethod", true); - if (!prepatchInterface.IsAssignableFrom(syncMethodType)) - throw new Exception("1.6 SyncMethod does not implement ISyncMethodForPrepatcher."); - - Console.WriteLine("Mono runtime detected."); - Console.WriteLine("Harmony=" + typeof(Harmony).Assembly.GetName().Version + "; Prepatcher API=" + prepatcherApi.GetName().Version + "; Game=" + gameAssembly.GetName().Version); - - // Prevent Unity texture access while loading the production serializer registrations. - var unityCore = Assembly.LoadFrom(Path.Combine(game, "UnityEngine.CoreModule.dll")); - var loadTexture = unityCore.GetType("UnityEngine.Resources", true) - .GetMethod("Load", new[] { typeof(string), typeof(Type) }); - if (loadTexture == null) - throw new Exception("Could not find UnityEngine.Resources.Load(string, Type)."); - fixtures.Patch(loadTexture, prefix: new HarmonyMethod(typeof(RitualPrepatcherMonoProbe).GetMethod("SkipTexture"))); - - var rituals = loader.GetType("MultiplayerLoader.SyncRituals", true); - rituals.GetMethod("Init", BindingFlags.Public | BindingFlags.Static).Invoke(null, null); - var handlers = (IList)rituals.GetField("syncMethodsWithPrepatches", BindingFlags.NonPublic | BindingFlags.Static).GetValue(null); - if (handlers.Count != 10) - throw new Exception("Expected ten ritual registrations, got " + handlers.Count + "."); - - var normalWidget = gameAssembly.GetType("RimWorld.PawnRitualRoleSelectionWidget", true); - var psychicWidget = gameAssembly.GetType("RimWorld.PawnPsychicRitualRoleSelectionWidget", true); - var targetType = syncMethodType.GetProperty("TargetType"); - var normalCount = 0; - var psychicCount = 0; - var serializerCount = 0; - foreach (var handler in handlers) - { - if (!prepatchInterface.IsInstanceOfType(handler)) - throw new Exception("A registered handler does not implement ISyncMethodForPrepatcher."); - var widgetType = (Type)targetType.GetValue(handler, null); - if (widgetType == normalWidget) normalCount++; - else if (widgetType == psychicWidget) psychicCount++; - else throw new Exception("Unexpected ritual handler target " + widgetType + "."); - - var transformers = (Array)syncMethodType.GetField("argTransformers", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(handler); - if (transformers.Length > 1 && transformers.GetValue(1) != null) - serializerCount++; - } - if (normalCount != 5 || psychicCount != 5 || serializerCount != 4) - throw new Exception("Unexpected registration shape: normal=" + normalCount + ", psychic=" + psychicCount + ", role serializers=" + serializerCount + "."); - Console.WriteLine("PASS SyncRituals.Init: 10 handlers (5 normal, 5 psychic), four role serializers."); - - // Exercise the actual production prefix against its registered handlers. Stub only - // DoSync's return value, so no multiplayer state or network transport is required. - var doSync = syncMethodType.GetMethod("DoSync", BindingFlags.Public | BindingFlags.Instance); - fixtures.Patch(doSync, prefix: new HarmonyMethod(typeof(RitualPrepatcherMonoProbe).GetMethod("StubDoSync"))); - var prefix = rituals.GetMethod("PrepatcherPrefix", BindingFlags.Public | BindingFlags.Static); - var normal = FormatterServices.GetUninitializedObject(normalWidget); - var psychic = FormatterServices.GetUninitializedObject(psychicWidget); - - foreach (bool active in new[] { true, false }) - { - simulatedDoSyncResult = active; - AssertPrefix(prefix, 0, normal, !active, active ? "normal active" : "normal inactive"); - AssertPrefix(prefix, 1, psychic, !active, active ? "psychic active" : "psychic inactive"); - AssertPrefix(prefix, 0, psychic, true, active ? "psychic wrong-type active" : "psychic wrong-type inactive"); - AssertPrefix(prefix, 1, normal, true, active ? "normal wrong-type active" : "normal wrong-type inactive"); - } - - Console.WriteLine("PASS direct PrepatcherPrefix dispatch: normal and psychic each call DoSync exactly once when types match; wrong types continue without dispatch."); - } - - static void AssertPrefix(MethodInfo prefix, int index, object instance, bool expectedContinue, string label) - { - doSyncCalls = 0; - var result = (bool)prefix.Invoke(null, new object[] { index, instance, new object[0] }); - if (result != expectedContinue) - throw new Exception(label + ": expected continue=" + expectedContinue + ", got " + result + "."); - var expectedCalls = label.Contains("wrong-type") ? 0 : 1; - if (doSyncCalls != expectedCalls) - throw new Exception(label + ": expected DoSync calls=" + expectedCalls + ", got " + doSyncCalls + "."); - Console.WriteLine("PASS " + label + ": continue=" + result + ", DoSync calls=" + doSyncCalls + "."); - } - - public static bool SkipTexture() { return false; } - - public static bool StubDoSync(object __instance, ref bool __result) - { - doSyncCalls++; - __result = simulatedDoSyncResult; - return false; - } - - static Assembly Resolve(object sender, ResolveEventArgs args) - { - var name = new AssemblyName(args.Name).Name + ".dll"; - foreach (var folder in new[] { - harmonyDir, prepatcher, game, - Path.Combine(mod, "1.6/Assemblies"), Path.Combine(mod, "1.6/AssembliesCustom") - }) - { - var candidate = Path.Combine(folder, name); - if (File.Exists(candidate)) - return Assembly.LoadFrom(candidate); - } - return null; - } -} diff --git a/Source/RuntimeTests/run-reflection-only-probe.sh b/Source/RuntimeTests/run-reflection-only-probe.sh deleted file mode 100644 index 03166f8c4..000000000 --- a/Source/RuntimeTests/run-reflection-only-probe.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -# Probe 1.6 Unity reflection-only dependency resolution under Mono. -set -euo pipefail -if [[ "$#" -lt 1 || "$#" -gt 2 ]]; then - echo "Usage: bash $0 [mod-root]" >&2 - exit 2 -fi -PROBE_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -MOD_ROOT="${2:-$(cd -- "$PROBE_DIR/../.." && pwd)}" -GAME_MANAGED_DIR="$1" -for file in "$GAME_MANAGED_DIR/UnityEngine.InputLegacyModule.dll" "$GAME_MANAGED_DIR/netstandard.dll" "$MOD_ROOT/1.6/Assemblies/MultiplayerLoader.dll"; do - [[ -f "$file" ]] || { echo "Missing probe input: $file" >&2; exit 1; } -done -mkdir -p "$PROBE_DIR/bin" -docker run --rm \ - -v "$MOD_ROOT":/mod:ro \ - -v "$GAME_MANAGED_DIR":/game:ro \ - -v "$PROBE_DIR":/probe -w /probe mono:6.12 bash -lc ' - set -euo pipefail - mcs -target:library -r:/game/netstandard.dll -r:/game/UnityEngine.CoreModule.dll \ - -r:/game/UnityEngine.InputLegacyModule.dll -out:bin/ReflectionOnlyUnityFixture.dll ReflectionOnlyUnityFixture.cs - mcs -out:bin/ReflectionOnlyUnityProbe.exe ReflectionOnlyUnityProbe.cs - for mode in baseline existing late; do - if ! mono bin/ReflectionOnlyUnityProbe.exe /mod/1.6/Assemblies/MultiplayerLoader.dll /game "$mode" > "bin/reflection-only-$mode.log" 2>&1; then - cat "bin/reflection-only-$mode.log" - exit 1 - fi - cat "bin/reflection-only-$mode.log" - done - ' diff --git a/Source/RuntimeTests/run-ritual-probe.sh b/Source/RuntimeTests/run-ritual-probe.sh deleted file mode 100755 index b847900ac..000000000 --- a/Source/RuntimeTests/run-ritual-probe.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env bash -# Probe 1.6 ritual registration and Prepatcher prefix dispatch under Mono. -set -euo pipefail -if [[ "$#" -lt 3 || "$#" -gt 4 ]]; then - echo "Usage: bash $0 [mod-root]" >&2 - exit 2 -fi - -PROBE_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd -- "$PROBE_DIR/../.." && pwd)" -GAME_MANAGED_DIR="$1" -PREPATCHER_DIR="$2" -HARMONY_DIR="$3" -MOD_ROOT="${4:-$REPO_ROOT}" - -for file in \ - "$GAME_MANAGED_DIR/Assembly-CSharp.dll" \ - "$GAME_MANAGED_DIR/UnityEngine.CoreModule.dll" \ - "$PREPATCHER_DIR/0PrepatcherAPI.dll" \ - "$HARMONY_DIR/0Harmony.dll" \ - "$MOD_ROOT/1.6/Assemblies/MultiplayerLoader.dll" \ - "$MOD_ROOT/1.6/AssembliesCustom/Multiplayer.dll" \ - "$MOD_ROOT/1.6/AssembliesCustom/MultiplayerCommon.dll"; do - if [[ ! -f "$file" ]]; then - echo "Required 1.6 probe input is missing: $file" >&2 - exit 1 - fi -done - -python3 - "$MOD_ROOT/1.6/AssembliesCustom/Multiplayer.dll" <<'PY' -from pathlib import Path -import sys - -data = Path(sys.argv[1]).read_bytes() -if b".NETFramework,Version=v4.8" not in data or b".NETCoreApp,Version=v10.0" in data: - raise SystemExit("The 1.6 probe requires a net48 Multiplayer.dll; build with RimWorldVersion=1.6.") -PY - -mkdir -p "$PROBE_DIR/bin" -rm -f "$PROBE_DIR/bin/prepatcher-prefix.log" -docker run --rm \ - -v "$MOD_ROOT":/mod:ro \ - -v "$GAME_MANAGED_DIR":/game:ro \ - -v "$PREPATCHER_DIR":/prepatcher:ro \ - -v "$HARMONY_DIR":/harmony:ro \ - -v "$PROBE_DIR":/probe \ - -w /probe mono:6.12 bash -lc ' - set -euo pipefail - mcs -r:/harmony/0Harmony.dll -out:bin/RitualPrepatcherMonoProbe.exe RitualPrepatcherMonoProbe.cs - export MONO_PATH=/harmony:/prepatcher:/mod/1.6/Assemblies:/mod/1.6/AssembliesCustom - if ! mono --debug bin/RitualPrepatcherMonoProbe.exe /mod /game /prepatcher /harmony > bin/prepatcher-prefix.log 2>&1; then - cat bin/prepatcher-prefix.log - exit 1 - fi - cat bin/prepatcher-prefix.log - ' diff --git a/Source/Server/Server.csproj b/Source/Server/Server.csproj index 76cd730b1..afaf0c7f9 100644 --- a/Source/Server/Server.csproj +++ b/Source/Server/Server.csproj @@ -2,7 +2,6 @@ Exe - $(RimWorldTestTargetFramework) enable enable 12 diff --git a/Source/Tests/CoreClrRuntimeTest.cs b/Source/Tests/CoreClrRuntimeTest.cs index bb0785f73..c4d5e09a8 100644 --- a/Source/Tests/CoreClrRuntimeTest.cs +++ b/Source/Tests/CoreClrRuntimeTest.cs @@ -30,14 +30,5 @@ private static class StaticDefaults public static readonly List Values = new() { 1, 2, 3 }; } - [Test] - public void MonoInitializationIsSkippedOnCoreClr() - { - Assert.That(Native.IsMonoRuntime, Is.False); - // Both calls used to load Mono libraries or read Mono thread memory. - Native.EarlyInit(Native.NativeOS.Windows); - Native.InitLmfPtr(Native.NativeOS.Windows); - Assert.That(Native.DomainPtr, Is.EqualTo(IntPtr.Zero)); - Assert.That(Native.LmfPtr, Is.Zero); - } + } diff --git a/Source/Tests/Tests.csproj b/Source/Tests/Tests.csproj index e8ab3ba38..ab5a60046 100644 --- a/Source/Tests/Tests.csproj +++ b/Source/Tests/Tests.csproj @@ -1,7 +1,6 @@ - $(RimWorldTestTargetFramework) enable enable 12 @@ -24,7 +23,6 @@ - diff --git a/Source/TestsOnMono/ExternMethods.cs b/Source/TestsOnMono/ExternMethods.cs deleted file mode 100644 index b7133d5c0..000000000 --- a/Source/TestsOnMono/ExternMethods.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Runtime.InteropServices; -using Multiplayer.Common; - -namespace TestsOnMono; - -public static class ExternMethods -{ - [DllImport("ucrtbase.dll", EntryPoint = "fegetround", CallingConvention = CallingConvention.Cdecl)] - public static extern RoundModeEnum GetRound(); - - [DllImport("ucrtbase.dll", EntryPoint = "fesetround", CallingConvention = CallingConvention.Cdecl)] - public static extern int SetRound(RoundModeEnum roundingMode); -} diff --git a/Source/TestsOnMono/Program.cs b/Source/TestsOnMono/Program.cs deleted file mode 100644 index c4ec90ee1..000000000 --- a/Source/TestsOnMono/Program.cs +++ /dev/null @@ -1,136 +0,0 @@ -using System; -using System.Reflection; -using System.Runtime.CompilerServices; -using HarmonyLib; -using Multiplayer.Client; -using Multiplayer.Client.Desyncs; -using Multiplayer.Common; - -// These only work on Windows Mono -namespace TestsOnMono; - -static class Program -{ - // Test DeferredStackTracing - public static void Main1(string[] args) - { - Native.mini_parse_debug_option("disable_omit_fp"); - Native.InitLmfPtr(Native.NativeOS.Dummy); - Native.EarlyInit(Native.NativeOS.Dummy); - - TestClass.Test1(); - TestClass.Test(); - } - - // Test rounding modes - public static void Main2(string[] args) - { - void Print() - { - Console.WriteLine(ExternMethods.GetRound()); - Console.WriteLine(RoundMode.GetCurrentRoundMode()); - } - - Print(); - ExternMethods.SetRound(RoundModeEnum.Upward); - Print(); - ExternMethods.SetRound(RoundModeEnum.Downward); - Print(); - ExternMethods.SetRound(RoundModeEnum.TowardZero); - Print(); - } - - // Test patching generic methods with Harmony - public static void Main3(string[] args) - { - TestClassForPatches test = new() - { - field = new SomeClassDerived { a = 2, b = 1 } - }; - - Console.WriteLine(test.GetField().b); - - new Harmony("test").Patch( - typeof(TestClassForPatches).GetMethod("GetField"), - postfix: new HarmonyMethod(typeof(Program).GetMethod("GenericPostfix")) - ); - - Console.WriteLine(test.GetField().b); - } - - public static void Main(string[] args) - { - var profiler = Native.mono_profiler_create(IntPtr.Zero); - Native.mono_profiler_set_jit_done_callback(profiler, (_, method, _) => - { - var methodBase = Native.GetMethodBaseFromRuntimePointer(method); - Console.WriteLine($"{methodBase.DeclaringType}.{methodBase.Name}"); - Console.WriteLine(MethodBase.GetCurrentMethod()); - }); - Test(); - } - - private static void Test() - { - Test2(); - } - - private static void Test2() - { - - } - - public static void GenericPostfix(ref SomeClass __result) - { - __result = __result; - } - - class TestClass - { - public static void Test() - { - int hash = 0; - long[] trace = new long[8]; - DeferredStackTracingImpl.TraceImpl(trace, ref hash); - Console.WriteLine(hash); - - foreach (long l in trace) - Console.WriteLine(Native.MethodNameFromAddr(l, false)); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.NoInlining)] - public static void Test1() - { - Test(); - } - } - - public class SomeClass - { - public int a; - - [MethodImpl(MethodImplOptions.NoInlining)] - public int GetA() => a; - } - - public class SomeClassDerived : SomeClass - { - public int b; - } - - public class SomeClass2 - { - public int a; - - [MethodImpl(MethodImplOptions.NoInlining)] - public int GetA() => a; - } - - class TestClassForPatches - { - public T field; - - [MethodImpl(MethodImplOptions.NoInlining)] - public T GetField() => field; - } -} diff --git a/Source/TestsOnMono/README.md b/Source/TestsOnMono/README.md deleted file mode 100644 index 89ac19e1c..000000000 --- a/Source/TestsOnMono/README.md +++ /dev/null @@ -1,15 +0,0 @@ -This is a manual test executable for Windows Mono, separate from the main solution. -It exercises Mono runtime internals and does not run on RimWorld 1.7's CoreCLR runtime. - -Build it explicitly with the .NET 10 SDK: - -```sh -dotnet build Source/TestsOnMono/TestsOnMono.csproj -c Release -p:RimWorldVersion=1.6 -``` - -This uses Harmony 2.4.2, matching the 1.6 runtime dependency. It checks Harmony behavior -under Mono; it does not replace an in-game Prepatcher load test. - -Run the resulting `TestsOnMono.exe` under Windows Mono. - -For the portable ritual patch probe, see [RuntimeTests](../RuntimeTests/README.md). diff --git a/Source/TestsOnMono/TestsOnMono.csproj b/Source/TestsOnMono/TestsOnMono.csproj deleted file mode 100644 index 187ca8c74..000000000 --- a/Source/TestsOnMono/TestsOnMono.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - - Exe - net48 - enable - 11 - true - - - - - - - - diff --git a/run-local-tests.sh b/run-local-tests.sh index 36a6188d7..4dfeea25f 100755 --- a/run-local-tests.sh +++ b/run-local-tests.sh @@ -1,107 +1,14 @@ #!/usr/bin/env bash -# Run the local game-assembly-dependent probes for RimWorld 1.6 and 1.7. +# Run the local assembly metadata audit for the current RimWorld 1.7 build. set -euo pipefail - -usage() { - cat >&2 < [mod-root] - -Runs the 1.6 ritual-prefix and reflection-only Mono probes plus the 1.7 -assembly-metadata audit. All inputs must already exist; the mod root must -contain built 1.6/ and 1.7/ assemblies. Docker and Python 3 are required. - -Paths may be absolute or relative to the current directory. The default mod -root is this repository. Invoke from any working directory. -EOF - exit "${1:-2}" -} - -if [[ "$#" -eq 1 && ( "$1" == "--help" || "$1" == "-h" ) ]]; then - usage 0 +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +if [[ "${1:-}" == --help || "${1:-}" == -h ]]; then + echo "Usage: $0 [built-mod-root]" + echo "Requires Docker and Python 3. No game installation is needed for the metadata audit." + exit 0 fi - -if [[ "$#" -lt 3 || "$#" -gt 4 ]]; then - usage +if [[ $# -gt 1 ]]; then + echo "Usage: $0 [built-mod-root]" >&2 + exit 2 fi - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$SCRIPT_DIR" - -resolve_dir() { - local label="$1" path="$2" - if [[ ! -d "$path" ]]; then - echo "Required $label directory does not exist: $path" >&2 - exit 1 - fi - (cd -- "$path" && pwd) -} - -GAME_MANAGED_DIR="$(resolve_dir 'RimWorld 1.6 Managed' "$1")" -PREPATCHER_DIR="$(resolve_dir 'Prepatcher Assemblies' "$2")" -HARMONY_DIR="$(resolve_dir 'Harmony 1.6 Assemblies' "$3")" -MOD_ROOT="$(resolve_dir 'mod root' "${4:-$REPO_ROOT}")" - -required_files=( - "$GAME_MANAGED_DIR/Assembly-CSharp.dll" - "$GAME_MANAGED_DIR/UnityEngine.CoreModule.dll" - "$GAME_MANAGED_DIR/UnityEngine.InputLegacyModule.dll" - "$GAME_MANAGED_DIR/netstandard.dll" - "$PREPATCHER_DIR/0PrepatcherAPI.dll" - "$HARMONY_DIR/0Harmony.dll" - "$MOD_ROOT/1.6/Assemblies/MultiplayerLoader.dll" - "$MOD_ROOT/1.6/AssembliesCustom/Multiplayer.dll" - "$MOD_ROOT/1.6/AssembliesCustom/MultiplayerCommon.dll" - "$MOD_ROOT/1.7/Assemblies/MultiplayerLoader.dll" - "$MOD_ROOT/1.7/AssembliesCustom/Multiplayer.dll" - "$MOD_ROOT/1.7/AssembliesCustom/MultiplayerCommon.dll" -) -for file in "${required_files[@]}"; do - if [[ ! -f "$file" ]]; then - echo "Required local test input is missing: $file" >&2 - exit 1 - fi -done - -if ! command -v python3 >/dev/null 2>&1; then - echo "python3 is required by the RimWorld 1.6 ritual probe, but was not found in PATH." >&2 - exit 1 -fi - -python3 - "$REPO_ROOT/global.json" <<'PY' -import json -import re -import sys - -path = sys.argv[1] -try: - with open(path, encoding="utf-8") as config_file: - config = json.load(config_file) -except OSError as exc: - raise SystemExit(f"Cannot read required SDK config {path}: {exc}") -except json.JSONDecodeError as exc: - raise SystemExit(f"Malformed SDK config {path}: {exc}") - -sdk = config.get("sdk") if isinstance(config, dict) else None -version = sdk.get("version") if isinstance(sdk, dict) else None -if not isinstance(version, str) or not re.fullmatch(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", version): - raise SystemExit(f"SDK config {path} must contain a valid sdk.version string") -PY - -if ! command -v docker >/dev/null 2>&1; then - echo "Docker is required to run these local probes, but was not found in PATH." >&2 - exit 1 -fi - -echo "== RimWorld 1.6 ritual prefix probe ==" -bash "$SCRIPT_DIR/Source/RuntimeTests/run-ritual-probe.sh" \ - "$GAME_MANAGED_DIR" "$PREPATCHER_DIR" "$HARMONY_DIR" "$MOD_ROOT" - -echo "== RimWorld 1.6 reflection-only probe ==" -bash "$SCRIPT_DIR/Source/RuntimeTests/run-reflection-only-probe.sh" \ - "$GAME_MANAGED_DIR" "$MOD_ROOT" - -echo "== RimWorld 1.7 metadata audit ==" -bash "$SCRIPT_DIR/Source/RuntimeTests/audit-rimworld-1.7-reference.sh" \ - "$MOD_ROOT" - -echo "All local runtime probes passed." +bash "$ROOT/Source/RuntimeTests/audit-rimworld-1.7-reference.sh" "${1:-$ROOT}" From d902f2d04e0fb1355e0919963f89193da9b942d7 Mon Sep 17 00:00:00 2001 From: roman_gr Date: Wed, 28 Oct 2026 19:09:44 +0100 Subject: [PATCH 17/19] Bundle all supported RimWorld versions through a CI matrix --- .github/actions/package-mod/action.yml | 78 ++++++++----- .github/package-versions.json | 21 ++++ .github/workflows/alpha-notes.md | 8 +- .github/workflows/build-packages.yml | 151 ++++++++++++++++--------- MajorReleaseProcess.txt | 4 +- README.md | 42 ++++--- 6 files changed, 203 insertions(+), 101 deletions(-) create mode 100644 .github/package-versions.json diff --git a/.github/actions/package-mod/action.yml b/.github/actions/package-mod/action.yml index dd3312098..8b3b4ea63 100644 --- a/.github/actions/package-mod/action.yml +++ b/.github/actions/package-mod/action.yml @@ -1,14 +1,17 @@ name: Package Multiplayer mod -description: Assemble current and independently built legacy game versions. +description: Assemble any number of independently built game versions. inputs: destination: description: Destination mod directory. required: true - current-root: - description: Downloaded current build, containing About and 1.7. + builds-root: + description: Directory containing one build per version, each with About and files. required: true - legacy-root: - description: Downloaded legacy build, containing About and 1.6. + versions: + description: JSON list of all required game versions; missing builds fail packaging. + required: true + current-version: + description: Version whose About metadata supplies the package name and description. required: true display-name: description: Optional package display name. @@ -23,8 +26,9 @@ runs: shell: bash env: DESTINATION: ${{ inputs.destination }} - CURRENT_ROOT: ${{ inputs.current-root }} - LEGACY_ROOT: ${{ inputs.legacy-root }} + BUILDS_ROOT: ${{ inputs.builds-root }} + VERSIONS: ${{ inputs.versions }} + CURRENT_VERSION: ${{ inputs.current-version }} DISPLAY_NAME: ${{ inputs.display-name }} STAMP_VERSION: ${{ inputs.stamp-version }} run: | @@ -32,37 +36,46 @@ runs: from copy import deepcopy from pathlib import Path import os + import json import re import shutil import subprocess import xml.etree.ElementTree as ET destination = Path(os.environ['DESTINATION']) - sources = {'1.7': Path(os.environ['CURRENT_ROOT']), '1.6': Path(os.environ['LEGACY_ROOT'])} - # Validate both independent inputs before copying any files. + builds_root = Path(os.environ['BUILDS_ROOT']) + sources = {p.name: p for p in builds_root.iterdir() if p.is_dir()} + current = os.environ['CURRENT_VERSION'] + required = json.loads(os.environ['VERSIONS']) + if len(set(required)) != len(required) or set(sources) != set(required) or current not in sources or any(not re.fullmatch(r'\d+\.\d+', v) for v in sources): + raise SystemExit('Expected version-named build directories including the current version') + versions = sorted(sources, key=lambda value: tuple(map(int, value.split('.')))) + # Validate every independent build before copying any files. metadata = {} for version, source in sources.items(): metadata[version] = ET.parse(source / 'About/About.xml').getroot() + if version not in [item.text for item in metadata[version].findall('supportedVersions/li')]: + raise SystemExit(f'Source metadata does not support {version}') for relative in ('Assemblies/MultiplayerLoader.dll', 'AssembliesCustom/Multiplayer.dll', 'AssembliesCustom/MultiplayerCommon.dll'): - path = source / version / relative + path = source / 'files' / relative if not path.is_file(): raise SystemExit(f'Missing {version} build output: {path}') - for dll in (source / version).rglob('*.dll'): + for dll in (source / 'files').rglob('*.dll'): name = dll.name.lower() - legacy_api = version == '1.6' and name == '0prepatcherapi.dll' - if name in ('0harmony.dll', 'assembly-csharp.dll', 'verse.dll', 'unitystubs.dll') or name.startswith(('unityengine', 'mono.cecil')) or (name.startswith(('0prepatcher', 'prepatcher')) and not legacy_api): + dependency_api = name == '0prepatcherapi.dll' + if name in ('0harmony.dll', 'assembly-csharp.dll', 'verse.dll', 'unitystubs.dll') or name.startswith(('unityengine', 'mono.cecil')) or (name.startswith(('0prepatcher', 'prepatcher')) and not dependency_api): raise SystemExit(f'Unexpected bundled game or dependency DLL: {dll}') destination.mkdir(parents=True, exist_ok=False) - shutil.copytree(sources['1.7'] / 'About', destination / 'About') - root = deepcopy(metadata['1.7']) + shutil.copytree(sources[current] / 'About', destination / 'About') + root = deepcopy(metadata[current]) for tag in ('supportedVersions', 'modDependencies', 'modDependenciesByVersion'): for old in list(root.findall(tag)): root.remove(old) supported = ET.SubElement(root, 'supportedVersions') dependencies = ET.SubElement(root, 'modDependenciesByVersion') folders = ET.Element('loadFolders') - for version in ('1.6', '1.7'): - shutil.copytree(sources[version] / version, destination / version, ignore=shutil.ignore_patterns('.git')) + for version in versions: + shutil.copytree(sources[version] / 'files', destination / version, ignore=shutil.ignore_patterns('.git')) ET.SubElement(supported, 'li').text = version selected = metadata[version].find(f'modDependenciesByVersion/v{version}') if selected is None: @@ -70,18 +83,31 @@ runs: entry = ET.SubElement(dependencies, f'v{version}') if selected is not None: entry.extend(deepcopy(list(selected))) + config_path = sources[version] / 'source.json' + config = json.loads(config_path.read_text()) if config_path.exists() else {} + identifiers = {item.findtext('packageId') for item in entry} + for dependency in config.get('extra-dependencies', []): + if dependency['packageId'] not in identifiers: + item = ET.SubElement(entry, 'li') + for key, value in dependency.items(): + ET.SubElement(item, key).text = value + identifiers.add(dependency['packageId']) folder = ET.SubElement(folders, f'v{version}') ET.SubElement(folder, 'li').text = '/' ET.SubElement(folder, 'li').text = version - # Preserve legacy dependency ordering as well as its declarations. - ordering = root.find('loadAfter') - if ordering is None: - ordering = ET.SubElement(root, 'loadAfter') - existing = {item.text for item in ordering} - for item in metadata['1.6'].findall('loadAfter/li'): - if item.text not in existing: - ordering.append(deepcopy(item)) - existing.add(item.text) + # Preserve all source ordering requirements and any release dependency additions. + for tag in ('loadAfter', 'loadBefore'): + ordering = root.find(tag) + if ordering is None: + ordering = ET.SubElement(root, tag) + existing = {item.text for item in ordering} + values = [item.text for version in versions for item in metadata[version].findall(f'{tag}/li')] + if tag == 'loadAfter': + values += [item.findtext('packageId') for entry in dependencies for item in entry] + for value in values: + if value and value not in existing: + ET.SubElement(ordering, 'li').text = value + existing.add(value) if os.environ['DISPLAY_NAME']: root.find('name').text = os.environ['DISPLAY_NAME'] if os.environ['STAMP_VERSION'] == 'true': diff --git a/.github/package-versions.json b/.github/package-versions.json new file mode 100644 index 000000000..3ae2c5c31 --- /dev/null +++ b/.github/package-versions.json @@ -0,0 +1,21 @@ +{ + "current": "1.7", + "releases": [ + { + "version": "1.5", + "branch": "rw-1.5", + "extra-dependencies": [ + { + "packageId": "zetrith.prepatcher", + "displayName": "Prepatcher", + "steamWorkshopUrl": "steam://url/CommunityFilePage/2934420800" + } + ] + }, + { + "version": "1.6", + "branch": "rw-1.6", + "fallback": "9df94bd04ea9a94c00bd39a276e58c4c2f965740" + } + ] +} diff --git a/.github/workflows/alpha-notes.md b/.github/workflows/alpha-notes.md index c515765bf..6bdab1d4b 100644 --- a/.github/workflows/alpha-notes.md +++ b/.github/workflows/alpha-notes.md @@ -1,5 +1,5 @@ -Development build for RimWorld 1.7, bundled with a separately built legacy 1.6 version. +Development build for RimWorld 1.7, bundled with independently built 1.5 and 1.6 versions. -Install the Harmony version matching your game. Legacy 1.6 also requires Prepatcher. -Extract the mod ZIP to Mods; version folders are selected through LoadFolders.xml. -Legacy fixes are backported separately. See README.md for exact source selection. +Install the dependencies matching your game version. The 1.5 and 1.6 packages +require Prepatcher; 1.7 requires Harmony 3. Extract the mod ZIP to Mods; +LoadFolders.xml selects matching assets and assemblies. See README.md for source selection. diff --git a/.github/workflows/build-packages.yml b/.github/workflows/build-packages.yml index 339925d1b..3329c57bc 100644 --- a/.github/workflows/build-packages.yml +++ b/.github/workflows/build-packages.yml @@ -22,7 +22,42 @@ on: permissions: contents: read jobs: + configuration: + runs-on: ubuntu-latest + outputs: + current: ${{ steps.config.outputs.current }} + releases: ${{ steps.config.outputs.releases }} + versions: ${{ steps.config.outputs.versions }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - id: config + name: Read supported version builds + run: | + python3 - <<'PYTHON' + import json + import os + import re + from pathlib import Path + config = json.loads(Path('.github/package-versions.json').read_text()) + versions = [config['current']] + [entry['version'] for entry in config['releases']] + if len(set(versions)) != len(versions) or any(not re.fullmatch(r'\d+\.\d+', value) for value in versions): + raise SystemExit('Package versions must be unique major.minor values') + for entry in config['releases']: + if not entry.get('branch'): + raise SystemExit('Each release source must name an upstream branch') + if entry.get('fallback') and not re.fullmatch(r'[0-9a-f]{40}', entry['fallback']): + raise SystemExit('Fallbacks must be full commit SHAs') + with open(os.environ['GITHUB_OUTPUT'], 'a') as output: + output.write(f"current={config['current']}\n") + output.write('versions=' + json.dumps(versions) + '\n') + output.write('releases=' + json.dumps(config['releases'], separators=(',', ':')) + '\n') + PYTHON current: + needs: configuration + env: + GAME_VERSION: ${{ needs.configuration.outputs.current }} runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -40,63 +75,76 @@ jobs: run: dotnet build Source/Client/Multiplayer.csproj -c Release - name: Stage current mod run: | - mkdir -p artifacts/current/1.7 - cp -R About artifacts/current/ - cp -R Defs Languages Textures 1.7/Assemblies 1.7/AssembliesCustom artifacts/current/1.7/ - rm -rf artifacts/current/1.7/Languages/.git + destination="artifacts/mod-build/$GAME_VERSION" + mkdir -p "$destination/files" + cp -R About "$destination/" + cp -R Defs Languages Textures "$GAME_VERSION/Assemblies" "$GAME_VERSION/AssembliesCustom" "$destination/files/" + rm -rf "$destination/files/Languages/.git" + git rev-parse HEAD > "$destination/files/source-commit.txt" - name: Audit current assembly references - run: dotnet run --project Source/RuntimeTests/MetadataAudit.csproj -c Release -- artifacts/current/1.7 + run: dotnet run --project Source/RuntimeTests/MetadataAudit.csproj -c Release -- "artifacts/mod-build/$GAME_VERSION/files" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: current-mod-${{ inputs.artifact-suffix }} - path: artifacts/current/ + name: mod-build-${{ env.GAME_VERSION }}-${{ inputs.artifact-suffix }} + path: artifacts/mod-build/ if-no-files-found: error retention-days: 7 - name: Publish current servers if: inputs.include-servers run: | for os in win linux; do - dotnet publish Source/Server/Server.csproj -c Release -r "$os-x64" --self-contained false -p:UseAppHost=true -o "artifacts/server/Server/1.7/$(if [[ $os == win ]]; then echo Windows; else echo Linux; fi)" + dotnet publish Source/Server/Server.csproj -c Release -r "$os-x64" --self-contained false -p:UseAppHost=true -o "artifacts/server/Server/$GAME_VERSION/$(if [[ $os == win ]]; then echo Windows; else echo Linux; fi)" done - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: inputs.include-servers with: - name: current-server-${{ inputs.artifact-suffix }} + name: server-build-${{ env.GAME_VERSION }}-${{ inputs.artifact-suffix }} path: artifacts/server/ retention-days: 7 if-no-files-found: error - legacy: + versions: + needs: configuration + strategy: + fail-fast: false + matrix: + source: ${{ fromJSON(needs.configuration.outputs.releases) }} + env: + GAME_VERSION: ${{ matrix.source.version }} runs-on: ubuntu-latest timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Resolve legacy 1.6 source - id: legacy + - name: Resolve version source + id: source env: - LEGACY_BRANCH: rw-1.6 - LEGACY_FALLBACK: 9df94bd04ea9a94c00bd39a276e58c4c2f965740 + SOURCE_BRANCH: ${{ matrix.source.branch }} + SOURCE_FALLBACK: ${{ matrix.source.fallback || '' }} run: | - if refs=$(git ls-remote --exit-code --heads https://github.com/rwmt/Multiplayer.git "refs/heads/$LEGACY_BRANCH"); then + if refs=$(git ls-remote --exit-code --heads https://github.com/rwmt/Multiplayer.git "refs/heads/$SOURCE_BRANCH"); then sha=${refs%%$'\t'*} - echo "Using $LEGACY_BRANCH at $sha" + echo "Using $SOURCE_BRANCH at $sha" else status=$? if [[ "$status" != 2 ]]; then - echo "Could not check the legacy branch (git exit $status)" >&2 + echo "Could not check the source branch (git exit $status)" >&2 exit "$status" fi - sha=$LEGACY_FALLBACK - echo "No $LEGACY_BRANCH branch; using pinned commit $sha" + if [[ -z "$SOURCE_FALLBACK" ]]; then + echo "Required branch $SOURCE_BRANCH does not exist and has no fallback" >&2 + exit 1 + fi + sha=$SOURCE_FALLBACK + echo "No $SOURCE_BRANCH branch; using pinned commit $sha" fi echo "sha=$sha" >> "$GITHUB_OUTPUT" - - name: Checkout legacy source + - name: Checkout version source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: rwmt/Multiplayer - ref: ${{ steps.legacy.outputs.sha }} - path: legacy-src + ref: ${{ steps.source.outputs.sha }} + path: version-src persist-credentials: false submodules: recursive - name: Set up .NET @@ -105,42 +153,45 @@ jobs: global-json-file: global.json cache: true cache-dependency-path: '**/*.csproj' - - name: Build legacy mod - working-directory: legacy-src + - name: Build version mod + working-directory: version-src run: dotnet build Source/Client/Multiplayer.csproj -c Release - - name: Stage legacy mod + - name: Stage version mod env: - LEGACY_SHA: ${{ steps.legacy.outputs.sha }} + SOURCE_SHA: ${{ steps.source.outputs.sha }} + SOURCE_CONFIG: ${{ toJSON(matrix.source) }} run: | - mkdir -p artifacts/legacy/1.6 - cp -R legacy-src/About artifacts/legacy/ + destination="artifacts/mod-build/$GAME_VERSION" + mkdir -p "$destination/files" + cp -R version-src/About "$destination/" for directory in Assemblies AssembliesCustom Defs Languages Textures; do - cp -R "legacy-src/$directory" artifacts/legacy/1.6/ + cp -R "version-src/$directory" "$destination/files/" done - rm -rf artifacts/legacy/1.6/Languages/.git - echo "$LEGACY_SHA" > artifacts/legacy/1.6/source-commit.txt + rm -rf "$destination/files/Languages/.git" + echo "$SOURCE_SHA" > "$destination/files/source-commit.txt" + printf '%s\n' "$SOURCE_CONFIG" > "$destination/source.json" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: legacy-mod-${{ inputs.artifact-suffix }} - path: artifacts/legacy/ + name: mod-build-${{ env.GAME_VERSION }}-${{ inputs.artifact-suffix }} + path: artifacts/mod-build/ retention-days: 7 if-no-files-found: error - - name: Publish legacy servers + - name: Publish version servers if: inputs.include-servers - working-directory: legacy-src + working-directory: version-src run: | for os in win linux; do - dotnet publish Source/Server/Server.csproj -c Release -r "$os-x64" --self-contained false -p:UseAppHost=true -o "../artifacts/server/Server/$(if [[ $os == win ]]; then echo Windows; else echo Linux; fi)" + dotnet publish Source/Server/Server.csproj -c Release -r "$os-x64" --self-contained false -p:UseAppHost=true -o "../artifacts/server/Server/$GAME_VERSION/$(if [[ $os == win ]]; then echo Windows; else echo Linux; fi)" done - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: inputs.include-servers with: - name: legacy-server-${{ inputs.artifact-suffix }} + name: server-build-${{ env.GAME_VERSION }}-${{ inputs.artifact-suffix }} path: artifacts/server/ retention-days: 7 if-no-files-found: error package: - needs: [current, legacy] + needs: [configuration, current, versions] runs-on: ubuntu-latest outputs: mod-artifact: ${{ steps.names.outputs.mod }} @@ -151,18 +202,16 @@ jobs: persist-credentials: false - uses: actions/download-artifact@v4 with: - name: current-mod-${{ inputs.artifact-suffix }} - path: bundle/current - - uses: actions/download-artifact@v4 - with: - name: legacy-mod-${{ inputs.artifact-suffix }} - path: bundle/legacy + pattern: mod-build-*-${{ inputs.artifact-suffix }} + path: bundle + merge-multiple: true - name: Package mod uses: ./.github/actions/package-mod with: destination: artifacts/mod/Multiplayer - current-root: bundle/current - legacy-root: bundle/legacy + builds-root: bundle + versions: ${{ needs.configuration.outputs.versions }} + current-version: ${{ needs.configuration.outputs.current }} display-name: ${{ inputs.display-name }} stamp-version: ${{ inputs.stamp-version }} - id: names @@ -180,17 +229,13 @@ jobs: - uses: actions/download-artifact@v4 if: inputs.include-servers with: - name: current-server-${{ inputs.artifact-suffix }} - path: artifacts/server - - uses: actions/download-artifact@v4 - if: inputs.include-servers - with: - name: legacy-server-${{ inputs.artifact-suffix }} + pattern: server-build-*-${{ inputs.artifact-suffix }} path: artifacts/server + merge-multiple: true - name: Add Linux server launchers if: inputs.include-servers run: | - for directory in artifacts/server/Server/Linux artifacts/server/Server/1.7/Linux; do + for directory in artifacts/server/Server/*/Linux; do cat > "$directory/Server.sh" <<'EOF' #!/usr/bin/env bash # Run the server beside this script with the supplied arguments. diff --git a/MajorReleaseProcess.txt b/MajorReleaseProcess.txt index 838fa5a77..813d09dad 100644 --- a/MajorReleaseProcess.txt +++ b/MajorReleaseProcess.txt @@ -1,7 +1,7 @@ 1. Check the mod version in Source/Common/Version.cs. -2. Check supported RimWorld versions in About/About.xml and the legacy source ref in .github/workflows/build-packages.yml. +2. Check supported RimWorld versions in About/About.xml and the release sources in .github/package-versions.json. 3. Check About/Preview.png. 4. Preserve a release branch for older RimWorld versions when dropping support. 5. Push the release tag (v*) or manually run the Build workshop workflow. -6. Download the Multiplayer-mod-workshop- artifact from the workflow run. It contains Multiplayer/ with both supported version folders and a generated modVersion. +6. Download the Multiplayer-mod-workshop- artifact from the workflow run. It contains Multiplayer/ with all configured version folders and a generated modVersion. 7. Use the downloaded ZIP for GitHub releases and the extracted Multiplayer/ folder for the Steam Workshop upload. diff --git a/README.md b/README.md index a3bff9495..6cfc3bab5 100644 --- a/README.md +++ b/README.md @@ -38,25 +38,35 @@ installation and must not be packaged. See [the metadata audit](Source/RuntimeTe for local validation and its limitations. Full host/join, reload/rejoin, stockpile filter/cache, and ritual scenarios still require live game testing. -## Legacy versions and CI packages +## Versioned CI packages -Legacy compatibility is a packaging concern. PR, beta, and workshop workflows -call `.github/workflows/build-packages.yml`. Independent jobs build this branch -and upstream's `rw-1.6` branch, when present. Until that branch exists, the legacy -job uses commit `9df94bd04ea9a94c00bd39a276e58c4c2f965740`. A branch-check network -failure fails the job rather than silently selecting the fallback. Its resolved -SHA is recorded in the artifact's `1.6/source-commit.txt`. +The Workshop mod currently includes RimWorld 1.5 and 1.6. New packages retain +both and add the current 1.7 source build. `.github/package-versions.json` is the +single list of included versions: `current` identifies this source branch; +`releases` lists the upstream branches to build independently. Adding another +release entry creates another CI matrix job without changing the packaging code. -Legacy code is built unchanged with its own references. Backports must be made -explicitly on the legacy source branch; changes here do not automatically affect -1.6. A shared GitHub action assembles independently built version folders, keeping -each version's Defs, Languages, Textures, assemblies, and dependency declarations. -Only release metadata is shared at the package root. `LoadFolders.xml` selects the -correct assets and DLLs. No standalone bundler script is used. +PR, beta, and workshop workflows call `.github/workflows/build-packages.yml`. +The 1.5 job uses upstream `rw-1.5`. The 1.6 job uses `rw-1.6` when available, +otherwise commit `9df94bd04ea9a94c00bd39a276e58c4c2f965740`. Network failures fail +the job rather than silently selecting a fallback. Every version records the +resolved SHA in `/source-commit.txt` in the package. -PR runs upload mod, server, and test-result artifacts for seven days. Server -artifacts retain .NET 8 legacy servers under `Server/Windows` and `Server/Linux`, -with current .NET 10 servers under `Server/1.7/Windows` and `Server/1.7/Linux`. +Each source is built unchanged with its own references. Backports are explicit; +changes to the 1.7 code do not automatically affect other game versions. Build +artifacts use a common `/About` and `/files` layout. The shared +packaging action consumes one `builds-root` and a list of required versions. It +fails on missing builds, preserves each version's Defs, Languages, Textures and +assemblies, and generates matching dependency metadata and `LoadFolders.xml`. +Only release metadata is shared at the package root. No bundler script is used. + +The 1.5 release entry explicitly retains the Workshop package's Prepatcher +requirement: the branch's older About metadata lists Harmony alone, but its ritual +implementation uses Prepatcher. Additional release dependencies are merged without +duplicating source declarations. + +PR runs upload mod, server, and test-result artifacts for seven days. Standalone +servers use `Server//Windows` and `Server//Linux` for every version. The workshop workflow stamps the mod version and uploads the combined mod package. ## Donations From 34c6a6a9efe25cefc3fe5d74c3bb8c5aa0bab2cb Mon Sep 17 00:00:00 2001 From: roman_gr Date: Wed, 28 Oct 2026 19:56:25 +0100 Subject: [PATCH 18/19] Give CI jobs and artifact steps descriptive names --- .github/workflows/build-beta.yml | 11 +++-- .github/workflows/build-packages.yml | 69 +++++++++++++++++----------- .github/workflows/build-workshop.yml | 1 + .github/workflows/pr-check.yml | 9 ++-- 4 files changed, 57 insertions(+), 33 deletions(-) diff --git a/.github/workflows/build-beta.yml b/.github/workflows/build-beta.yml index 8e62a3036..b5a88c526 100644 --- a/.github/workflows/build-beta.yml +++ b/.github/workflows/build-beta.yml @@ -8,11 +8,13 @@ permissions: contents: read jobs: build: + name: Build beta mod and server packages uses: ./.github/workflows/build-packages.yml with: artifact-suffix: beta-${{ github.run_id }} display-name: Multiplayer [Continuous] release: + name: Publish beta release needs: build runs-on: ubuntu-latest permissions: @@ -20,14 +22,17 @@ jobs: env: GH_TOKEN: ${{ github.token }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out release metadata + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/download-artifact@v4 + - name: Download combined mod package + uses: actions/download-artifact@v4 with: name: ${{ needs.build.outputs.mod-artifact }} path: output - - uses: actions/download-artifact@v4 + - name: Download combined server package + uses: actions/download-artifact@v4 with: name: ${{ needs.build.outputs.server-artifact }} path: output diff --git a/.github/workflows/build-packages.yml b/.github/workflows/build-packages.yml index 3329c57bc..eeb851159 100644 --- a/.github/workflows/build-packages.yml +++ b/.github/workflows/build-packages.yml @@ -23,13 +23,15 @@ permissions: contents: read jobs: configuration: + name: Select supported game versions runs-on: ubuntu-latest outputs: current: ${{ steps.config.outputs.current }} releases: ${{ steps.config.outputs.releases }} versions: ${{ steps.config.outputs.versions }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out packaging configuration + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - id: config @@ -55,25 +57,27 @@ jobs: output.write('releases=' + json.dumps(config['releases'], separators=(',', ':')) + '\n') PYTHON current: + name: Build RimWorld ${{ needs.configuration.outputs.current }} needs: configuration env: GAME_VERSION: ${{ needs.configuration.outputs.current }} runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out current game source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false submodules: recursive - - name: Set up .NET + - name: Set up .NET SDK for RimWorld ${{ env.GAME_VERSION }} uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json cache: true cache-dependency-path: '**/*.csproj' - - name: Build current mod + - name: Build RimWorld ${{ env.GAME_VERSION }} mod (Release) run: dotnet build Source/Client/Multiplayer.csproj -c Release - - name: Stage current mod + - name: Stage RimWorld ${{ env.GAME_VERSION }} mod files run: | destination="artifacts/mod-build/$GAME_VERSION" mkdir -p "$destination/files" @@ -81,21 +85,23 @@ jobs: cp -R Defs Languages Textures "$GAME_VERSION/Assemblies" "$GAME_VERSION/AssembliesCustom" "$destination/files/" rm -rf "$destination/files/Languages/.git" git rev-parse HEAD > "$destination/files/source-commit.txt" - - name: Audit current assembly references + - name: Audit RimWorld ${{ env.GAME_VERSION }} mod references run: dotnet run --project Source/RuntimeTests/MetadataAudit.csproj -c Release -- "artifacts/mod-build/$GAME_VERSION/files" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - name: Upload RimWorld ${{ env.GAME_VERSION }} mod build + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: mod-build-${{ env.GAME_VERSION }}-${{ inputs.artifact-suffix }} path: artifacts/mod-build/ if-no-files-found: error retention-days: 7 - - name: Publish current servers + - name: Publish RimWorld ${{ env.GAME_VERSION }} Windows/Linux servers if: inputs.include-servers run: | for os in win linux; do dotnet publish Source/Server/Server.csproj -c Release -r "$os-x64" --self-contained false -p:UseAppHost=true -o "artifacts/server/Server/$GAME_VERSION/$(if [[ $os == win ]]; then echo Windows; else echo Linux; fi)" done - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - name: Upload RimWorld ${{ env.GAME_VERSION }} Windows/Linux server builds + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: inputs.include-servers with: name: server-build-${{ env.GAME_VERSION }}-${{ inputs.artifact-suffix }} @@ -103,6 +109,7 @@ jobs: retention-days: 7 if-no-files-found: error versions: + name: Build RimWorld ${{ matrix.source.version }} needs: configuration strategy: fail-fast: false @@ -113,10 +120,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out build configuration + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Resolve version source + - name: Resolve RimWorld ${{ env.GAME_VERSION }} source branch or fallback id: source env: SOURCE_BRANCH: ${{ matrix.source.branch }} @@ -139,7 +147,7 @@ jobs: echo "No $SOURCE_BRANCH branch; using pinned commit $sha" fi echo "sha=$sha" >> "$GITHUB_OUTPUT" - - name: Checkout version source + - name: Check out RimWorld ${{ env.GAME_VERSION }} source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: rwmt/Multiplayer @@ -147,16 +155,16 @@ jobs: path: version-src persist-credentials: false submodules: recursive - - name: Set up .NET + - name: Set up .NET SDK for RimWorld ${{ env.GAME_VERSION }} uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json cache: true cache-dependency-path: '**/*.csproj' - - name: Build version mod + - name: Build RimWorld ${{ env.GAME_VERSION }} mod (Release) working-directory: version-src run: dotnet build Source/Client/Multiplayer.csproj -c Release - - name: Stage version mod + - name: Stage RimWorld ${{ env.GAME_VERSION }} mod files env: SOURCE_SHA: ${{ steps.source.outputs.sha }} SOURCE_CONFIG: ${{ toJSON(matrix.source) }} @@ -170,20 +178,22 @@ jobs: rm -rf "$destination/files/Languages/.git" echo "$SOURCE_SHA" > "$destination/files/source-commit.txt" printf '%s\n' "$SOURCE_CONFIG" > "$destination/source.json" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - name: Upload RimWorld ${{ env.GAME_VERSION }} mod build + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: mod-build-${{ env.GAME_VERSION }}-${{ inputs.artifact-suffix }} path: artifacts/mod-build/ retention-days: 7 if-no-files-found: error - - name: Publish version servers + - name: Publish RimWorld ${{ env.GAME_VERSION }} Windows/Linux servers if: inputs.include-servers working-directory: version-src run: | for os in win linux; do dotnet publish Source/Server/Server.csproj -c Release -r "$os-x64" --self-contained false -p:UseAppHost=true -o "../artifacts/server/Server/$GAME_VERSION/$(if [[ $os == win ]]; then echo Windows; else echo Linux; fi)" done - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - name: Upload RimWorld ${{ env.GAME_VERSION }} Windows/Linux server builds + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: inputs.include-servers with: name: server-build-${{ env.GAME_VERSION }}-${{ inputs.artifact-suffix }} @@ -191,21 +201,24 @@ jobs: retention-days: 7 if-no-files-found: error package: + name: Assemble release packages needs: [configuration, current, versions] runs-on: ubuntu-latest outputs: mod-artifact: ${{ steps.names.outputs.mod }} server-artifact: ${{ steps.names.outputs.server }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out packaging action and release metadata + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/download-artifact@v4 + - name: Download mod builds for all supported versions + uses: actions/download-artifact@v4 with: pattern: mod-build-*-${{ inputs.artifact-suffix }} path: bundle merge-multiple: true - - name: Package mod + - name: Assemble multi-version mod package uses: ./.github/actions/package-mod with: destination: artifacts/mod/Multiplayer @@ -214,25 +227,28 @@ jobs: current-version: ${{ needs.configuration.outputs.current }} display-name: ${{ inputs.display-name }} stamp-version: ${{ inputs.stamp-version }} - - id: names + - name: Name mod and server artifacts + id: names env: SUFFIX: ${{ inputs.artifact-suffix }} run: | echo "mod=Multiplayer-mod-$SUFFIX" >> "$GITHUB_OUTPUT" echo "server=Multiplayer-server-$SUFFIX" >> "$GITHUB_OUTPUT" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - name: Upload combined mod package + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ steps.names.outputs.mod }} path: artifacts/mod/ retention-days: 7 if-no-files-found: error - - uses: actions/download-artifact@v4 + - name: Download server builds for all supported versions + uses: actions/download-artifact@v4 if: inputs.include-servers with: pattern: server-build-*-${{ inputs.artifact-suffix }} path: artifacts/server merge-multiple: true - - name: Add Linux server launchers + - name: Add Linux server launchers for each version if: inputs.include-servers run: | for directory in artifacts/server/Server/*/Linux; do @@ -245,7 +261,8 @@ jobs: EOF chmod +x "$directory/Server.sh" done - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - name: Upload combined server package + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: inputs.include-servers with: name: ${{ steps.names.outputs.server }} diff --git a/.github/workflows/build-workshop.yml b/.github/workflows/build-workshop.yml index 05241feb1..bd4c105e8 100644 --- a/.github/workflows/build-workshop.yml +++ b/.github/workflows/build-workshop.yml @@ -7,6 +7,7 @@ permissions: contents: read jobs: build: + name: Build Workshop mod package uses: ./.github/workflows/build-packages.yml with: artifact-suffix: workshop-${{ github.run_id }} diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 4718f95b6..ef8e33f38 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -36,19 +36,19 @@ jobs: - name: Restore dependencies run: dotnet restore Source/Multiplayer.sln - - name: Build debug + - name: Build mod, server and tests (Debug) run: dotnet build Source/Multiplayer.sln -c Debug --no-restore - - name: Build release + - name: Build mod, server and tests (Release) run: dotnet build Source/Multiplayer.sln -c Release --no-restore - - name: Run tests + - name: Run unit tests run: >- dotnet test Source/Tests/Tests.csproj -c Release --no-build --no-restore --logger "trx;LogFileName=TestResults.trx" --results-directory artifacts/test-results - - name: Upload test results + - name: Upload unit test results if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -60,6 +60,7 @@ jobs: # Independent of tests so buildable PRs still supply reproduction packages. package-artifacts: + name: Build PR mod and server packages uses: ./.github/workflows/build-packages.yml with: artifact-suffix: pr${{ github.event.pull_request.number }}-${{ github.sha }} From cf163a2e5464777038107ce8ef5550720230ccf2 Mon Sep 17 00:00:00 2001 From: roman_gr Date: Wed, 28 Oct 2026 22:41:11 +0100 Subject: [PATCH 19/19] Separate fast context tracing from slow managed stack capture --- README.md | 13 ++++ Source/Client/Desyncs/DeferredStackTracing.cs | 30 ++-------- Source/Client/Desyncs/DesyncTraceCapture.cs | 49 +++++++++++++++ Source/Client/Desyncs/StackTraceLogItem.cs | 2 +- Source/Client/Settings/DesyncTracingMode.cs | 8 +++ Source/Client/Settings/MpSettings.cs | 6 +- Source/Client/Settings/MpSettingsUI.cs | 16 +++-- Source/Tests/DesyncTraceCaptureTest.cs | 60 +++++++++++++++++++ Source/Tests/Tests.csproj | 2 + 9 files changed, 151 insertions(+), 35 deletions(-) create mode 100644 Source/Client/Desyncs/DesyncTraceCapture.cs create mode 100644 Source/Client/Settings/DesyncTracingMode.cs create mode 100644 Source/Tests/DesyncTraceCaptureTest.cs diff --git a/README.md b/README.md index 6cfc3bab5..c1c45312b 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,19 @@ installation and must not be packaged. See [the metadata audit](Source/RuntimeTe for local validation and its limitations. Full host/join, reload/rejoin, stockpile filter/cache, and ritual scenarios still require live game testing. +## Desync tracing modes + +RNG, world, map, and command synchronization checks remain enabled in every mode. +The normal **Fast** setting records lightweight event context (tick, RNG state and +thing identity), without walking managed call stacks or formatting stack strings. +**None** disables diagnostic event history. **Slow** additionally captures detailed +managed stacks for each eligible event and can significantly increase CPU and memory +usage. Use Slow only when collecting diagnostics for a reproducible issue. + +Diagnostic history also requires the session's desync trace logging to be enabled. +The selector is available in the normal mod settings in Release builds, and the +chosen mode is saved. Managed tracing works on ARM64 as well as x64. + ## Versioned CI packages The Workshop mod currently includes RimWorld 1.5 and 1.6. New packages retain diff --git a/Source/Client/Desyncs/DeferredStackTracing.cs b/Source/Client/Desyncs/DeferredStackTracing.cs index 38c5aeb5f..c7cf79954 100644 --- a/Source/Client/Desyncs/DeferredStackTracing.cs +++ b/Source/Client/Desyncs/DeferredStackTracing.cs @@ -1,8 +1,5 @@ using System.Collections.Generic; -using System.Diagnostics; -using System.Text; using System.Reflection; -using System.Runtime.InteropServices; using HarmonyLib; using Multiplayer.Client.Patches; using Multiplayer.Common; @@ -28,36 +25,20 @@ static IEnumerable TargetMethods() public static int acc; - private static bool SupportsDeferredStackTracing => - RuntimeInformation.ProcessArchitecture is Architecture.X64 or Architecture.X86; - public static void Postfix() { if (!ShouldAddStackTraceForDesyncLog()) return; + if (!DesyncTraceCapture.TryCapture(Multiplayer.settings.desyncTracingMode, out var trace)) return; + var logItem = StackTraceLogItemRaw.GetFromPool(); - // Managed stack capture for the CoreCLR runtime. - var frames = new StackTrace(1, false).GetFrames(); - var text = new StringBuilder(); - int managedHash = 17; - int managedDepth = System.Math.Min(frames?.Length ?? 0, StackTraceLogItemRaw.MaxDepth); - for (int i = 0; i < managedDepth; i++) - { - var method = frames[i].GetMethod(); - var name = $"{method?.DeclaringType?.FullName}.{method}"; - text.AppendLine(name); - // Explicit stable hashing; no process-randomized string hashes. - foreach (char c in name) - managedHash = unchecked(managedHash * 31 + c); - } - logItem.managedTrace = text.ToString(); - Multiplayer.game.sync.TryAddStackTraceForDesyncLogRaw(logItem, managedDepth, managedHash); + logItem.managedTrace = trace.Text; + Multiplayer.game.sync.TryAddStackTraceForDesyncLogRaw(logItem, trace.Depth, trace.Hash); acc++; } public static bool ShouldAddStackTraceForDesyncLog() { - if (!SupportsDeferredStackTracing) return false; if (Multiplayer.Client == null) return false; if (Multiplayer.settings.desyncTracingMode == DesyncTracingMode.None) return false; if (Multiplayer.game == null) return false; @@ -107,7 +88,8 @@ public static class EndCurrentJobPatch { static void Prefix(Pawn_JobTracker __instance) { - if (MpVersion.IsDebug && __instance.curJob != null && DeferredStackTracing.ShouldAddStackTraceForDesyncLog()) + if (MpVersion.IsDebug && __instance.curJob != null && DeferredStackTracing.ShouldAddStackTraceForDesyncLog() + && Multiplayer.settings.desyncTracingMode == DesyncTracingMode.Slow) Multiplayer.game.sync.TryAddInfoForDesyncLog($"EndCurrentJob for {__instance.pawn}: {__instance.curJob}", ""); } } diff --git a/Source/Client/Desyncs/DesyncTraceCapture.cs b/Source/Client/Desyncs/DesyncTraceCapture.cs new file mode 100644 index 000000000..3529beeeb --- /dev/null +++ b/Source/Client/Desyncs/DesyncTraceCapture.cs @@ -0,0 +1,49 @@ +using System; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Text; + +namespace Multiplayer.Client.Desyncs; + +internal readonly record struct DesyncTrace(string Text, int Depth, int Hash); + +internal static class DesyncTraceCapture +{ + internal const int MaxDepth = 32; + internal const string FastDescription = "Detailed stack not captured (Fast tracing mode)."; + + // This decision must precede stack walking, formatting, and any pooled-item allocation. + internal static bool TryCapture(DesyncTracingMode mode, out DesyncTrace trace) + { + trace = default; + if (mode == DesyncTracingMode.Fast) + { + trace = new DesyncTrace(FastDescription, 0, 0); + return true; + } + if (mode != DesyncTracingMode.Slow) + return false; + + trace = CaptureSlow(); + return true; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static DesyncTrace CaptureSlow() + { + // Skip this helper. Retain caller frames even when the JIT inlines TryCapture. + var frames = new StackTrace(1, false).GetFrames(); + var text = new StringBuilder(); + int hash = 17; + int depth = Math.Min(frames?.Length ?? 0, MaxDepth); + for (int i = 0; i < depth; i++) + { + var method = frames[i].GetMethod(); + var name = $"{method?.DeclaringType?.FullName}.{method}"; + text.AppendLine(name); + foreach (char c in name) + hash = unchecked(hash * 31 + c); + } + return new DesyncTrace(text.ToString(), depth, hash); + } +} diff --git a/Source/Client/Desyncs/StackTraceLogItem.cs b/Source/Client/Desyncs/StackTraceLogItem.cs index e0c6ec0b6..4c92a3f46 100644 --- a/Source/Client/Desyncs/StackTraceLogItem.cs +++ b/Source/Client/Desyncs/StackTraceLogItem.cs @@ -37,7 +37,7 @@ public class StackTraceLogItemObj : StackTraceLogItem public class StackTraceLogItemRaw : StackTraceLogItem { - public const int MaxDepth = 32; + public const int MaxDepth = DesyncTraceCapture.MaxDepth; public int depth; public string managedTrace; diff --git a/Source/Client/Settings/DesyncTracingMode.cs b/Source/Client/Settings/DesyncTracingMode.cs new file mode 100644 index 000000000..4ff1d035c --- /dev/null +++ b/Source/Client/Settings/DesyncTracingMode.cs @@ -0,0 +1,8 @@ +namespace Multiplayer.Client; + +public enum DesyncTracingMode +{ + None, + Fast, + Slow +} diff --git a/Source/Client/Settings/MpSettings.cs b/Source/Client/Settings/MpSettings.cs index 31ae7fdbb..f45f30cf1 100644 --- a/Source/Client/Settings/MpSettings.cs +++ b/Source/Client/Settings/MpSettings.cs @@ -65,6 +65,7 @@ public override void ExposeData() Scribe_Values.Look(ref showDevInfo, "showDevInfo"); Scribe_Values.Look(ref includeReplayInDesync, "includeReplayInDesync", VersionChecker.IsContinuousRelease); Scribe_Values.Look(ref desyncTracesRadius, "desyncTracesRadius", 40); + Scribe_Values.Look(ref desyncTracingMode, "desyncTracingMode", DesyncTracingMode.Fast); Scribe_Values.Look(ref serverAddress, "serverAddress", "127.0.0.1"); Scribe_Values.Look(ref showModCompatibility, "showModCompatibility", true); Scribe_Values.Look(ref hideTranslationMods, "hideTranslationMods", true); @@ -90,11 +91,6 @@ public override void ExposeData() } } - public enum DesyncTracingMode - { - None, Fast, Slow - } - public struct ColorRGBClient : IExposable { public byte r, g, b; diff --git a/Source/Client/Settings/MpSettingsUI.cs b/Source/Client/Settings/MpSettingsUI.cs index 541ae1cd5..d05222019 100644 --- a/Source/Client/Settings/MpSettingsUI.cs +++ b/Source/Client/Settings/MpSettingsUI.cs @@ -107,6 +107,17 @@ public static void DoGeneralSettings(MpSettings settings, Rect inRect, Rect page if (VersionChecker.IsContinuousRelease || VersionChecker.IsLocalBuild) listing.CheckboxLabeled("MpIncludeReplayInDesync".Translate(), ref settings.includeReplayInDesync); + using (MpStyle.Set(TextAnchor.MiddleCenter)) + if (listing.ButtonTextLabeled("Desync tracing mode", settings.desyncTracingMode.ToString())) + settings.desyncTracingMode = settings.desyncTracingMode.Cycle(); + + listing.Label(settings.desyncTracingMode switch + { + DesyncTracingMode.None => "None: Diagnostic history disabled.", + DesyncTracingMode.Slow => "Slow: Detailed stacks (high CPU/memory cost).", + _ => "Fast: Context only (recommended)." + }, tooltip: "Desync detection stays enabled in every mode. Fast records event context without call stacks. Slow captures a managed stack for each eligible event and can significantly increase CPU and memory usage."); + if (Prefs.DevMode) { listing.CheckboxLabeled("Show debug info", ref settings.showDevInfo); @@ -124,11 +135,6 @@ public static void DoGeneralSettings(MpSettings settings, Rect inRect, Rect page if (listing.ButtonText("Register file associations")) FileAssoc.Register(); } } -#if DEBUG - using (MpStyle.Set(TextAnchor.MiddleCenter)) - if (listing.ButtonTextLabeled("Desync tracing mode", settings.desyncTracingMode.ToString())) - settings.desyncTracingMode = settings.desyncTracingMode.Cycle(); -#endif } listing.End(); diff --git a/Source/Tests/DesyncTraceCaptureTest.cs b/Source/Tests/DesyncTraceCaptureTest.cs new file mode 100644 index 000000000..cb8a53ca5 --- /dev/null +++ b/Source/Tests/DesyncTraceCaptureTest.cs @@ -0,0 +1,60 @@ +using System.Runtime.CompilerServices; +using Multiplayer.Client; +using Multiplayer.Client.Desyncs; + +namespace Tests; + +public class DesyncTraceCaptureTest +{ + [TestCase(DesyncTracingMode.None)] + [TestCase((DesyncTracingMode)99)] + public void DisabledOrUnknownModeDoesNotCollect(DesyncTracingMode mode) + { + Assert.That(DesyncTraceCapture.TryCapture(mode, out var trace), Is.False); + Assert.That(trace, Is.EqualTo(default(DesyncTrace))); + } + + [Test] + public void FastReturnsContextMarkerWithoutAllocatingManagedStacks() + { + for (int i = 0; i < 100; i++) + DesyncTraceCapture.TryCapture(DesyncTracingMode.Fast, out _); + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 10000; i++) + DesyncTraceCapture.TryCapture(DesyncTracingMode.Fast, out _); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.That(allocated, Is.Zero, "Fast must not allocate stack traces or format event strings"); + Assert.That(DesyncTraceCapture.TryCapture(DesyncTracingMode.Fast, out var trace), Is.True); + Assert.That(trace.Text, Is.SameAs(DesyncTraceCapture.FastDescription)); + Assert.That(trace.Depth, Is.Zero); + Assert.That(trace.Hash, Is.Zero); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static DesyncTrace CaptureFromDiagnosticCaller() + { + DesyncTraceCapture.TryCapture(DesyncTracingMode.Slow, out var trace); + return trace; + } + + [Test] + public void SlowCapturesDetailedManagedCallerFrames() + { + var trace = CaptureFromDiagnosticCaller(); + Assert.That(trace.Text, Does.Contain(nameof(CaptureFromDiagnosticCaller))); + Assert.That(trace.Depth, Is.InRange(1, DesyncTraceCapture.MaxDepth)); + Assert.That(trace.Text, Is.Not.EqualTo(DesyncTraceCapture.FastDescription)); + } + + [Test] + public void SwitchingModesDoesNotReuseSlowStackData() + { + DesyncTraceCapture.TryCapture(DesyncTracingMode.Slow, out var trace); + Assert.That(trace.Depth, Is.Positive); + DesyncTraceCapture.TryCapture(DesyncTracingMode.Fast, out trace); + Assert.That(trace.Text, Is.EqualTo(DesyncTraceCapture.FastDescription)); + DesyncTraceCapture.TryCapture(DesyncTracingMode.None, out trace); + Assert.That(trace, Is.EqualTo(default(DesyncTrace))); + } +} diff --git a/Source/Tests/Tests.csproj b/Source/Tests/Tests.csproj index ab5a60046..97f7ab6c1 100644 --- a/Source/Tests/Tests.csproj +++ b/Source/Tests/Tests.csproj @@ -23,6 +23,8 @@ + +