From cf847895dac14f0f7d2b165db13f679c44eddd12 Mon Sep 17 00:00:00 2001 From: retr0 <45705896+1Anton10@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:40:52 +0500 Subject: [PATCH 1/9] Fix Exosuit Framework multiplayer desyncs Update compat for the Exosuit/Mechsuit rewrite: sync gizmos, ITab work, turrets, and RNG; block unsynced job creation from sleep/gear patches. Co-authored-by: Cursor --- README.md | 1 + Source/Mods/ExosuitFramework.cs | 185 ++++++++++++++++++++++++++------ 2 files changed, 156 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 9c8dcf8d..15a72aac 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Dubs Paint Shop | [![url][steam]](https://steamcommunity.com/sharedfiles/filedet Eccentric Tech - Fusion Power | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2742125879) Enhanced Vat Learning | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2908453851) [![url][repo]](https://github.com/SmArtKar/EnhancedVatLearning) Extended Bioengineering for VFE Insectoids | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2706534548) +Exosuit Framework | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=3352894993) [![url][repo]](https://github.com/AobaKuma/MechsuitFramework) Fertile Fields | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2012735237) Follow Me | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=715759739) [![url][repo]](https://github.com/fluffy-mods/FollowMe) Gastronomy | [![url][steam]](https://steamcommunity.com/sharedfiles/filedetails/?id=2279786905) [![url][repo]](https://github.com/OrionFive/Gastronomy) diff --git a/Source/Mods/ExosuitFramework.cs b/Source/Mods/ExosuitFramework.cs index 87af6eb9..af77436f 100644 --- a/Source/Mods/ExosuitFramework.cs +++ b/Source/Mods/ExosuitFramework.cs @@ -1,67 +1,192 @@ -using System.Linq; +using System; using HarmonyLib; using Multiplayer.API; +using RimWorld; using Verse; +using Verse.AI; namespace Multiplayer.Compat; -/// Exosuit Framework by AobaKuma +/// Exosuit Framework (MechsuitFramework) by AobaKuma /// /// [MpCompatFor("Aoba.Exosuit.Framework")] public class ExosuitFramework { + private static JobDef wgSleepJobDef; + private static Type exosuitCoreType; + public ExosuitFramework(ModContentPack mod) { LongEventHandler.ExecuteWhenFinished(LatePatch); - #region Gizmos + #region RNG { - // Eject to location (0), launch to map (3), release (8) - MpCompat.RegisterLambdaMethod("WalkerGear.Building_EjectorBay", nameof(Building.GetGizmos), 0, 3, 8) - // The first 2 gizmos use current map, the last one doesn't care - .SkipLast(1).SetContext(SyncContext.CurrentMap); - // Get in (0), toggle auto repair (2) - MpCompat.RegisterLambdaMethod("WalkerGear.Building_MaintenanceBay", nameof(Building.GetGizmos), 0, 2); - // Toggle safety (1), syncing it is not necessary, as it's only used to enable the eject - // gizmo. Better to sync it anyway in case some mods end up using it in some other way. - // Eject (2) is synced through WalkerGear_Core:Eject for more compatibility. - MpCompat.RegisterLambdaMethod("WalkerGear.ModuleComp_EmergencyEject", nameof(ThingComp.CompGetWornGizmosExtra), 1); + // Combat, disassembly, turret spread, and repair timing all consume Rand and can desync. + PatchingUtilities.PatchPushPopRand([ + "Exosuit.Exosuit_Core:CheckPreAbsorbDamage", + "Exosuit.Exosuit_Core:GetPostArmorDamage", + "Exosuit.Exosuit_Core:ApplyDamageToModules", + "Exosuit.Exosuit_Core:ExosuitDestory", + "Exosuit.MechUtility:DissambleFrom", + "Exosuit.Verb_MeleeSweep:DoSweep", + "Exosuit.Building_AutoRepairArm:Tick", + "Mechsuit.AsyncShootVerb:TryCastShot", + ]); } #endregion - #region Float Menus + #region Gizmos { - var type = AccessTools.TypeByName("WalkerGear.Building_MaintenanceBay"); - // Add/replace/remove methods, called from ITab_MechGear - MP.RegisterSyncMethod(type, "AddOrReplaceModule"); - MP.RegisterSyncMethod(type, "RemoveModules"); - - type = AccessTools.TypeByName("WalkerGear.FloatMenuMakerMap_MakeForFrame"); - // Take to maintenance bay. Needs to be synced due to the method - // modifying a field after starting a job - `job.count = 1`. - MpCompat.RegisterLambdaDelegate(type, "AddHumanlikeOrders", 1); + // Assigned-bay get in / get off on the pawn gizmo bar. + MpCompat.RegisterLambdaMethod("Exosuit.Patch_Pawn_GetGizmos", "Postfix", 0, 1); + + var maintenanceBayType = AccessTools.TypeByName("Exosuit.Building_MaintenanceBay"); + // Assigned get in (0), target get in (3), auto repair toggle (4). + MpCompat.RegisterLambdaMethod(maintenanceBayType, nameof(Building.GetGizmos), 0, 3, 4); + + // Ejector bay: throw (0), launch to map (1), release (2). + var ejectorLambdas = MpCompat.RegisterLambdaMethod("Exosuit.Building_EjectorBay", nameof(Building.GetGizmos), 0, 1, 2); + ejectorLambdas[0].SetContext(SyncContext.CurrentMap); + ejectorLambdas[1].SetContext(SyncContext.CurrentMap); + PatchingUtilities.ReplaceCurrentMapUsage("Exosuit.Building_EjectorBay:GetGizmos"); + + var turretType = AccessTools.TypeByName("Mechsuit.CompTurretGun"); + MpCompat.RegisterLambdaMethod(turretType, "GetGizmos", 0, 1); } #endregion - // Once it's included in a mod/implemented, also patch Building_EjectorBay and WG_PawnFlyer. - // The building (and thus the flyer) aren't used by this mod or any of its addons yet. + #region Float menus + + { + var maintenanceBayType = AccessTools.TypeByName("Exosuit.Building_MaintenanceBay"); + MpCompat.RegisterLambdaDelegate(maintenanceBayType, nameof(Building.GetFloatMenuOptions), 0); + MpCompat.RegisterLambdaDelegate("Exosuit.Building_EjectorBay", nameof(Building.GetFloatMenuOptions), 0); + + RegisterFloatMenuProviderLambdas(); + + MP.RegisterSyncMethod(maintenanceBayType, "RequestInstallModule"); + MP.RegisterSyncMethod(maintenanceBayType, "RequestRemoveModule"); + MP.RegisterSyncMethod(maintenanceBayType, "CancelPendingWork"); + MP.RegisterSyncMethod(maintenanceBayType, "CancelPendingInstall"); + MP.RegisterSyncMethod(maintenanceBayType, "CancelAllCoreWork"); + MP.RegisterSyncMethod(maintenanceBayType, "AddOrReplaceModule"); + MP.RegisterSyncMethod(maintenanceBayType, "RemoveModules"); + } + + #endregion } private static void LatePatch() { - #region Gizmos + #region Jobs { - // Eject, only called from lambda in ModuleComp_EmergencyEject. Could sync it instead, - // but could be called from other mods. Probably safer to call this method. - MP.RegisterSyncMethod(AccessTools.DeclaredMethod("WalkerGear.WalkerGear_Core:Eject")); + // Desync logs (MpDesyncs): Pawn_JobTraceker_Patch replaces LayDown with TryTakeOrderedJob, + // issuing unsynced job IDs. Rewrite the incoming job instead so MP keeps the same job id. + var startJob = AccessTools.Method(typeof(Pawn_JobTracker), nameof(Pawn_JobTracker.StartJob)); + MpCompat.harmony.Patch(startJob, prefix: new HarmonyMethod(typeof(ExosuitFramework), nameof(RewriteExosuitSleepJob))); + + var jobReplacer = AccessTools.Method("Exosuit.HarmonyPatches.Pawn_JobTraceker_Patch:JobReplacerPatch"); + if (jobReplacer != null) + MpCompat.harmony.Patch(jobReplacer, prefix: new HarmonyMethod(typeof(ExosuitFramework), nameof(SkipIfMultiplayer))); + + // JobGiver_GetRest/Work GetPriority call these every think tick and spawn local jobs. + var gearOn = AccessTools.Method("Exosuit.MechUtility:TryMakeJob_GearOn"); + var gearOff = AccessTools.Method("Exosuit.MechUtility:TryMakeJob_GearOff"); + if (gearOn != null) + MpCompat.harmony.Patch(gearOn, prefix: new HarmonyMethod(typeof(ExosuitFramework), nameof(SkipIfMultiplayer))); + if (gearOff != null) + MpCompat.harmony.Patch(gearOff, prefix: new HarmonyMethod(typeof(ExosuitFramework), nameof(SkipIfMultiplayer))); } #endregion + + #region Turrets + + { + var turretType = AccessTools.TypeByName("Mechsuit.CompTurretGun"); + MP.RegisterSyncMethod(turretType, "OrderAttack"); + MP.RegisterSyncMethod(turretType, "ClearForcedTarget"); + } + + #endregion + + #region Catapult + + { + var flyerType = AccessTools.TypeByName("Exosuit.WG_PawnFlyer"); + if (flyerType != null) + MpCompat.RegisterLambdaDelegate(flyerType, "GetOptionsForTile", 0, 1); + + var jumpType = AccessTools.TypeByName("Exosuit.WG_AbilityVerb_QuickJump"); + var doJump = AccessTools.Method(jumpType, "DoJump", + [ + typeof(Pawn), typeof(Map), typeof(LocalTargetInfo), typeof(LocalTargetInfo), typeof(bool), typeof(bool) + ]); + if (doJump != null) + MP.RegisterSyncMethod(doJump); + } + + #endregion + } + + private static void RewriteExosuitSleepJob(ref Job newJob, Pawn ___pawn) + { + if (!MP.IsInMultiplayer || newJob == null || ___pawn == null) + return; + + wgSleepJobDef ??= DefDatabase.GetNamedSilentFail("WG_SleepInWalkerCore"); + if (wgSleepJobDef == null) + return; + + if (newJob.def != JobDefOf.LayDown && newJob.def != JobDefOf.Wait_Asleep) + return; + + if (!WearingExosuitCore(___pawn)) + return; + + newJob.def = wgSleepJobDef; + } + + private static bool WearingExosuitCore(Pawn pawn) + { + exosuitCoreType ??= AccessTools.TypeByName("Exosuit.Exosuit_Core"); + if (exosuitCoreType == null || pawn?.apparel?.WornApparel == null) + return false; + + foreach (var apparel in pawn.apparel.WornApparel) + { + if (exosuitCoreType.IsInstanceOfType(apparel)) + return true; + } + + return false; + } + + /// Blocks exosuit patches that create jobs outside of MP's synced StartJob path. + private static bool SkipIfMultiplayer() => !MP.IsInMultiplayer; + + private static void RegisterFloatMenuProviderLambdas() + { + var providerType = AccessTools.TypeByName("Exosuit.FloatMenuOptionProvider_ExosuitDown"); + if (providerType == null) + return; + + var floatMenuContextType = AccessTools.TypeByName("RimWorld.FloatMenuContext"); + var pawnMethod = AccessTools.Method(providerType, "GetOptionsFor", [typeof(Pawn), floatMenuContextType]); + if (pawnMethod == null) + return; + + foreach (var ord in new[] { 0, 1 }) + { + var lambda = MpMethodUtil.GetLambda(providerType, pawnMethod.Name, MethodType.Normal, + [typeof(Pawn), floatMenuContextType], ord); + MP.RegisterSyncDelegate(providerType, lambda.DeclaringType!.Name, lambda.Name); + } } -} \ No newline at end of file +} From a24ccffe2b7699c9f6c2fcad16ead2eeea6dcc7b Mon Sep 17 00:00:00 2001 From: retr0 <45705896+1Anton10@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:50:19 +0500 Subject: [PATCH 2/9] Fix Exosuit compat init crash on load Defer type-dependent gizmo and sync registrations to LatePatch so Exosuit assemblies are loaded before lambda lookup. Co-authored-by: Cursor --- Source/Mods/ExosuitFramework.cs | 62 ++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/Source/Mods/ExosuitFramework.cs b/Source/Mods/ExosuitFramework.cs index af77436f..168cf098 100644 --- a/Source/Mods/ExosuitFramework.cs +++ b/Source/Mods/ExosuitFramework.cs @@ -37,25 +37,31 @@ public ExosuitFramework(ModContentPack mod) } #endregion + } + private static void LatePatch() + { #region Gizmos { - // Assigned-bay get in / get off on the pawn gizmo bar. MpCompat.RegisterLambdaMethod("Exosuit.Patch_Pawn_GetGizmos", "Postfix", 0, 1); var maintenanceBayType = AccessTools.TypeByName("Exosuit.Building_MaintenanceBay"); - // Assigned get in (0), target get in (3), auto repair toggle (4). - MpCompat.RegisterLambdaMethod(maintenanceBayType, nameof(Building.GetGizmos), 0, 3, 4); - - // Ejector bay: throw (0), launch to map (1), release (2). - var ejectorLambdas = MpCompat.RegisterLambdaMethod("Exosuit.Building_EjectorBay", nameof(Building.GetGizmos), 0, 1, 2); - ejectorLambdas[0].SetContext(SyncContext.CurrentMap); - ejectorLambdas[1].SetContext(SyncContext.CurrentMap); - PatchingUtilities.ReplaceCurrentMapUsage("Exosuit.Building_EjectorBay:GetGizmos"); + if (maintenanceBayType != null) + MpCompat.RegisterLambdaMethod(maintenanceBayType, nameof(Building.GetGizmos), 0, 3, 4); + + var ejectorType = AccessTools.TypeByName("Exosuit.Building_EjectorBay"); + if (ejectorType != null) + { + var ejectorLambdas = MpCompat.RegisterLambdaMethod(ejectorType, nameof(Building.GetGizmos), 0, 1, 2); + ejectorLambdas[0].SetContext(SyncContext.CurrentMap); + ejectorLambdas[1].SetContext(SyncContext.CurrentMap); + PatchingUtilities.ReplaceCurrentMapUsage("Exosuit.Building_EjectorBay:GetGizmos"); + } var turretType = AccessTools.TypeByName("Mechsuit.CompTurretGun"); - MpCompat.RegisterLambdaMethod(turretType, "GetGizmos", 0, 1); + if (turretType != null) + MpCompat.RegisterLambdaMethod(turretType, "GetGizmos", 0, 1); } #endregion @@ -64,25 +70,28 @@ public ExosuitFramework(ModContentPack mod) { var maintenanceBayType = AccessTools.TypeByName("Exosuit.Building_MaintenanceBay"); - MpCompat.RegisterLambdaDelegate(maintenanceBayType, nameof(Building.GetFloatMenuOptions), 0); - MpCompat.RegisterLambdaDelegate("Exosuit.Building_EjectorBay", nameof(Building.GetFloatMenuOptions), 0); + if (maintenanceBayType != null) + { + MpCompat.RegisterLambdaDelegate(maintenanceBayType, nameof(Building.GetFloatMenuOptions), 0); + + MP.RegisterSyncMethod(maintenanceBayType, "RequestInstallModule"); + MP.RegisterSyncMethod(maintenanceBayType, "RequestRemoveModule"); + MP.RegisterSyncMethod(maintenanceBayType, "CancelPendingWork"); + MP.RegisterSyncMethod(maintenanceBayType, "CancelPendingInstall"); + MP.RegisterSyncMethod(maintenanceBayType, "CancelAllCoreWork"); + MP.RegisterSyncMethod(maintenanceBayType, "AddOrReplaceModule"); + MP.RegisterSyncMethod(maintenanceBayType, "RemoveModules"); + } + + var ejectorType = AccessTools.TypeByName("Exosuit.Building_EjectorBay"); + if (ejectorType != null) + MpCompat.RegisterLambdaDelegate(ejectorType, nameof(Building.GetFloatMenuOptions), 0); RegisterFloatMenuProviderLambdas(); - - MP.RegisterSyncMethod(maintenanceBayType, "RequestInstallModule"); - MP.RegisterSyncMethod(maintenanceBayType, "RequestRemoveModule"); - MP.RegisterSyncMethod(maintenanceBayType, "CancelPendingWork"); - MP.RegisterSyncMethod(maintenanceBayType, "CancelPendingInstall"); - MP.RegisterSyncMethod(maintenanceBayType, "CancelAllCoreWork"); - MP.RegisterSyncMethod(maintenanceBayType, "AddOrReplaceModule"); - MP.RegisterSyncMethod(maintenanceBayType, "RemoveModules"); } #endregion - } - private static void LatePatch() - { #region Jobs { @@ -110,8 +119,11 @@ private static void LatePatch() { var turretType = AccessTools.TypeByName("Mechsuit.CompTurretGun"); - MP.RegisterSyncMethod(turretType, "OrderAttack"); - MP.RegisterSyncMethod(turretType, "ClearForcedTarget"); + if (turretType != null) + { + MP.RegisterSyncMethod(turretType, "OrderAttack"); + MP.RegisterSyncMethod(turretType, "ClearForcedTarget"); + } } #endregion From 766b2b557c387586a3c74a53117edd3b7402e0ef Mon Sep 17 00:00:00 2001 From: retr0 <45705896+1Anton10@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:57:01 +0500 Subject: [PATCH 3/9] Harden Exosuit compat startup against missing sync targets Use safe lambda/sync registration in LatePatch, drop redundant ITab sync methods already covered by Exosuit MP.RegisterAll(), and log successful init without aborting the whole compat class. Co-authored-by: Cursor --- Source/Mods/ExosuitFramework.cs | 214 ++++++++++++++++++++------------ 1 file changed, 134 insertions(+), 80 deletions(-) diff --git a/Source/Mods/ExosuitFramework.cs b/Source/Mods/ExosuitFramework.cs index 168cf098..fd7eda91 100644 --- a/Source/Mods/ExosuitFramework.cs +++ b/Source/Mods/ExosuitFramework.cs @@ -41,110 +41,157 @@ public ExosuitFramework(ModContentPack mod) private static void LatePatch() { - #region Gizmos - + try + { + PatchGizmos(); + PatchFloatMenus(); + PatchJobs(); + PatchTurrets(); + PatchCatapult(); + Log.Message("MPCompat :: Initialized compatibility for aoba.exosuit.framework"); + } + catch (Exception ex) { - MpCompat.RegisterLambdaMethod("Exosuit.Patch_Pawn_GetGizmos", "Postfix", 0, 1); + Log.Warning($"MPCompat :: Failed to finish Exosuit Framework compat setup: {ex}"); + } + } + + private static void PatchGizmos() + { + TryRegisterLambdaMethod("Exosuit.Patch_Pawn_GetGizmos", "Postfix", 0, 1); - var maintenanceBayType = AccessTools.TypeByName("Exosuit.Building_MaintenanceBay"); - if (maintenanceBayType != null) - MpCompat.RegisterLambdaMethod(maintenanceBayType, nameof(Building.GetGizmos), 0, 3, 4); + var maintenanceBayType = AccessTools.TypeByName("Exosuit.Building_MaintenanceBay"); + if (maintenanceBayType != null) + TryRegisterLambdaMethod(maintenanceBayType, nameof(Building.GetGizmos), 0, 3, 4); - var ejectorType = AccessTools.TypeByName("Exosuit.Building_EjectorBay"); - if (ejectorType != null) + var ejectorType = AccessTools.TypeByName("Exosuit.Building_EjectorBay"); + if (ejectorType != null) + { + var ejectorLambdas = TryRegisterLambdaMethod(ejectorType, nameof(Building.GetGizmos), 0, 1, 2); + if (ejectorLambdas != null) { - var ejectorLambdas = MpCompat.RegisterLambdaMethod(ejectorType, nameof(Building.GetGizmos), 0, 1, 2); ejectorLambdas[0].SetContext(SyncContext.CurrentMap); ejectorLambdas[1].SetContext(SyncContext.CurrentMap); - PatchingUtilities.ReplaceCurrentMapUsage("Exosuit.Building_EjectorBay:GetGizmos"); } - var turretType = AccessTools.TypeByName("Mechsuit.CompTurretGun"); - if (turretType != null) - MpCompat.RegisterLambdaMethod(turretType, "GetGizmos", 0, 1); + PatchingUtilities.ReplaceCurrentMapUsage("Exosuit.Building_EjectorBay:GetGizmos"); } - #endregion + var turretType = AccessTools.TypeByName("Mechsuit.CompTurretGun"); + if (turretType != null) + TryRegisterLambdaMethod(turretType, "GetGizmos", 0, 1); + } - #region Float menus + private static void PatchFloatMenus() + { + var maintenanceBayType = AccessTools.TypeByName("Exosuit.Building_MaintenanceBay"); + if (maintenanceBayType != null) + TryRegisterLambdaDelegate(maintenanceBayType, nameof(Building.GetFloatMenuOptions), 0); - { - var maintenanceBayType = AccessTools.TypeByName("Exosuit.Building_MaintenanceBay"); - if (maintenanceBayType != null) - { - MpCompat.RegisterLambdaDelegate(maintenanceBayType, nameof(Building.GetFloatMenuOptions), 0); - - MP.RegisterSyncMethod(maintenanceBayType, "RequestInstallModule"); - MP.RegisterSyncMethod(maintenanceBayType, "RequestRemoveModule"); - MP.RegisterSyncMethod(maintenanceBayType, "CancelPendingWork"); - MP.RegisterSyncMethod(maintenanceBayType, "CancelPendingInstall"); - MP.RegisterSyncMethod(maintenanceBayType, "CancelAllCoreWork"); - MP.RegisterSyncMethod(maintenanceBayType, "AddOrReplaceModule"); - MP.RegisterSyncMethod(maintenanceBayType, "RemoveModules"); - } + var ejectorType = AccessTools.TypeByName("Exosuit.Building_EjectorBay"); + if (ejectorType != null) + TryRegisterLambdaDelegate(ejectorType, nameof(Building.GetFloatMenuOptions), 0); - var ejectorType = AccessTools.TypeByName("Exosuit.Building_EjectorBay"); - if (ejectorType != null) - MpCompat.RegisterLambdaDelegate(ejectorType, nameof(Building.GetFloatMenuOptions), 0); + RegisterFloatMenuProviderLambdas(); + } - RegisterFloatMenuProviderLambdas(); - } + private static void PatchJobs() + { + // Desync logs (MpDesyncs): Pawn_JobTraceker_Patch replaces LayDown with TryTakeOrderedJob, + // issuing unsynced job IDs. Rewrite the incoming job instead so MP keeps the same job id. + var startJob = AccessTools.Method(typeof(Pawn_JobTracker), nameof(Pawn_JobTracker.StartJob)); + MpCompat.harmony.Patch(startJob, prefix: new HarmonyMethod(typeof(ExosuitFramework), nameof(RewriteExosuitSleepJob))); + + var jobReplacer = AccessTools.Method("Exosuit.HarmonyPatches.Pawn_JobTraceker_Patch:JobReplacerPatch"); + if (jobReplacer != null) + MpCompat.harmony.Patch(jobReplacer, prefix: new HarmonyMethod(typeof(ExosuitFramework), nameof(SkipIfMultiplayer))); + + // JobGiver_GetRest/Work GetPriority call these every think tick and spawn local jobs. + var gearOn = AccessTools.Method("Exosuit.MechUtility:TryMakeJob_GearOn"); + var gearOff = AccessTools.Method("Exosuit.MechUtility:TryMakeJob_GearOff"); + if (gearOn != null) + MpCompat.harmony.Patch(gearOn, prefix: new HarmonyMethod(typeof(ExosuitFramework), nameof(SkipIfMultiplayer))); + if (gearOff != null) + MpCompat.harmony.Patch(gearOff, prefix: new HarmonyMethod(typeof(ExosuitFramework), nameof(SkipIfMultiplayer))); + } - #endregion + private static void PatchTurrets() + { + var turretType = AccessTools.TypeByName("Mechsuit.CompTurretGun"); + if (turretType == null) + return; - #region Jobs + // ITab/gizmo actions are already covered by Exosuit's MP.RegisterAll(); only sync explicit API entry points. + TryRegisterSyncMethod(turretType, "OrderAttack", typeof(LocalTargetInfo)); + TryRegisterSyncMethod(turretType, "ClearForcedTarget"); + } + + private static void PatchCatapult() + { + var flyerType = AccessTools.TypeByName("Exosuit.WG_PawnFlyer"); + if (flyerType != null) + TryRegisterLambdaDelegate(flyerType, "GetOptionsForTile", 0, 1); + + var jumpType = AccessTools.TypeByName("Exosuit.WG_AbilityVerb_QuickJump"); + var doJump = AccessTools.Method(jumpType, "DoJump", + [ + typeof(Pawn), typeof(Map), typeof(LocalTargetInfo), typeof(LocalTargetInfo), typeof(bool), typeof(bool) + ]); + if (doJump != null) + MP.RegisterSyncMethod(doJump); + } + private static ISyncMethod[] TryRegisterLambdaMethod(Type parentType, string parentMethod, params int[] lambdaOrdinals) + { + try { - // Desync logs (MpDesyncs): Pawn_JobTraceker_Patch replaces LayDown with TryTakeOrderedJob, - // issuing unsynced job IDs. Rewrite the incoming job instead so MP keeps the same job id. - var startJob = AccessTools.Method(typeof(Pawn_JobTracker), nameof(Pawn_JobTracker.StartJob)); - MpCompat.harmony.Patch(startJob, prefix: new HarmonyMethod(typeof(ExosuitFramework), nameof(RewriteExosuitSleepJob))); - - var jobReplacer = AccessTools.Method("Exosuit.HarmonyPatches.Pawn_JobTraceker_Patch:JobReplacerPatch"); - if (jobReplacer != null) - MpCompat.harmony.Patch(jobReplacer, prefix: new HarmonyMethod(typeof(ExosuitFramework), nameof(SkipIfMultiplayer))); - - // JobGiver_GetRest/Work GetPriority call these every think tick and spawn local jobs. - var gearOn = AccessTools.Method("Exosuit.MechUtility:TryMakeJob_GearOn"); - var gearOff = AccessTools.Method("Exosuit.MechUtility:TryMakeJob_GearOff"); - if (gearOn != null) - MpCompat.harmony.Patch(gearOn, prefix: new HarmonyMethod(typeof(ExosuitFramework), nameof(SkipIfMultiplayer))); - if (gearOff != null) - MpCompat.harmony.Patch(gearOff, prefix: new HarmonyMethod(typeof(ExosuitFramework), nameof(SkipIfMultiplayer))); + return MpCompat.RegisterLambdaMethod(parentType, parentMethod, lambdaOrdinals); } + catch (Exception ex) + { + Log.Warning($"MPCompat :: Exosuit skipped lambda sync for {parentType?.FullName}.{parentMethod}: {ex.Message}"); + return null; + } + } - #endregion - - #region Turrets - + private static ISyncMethod[] TryRegisterLambdaMethod(string parentType, string parentMethod, params int[] lambdaOrdinals) + { + try { - var turretType = AccessTools.TypeByName("Mechsuit.CompTurretGun"); - if (turretType != null) - { - MP.RegisterSyncMethod(turretType, "OrderAttack"); - MP.RegisterSyncMethod(turretType, "ClearForcedTarget"); - } + return MpCompat.RegisterLambdaMethod(parentType, parentMethod, lambdaOrdinals); + } + catch (Exception ex) + { + Log.Warning($"MPCompat :: Exosuit skipped lambda sync for {parentType}.{parentMethod}: {ex.Message}"); + return null; } + } - #endregion + private static void TryRegisterLambdaDelegate(Type parentType, string parentMethod, params int[] lambdaOrdinals) + { + try + { + MpCompat.RegisterLambdaDelegate(parentType, parentMethod, lambdaOrdinals); + } + catch (Exception ex) + { + Log.Warning($"MPCompat :: Exosuit skipped lambda delegate for {parentType?.FullName}.{parentMethod}: {ex.Message}"); + } + } - #region Catapult + private static void TryRegisterSyncMethod(Type parentType, string methodName, params Type[] args) + { + var method = args.Length == 0 + ? AccessTools.Method(parentType, methodName) + : AccessTools.Method(parentType, methodName, args); + if (method == null) { - var flyerType = AccessTools.TypeByName("Exosuit.WG_PawnFlyer"); - if (flyerType != null) - MpCompat.RegisterLambdaDelegate(flyerType, "GetOptionsForTile", 0, 1); - - var jumpType = AccessTools.TypeByName("Exosuit.WG_AbilityVerb_QuickJump"); - var doJump = AccessTools.Method(jumpType, "DoJump", - [ - typeof(Pawn), typeof(Map), typeof(LocalTargetInfo), typeof(LocalTargetInfo), typeof(bool), typeof(bool) - ]); - if (doJump != null) - MP.RegisterSyncMethod(doJump); + Log.Warning($"MPCompat :: Exosuit skipped missing sync method {parentType?.FullName}.{methodName}"); + return; } - #endregion + MP.RegisterSyncMethod(method); } private static void RewriteExosuitSleepJob(ref Job newJob, Pawn ___pawn) @@ -194,11 +241,18 @@ private static void RegisterFloatMenuProviderLambdas() if (pawnMethod == null) return; - foreach (var ord in new[] { 0, 1 }) + try + { + foreach (var ord in new[] { 0, 1 }) + { + var lambda = MpMethodUtil.GetLambda(providerType, pawnMethod.Name, MethodType.Normal, + [typeof(Pawn), floatMenuContextType], ord); + MP.RegisterSyncDelegate(providerType, lambda.DeclaringType!.Name, lambda.Name); + } + } + catch (Exception ex) { - var lambda = MpMethodUtil.GetLambda(providerType, pawnMethod.Name, MethodType.Normal, - [typeof(Pawn), floatMenuContextType], ord); - MP.RegisterSyncDelegate(providerType, lambda.DeclaringType!.Name, lambda.Name); + Log.Warning($"MPCompat :: Exosuit skipped float menu provider sync: {ex.Message}"); } } } From 8c7598f5e8b79979cd7917c53ccd9ebd902a12a5 Mon Sep 17 00:00:00 2001 From: retr0 <45705896+1Anton10@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:18:21 +0500 Subject: [PATCH 4/9] Fix Exosuit compat thread safety and gizmo patching Defer RNG patches to LatePatch so Exosuit_Core static init does not create textures on a background thread. Drop obsolete MaintenanceBay lambda sync (covered by Exosuit MP.RegisterAll), and patch EjectorBay Find.CurrentMap on compiler-generated lambdas instead of GetGizmos. Co-authored-by: Cursor --- Source/Mods/ExosuitFramework.cs | 94 +++++++++++++++++++-------------- 1 file changed, 54 insertions(+), 40 deletions(-) diff --git a/Source/Mods/ExosuitFramework.cs b/Source/Mods/ExosuitFramework.cs index fd7eda91..17ac078f 100644 --- a/Source/Mods/ExosuitFramework.cs +++ b/Source/Mods/ExosuitFramework.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using HarmonyLib; using Multiplayer.API; using RimWorld; @@ -19,30 +20,13 @@ public class ExosuitFramework public ExosuitFramework(ModContentPack mod) { LongEventHandler.ExecuteWhenFinished(LatePatch); - - #region RNG - - { - // Combat, disassembly, turret spread, and repair timing all consume Rand and can desync. - PatchingUtilities.PatchPushPopRand([ - "Exosuit.Exosuit_Core:CheckPreAbsorbDamage", - "Exosuit.Exosuit_Core:GetPostArmorDamage", - "Exosuit.Exosuit_Core:ApplyDamageToModules", - "Exosuit.Exosuit_Core:ExosuitDestory", - "Exosuit.MechUtility:DissambleFrom", - "Exosuit.Verb_MeleeSweep:DoSweep", - "Exosuit.Building_AutoRepairArm:Tick", - "Mechsuit.AsyncShootVerb:TryCastShot", - ]); - } - - #endregion } private static void LatePatch() { try { + PatchRand(); PatchGizmos(); PatchFloatMenus(); PatchJobs(); @@ -56,13 +40,26 @@ private static void LatePatch() } } + private static void PatchRand() + { + // Must run on the main thread: patching Exosuit_Core triggers its static ctor, which creates textures. + PatchingUtilities.PatchPushPopRand([ + "Exosuit.Exosuit_Core:CheckPreAbsorbDamage", + "Exosuit.Exosuit_Core:GetPostArmorDamage", + "Exosuit.Exosuit_Core:ApplyDamageToModules", + "Exosuit.Exosuit_Core:ExosuitDestory", + "Exosuit.MechUtility:DissambleFrom", + "Exosuit.Verb_MeleeSweep:DoSweep", + "Exosuit.Building_AutoRepairArm:Tick", + "Mechsuit.AsyncShootVerb:TryCastShot", + ]); + } + private static void PatchGizmos() { TryRegisterLambdaMethod("Exosuit.Patch_Pawn_GetGizmos", "Postfix", 0, 1); - var maintenanceBayType = AccessTools.TypeByName("Exosuit.Building_MaintenanceBay"); - if (maintenanceBayType != null) - TryRegisterLambdaMethod(maintenanceBayType, nameof(Building.GetGizmos), 0, 3, 4); + // Maintenance bay gizmos use Exosuit's own [SyncMethod] locals (MP.RegisterAll); no lambda ordinals here. var ejectorType = AccessTools.TypeByName("Exosuit.Building_EjectorBay"); if (ejectorType != null) @@ -70,11 +67,14 @@ private static void PatchGizmos() var ejectorLambdas = TryRegisterLambdaMethod(ejectorType, nameof(Building.GetGizmos), 0, 1, 2); if (ejectorLambdas != null) { - ejectorLambdas[0].SetContext(SyncContext.CurrentMap); - ejectorLambdas[1].SetContext(SyncContext.CurrentMap); + if (ejectorLambdas.Length > 0) + ejectorLambdas[0].SetContext(SyncContext.CurrentMap); + if (ejectorLambdas.Length > 1) + ejectorLambdas[1].SetContext(SyncContext.CurrentMap); } - PatchingUtilities.ReplaceCurrentMapUsage("Exosuit.Building_EjectorBay:GetGizmos"); + // Find.CurrentMap lives inside compiler-generated closures, not in GetGizmos itself. + PatchEjectorBayLambdasCurrentMap(ejectorType); } var turretType = AccessTools.TypeByName("Mechsuit.CompTurretGun"); @@ -82,6 +82,22 @@ private static void PatchGizmos() TryRegisterLambdaMethod(turretType, "GetGizmos", 0, 1); } + private static void PatchEjectorBayLambdasCurrentMap(Type ejectorType) + { + for (var ord = 0; ord <= 1; ord++) + { + try + { + var lambda = MpMethodUtil.GetLambda(ejectorType, nameof(Building.GetGizmos), MethodType.Normal, null, ord); + PatchingUtilities.ReplaceCurrentMapUsage(lambda, logIfNothingPatched: false, logIfMissingMethod: false); + } + catch (Exception ex) + { + Log.Warning($"MPCompat :: Exosuit skipped ejector bay map patch for lambda {ord}: {ex.Message}"); + } + } + } + private static void PatchFloatMenus() { var maintenanceBayType = AccessTools.TypeByName("Exosuit.Building_MaintenanceBay"); @@ -143,28 +159,26 @@ private static void PatchCatapult() private static ISyncMethod[] TryRegisterLambdaMethod(Type parentType, string parentMethod, params int[] lambdaOrdinals) { - try + var registered = new List(); + foreach (var ord in lambdaOrdinals) { - return MpCompat.RegisterLambdaMethod(parentType, parentMethod, lambdaOrdinals); - } - catch (Exception ex) - { - Log.Warning($"MPCompat :: Exosuit skipped lambda sync for {parentType?.FullName}.{parentMethod}: {ex.Message}"); - return null; + try + { + registered.AddRange(MpCompat.RegisterLambdaMethod(parentType, parentMethod, ord)); + } + catch (Exception ex) + { + Log.Warning($"MPCompat :: Exosuit skipped lambda sync for {parentType?.FullName}.{parentMethod} ordinal {ord}: {ex.Message}"); + } } + + return registered.Count > 0 ? registered.ToArray() : null; } private static ISyncMethod[] TryRegisterLambdaMethod(string parentType, string parentMethod, params int[] lambdaOrdinals) { - try - { - return MpCompat.RegisterLambdaMethod(parentType, parentMethod, lambdaOrdinals); - } - catch (Exception ex) - { - Log.Warning($"MPCompat :: Exosuit skipped lambda sync for {parentType}.{parentMethod}: {ex.Message}"); - return null; - } + var type = AccessTools.TypeByName(parentType); + return type == null ? null : TryRegisterLambdaMethod(type, parentMethod, lambdaOrdinals); } private static void TryRegisterLambdaDelegate(Type parentType, string parentMethod, params int[] lambdaOrdinals) From 4b2ecbc56dbdddf569c411fe40529e03c23a1510 Mon Sep 17 00:00:00 2001 From: retr0 <45705896+1Anton10@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:09:26 +0500 Subject: [PATCH 5/9] Add CE/RunAndGun/MWR MP compat; fix Exosuit gear jobs via StartJob Register roolo.RunAndGun.kotobike and sync CompRunAndGun.isEnabled. Add MoveWhileReloading compat for himawari fork (kotobike burst patch, MP-safe PawnGoto outside reload). Redirect Exosuit gear on/off to synced StartJob instead of blocking TryMakeJob in multiplayer. Co-authored-by: Cursor --- Source/Mods/ExosuitFramework.cs | 74 +++++++++++++++++++-- Source/Mods/MoveWhileReloading.cs | 106 ++++++++++++++++++++++++++++++ Source/Mods/RunandGun.cs | 33 +++++++++- 3 files changed, 208 insertions(+), 5 deletions(-) create mode 100644 Source/Mods/MoveWhileReloading.cs diff --git a/Source/Mods/ExosuitFramework.cs b/Source/Mods/ExosuitFramework.cs index 17ac078f..fc6bb785 100644 --- a/Source/Mods/ExosuitFramework.cs +++ b/Source/Mods/ExosuitFramework.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Reflection; using HarmonyLib; using Multiplayer.API; using RimWorld; @@ -15,7 +16,11 @@ namespace Multiplayer.Compat; public class ExosuitFramework { private static JobDef wgSleepJobDef; + private static JobDef wgGetInJobDef; + private static JobDef wgGetOffJobDef; private static Type exosuitCoreType; + private static MethodInfo getClosestCoreForPawn; + private static MethodInfo getClosestBay; public ExosuitFramework(ModContentPack mod) { @@ -122,13 +127,14 @@ private static void PatchJobs() if (jobReplacer != null) MpCompat.harmony.Patch(jobReplacer, prefix: new HarmonyMethod(typeof(ExosuitFramework), nameof(SkipIfMultiplayer))); - // JobGiver_GetRest/Work GetPriority call these every think tick and spawn local jobs. + // JobGiver_GetRest/Work call these every think tick via TryTakeOrderedJob (unsynced job ids in MP). + // Redirect to StartJob so gear on/off keeps working without desyncing. var gearOn = AccessTools.Method("Exosuit.MechUtility:TryMakeJob_GearOn"); var gearOff = AccessTools.Method("Exosuit.MechUtility:TryMakeJob_GearOff"); if (gearOn != null) - MpCompat.harmony.Patch(gearOn, prefix: new HarmonyMethod(typeof(ExosuitFramework), nameof(SkipIfMultiplayer))); + MpCompat.harmony.Patch(gearOn, prefix: new HarmonyMethod(typeof(ExosuitFramework), nameof(RedirectExosuitGearOn))); if (gearOff != null) - MpCompat.harmony.Patch(gearOff, prefix: new HarmonyMethod(typeof(ExosuitFramework), nameof(SkipIfMultiplayer))); + MpCompat.harmony.Patch(gearOff, prefix: new HarmonyMethod(typeof(ExosuitFramework), nameof(RedirectExosuitGearOff))); } private static void PatchTurrets() @@ -242,7 +248,67 @@ private static bool WearingExosuitCore(Pawn pawn) } /// Blocks exosuit patches that create jobs outside of MP's synced StartJob path. - private static bool SkipIfMultiplayer() => !MP.IsInMultiplayer; + private static bool SkipIfMultiplayer() => MP.IsInMultiplayer; + + private static bool RedirectExosuitGearOn(Pawn pawn) + { + if (!MP.IsInMultiplayer) + return true; + + EnsureGearJobHelpers(); + var closest = getClosestCoreForPawn?.Invoke(null, [pawn]) as Thing; + if (closest == null) + return false; + + TryStartExosuitGearJob(pawn, wgGetInJobDef, closest); + return false; + } + + private static bool RedirectExosuitGearOff(Pawn pawn) + { + if (!MP.IsInMultiplayer) + return true; + + EnsureGearJobHelpers(); + var closest = getClosestBay?.Invoke(null, [pawn, true]) as Thing; + if (closest == null) + return false; + + TryStartExosuitGearJob(pawn, wgGetOffJobDef, closest); + return false; + } + + private static void EnsureGearJobHelpers() + { + wgGetInJobDef ??= DefDatabase.GetNamedSilentFail("WG_GetInWalkerCore_NonDrafted"); + wgGetOffJobDef ??= DefDatabase.GetNamedSilentFail("WG_GetOffWalkerCore"); + + var mechUtility = AccessTools.TypeByName("Exosuit.MechUtility"); + if (mechUtility == null) + return; + + getClosestCoreForPawn ??= AccessTools.Method(mechUtility, "GetClosestCoreForPawn"); + getClosestBay ??= AccessTools.Method(mechUtility, "GetClosestBay", [typeof(Pawn), typeof(bool)]); + } + + private static void TryStartExosuitGearJob(Pawn pawn, JobDef jobDef, Thing target) + { + if (jobDef == null || pawn?.jobs == null || target == null) + return; + + var curJob = pawn.jobs.curJob; + if (curJob?.def == jobDef && curJob.targetA.Thing == target) + return; + + foreach (var queued in pawn.jobs.jobQueue) + { + if (queued.job.def == jobDef && queued.job.targetA.Thing == target) + return; + } + + var job = JobMaker.MakeJob(jobDef, target); + pawn.jobs.StartJob(job, JobCondition.InterruptOptional, null, resumeCurJobAfterwards: false); + } private static void RegisterFloatMenuProviderLambdas() { diff --git a/Source/Mods/MoveWhileReloading.cs b/Source/Mods/MoveWhileReloading.cs new file mode 100644 index 00000000..5cf148eb --- /dev/null +++ b/Source/Mods/MoveWhileReloading.cs @@ -0,0 +1,106 @@ +using System.Linq; +using HarmonyLib; +using Multiplayer.API; +using RimWorld; +using Verse; +using Verse.AI; + +namespace Multiplayer.Compat; + +/// CE - Move while reloading (Continue) by himawari +/// +[MpCompatFor("himawari.moveWhileReloading")] +public class MoveWhileReloadingCompat +{ + private static JobDef reloadWeaponJobDef; + + public MoveWhileReloadingCompat(ModContentPack mod) + { + LongEventHandler.ExecuteWhenFinished(LatePatch); + } + + private static void LatePatch() + { + try + { + reloadWeaponJobDef = GetCeReloadJobDef(); + EnsureRunAndGunBurstShotPatch(); + PatchPawnGotoForMultiplayer(); + Log.Message("MPCompat :: Initialized compatibility for himawari.moveWhileReloading"); + } + catch (System.Exception ex) + { + Log.Warning($"MPCompat :: Failed to finish Move While Reloading compat setup: {ex}"); + } + } + + /// + /// CEMoveReload only checks roolo.RunAndGun at static init; kotobike/memegoddess forks never get TryCastNextBurstShot patched. + /// + private static void EnsureRunAndGunBurstShotPatch() + { + if (!IsAnyRunAndGunActive()) + return; + + var verbMethod = AccessTools.Method(typeof(Verb), nameof(Verb.TryCastNextBurstShot)); + var patchesType = AccessTools.TypeByName("CEMoveReload.HarmonyPatches"); + var prefix = patchesType != null + ? AccessTools.Method(patchesType, "Prefix_TryCastNextBurstShot") + : null; + if (verbMethod == null || prefix == null) + return; + + var patchInfo = Harmony.GetPatchInfo(verbMethod); + if (patchInfo?.Prefixes?.Any(p => p.PatchMethod == prefix) == true) + return; + + MpCompat.harmony.Patch(verbMethod, prefix: new HarmonyMethod(prefix)); + } + + private static void PatchPawnGotoForMultiplayer() + { + var patchesType = AccessTools.TypeByName("CEMoveReload.HarmonyPatches"); + var ceGotoPrefix = patchesType != null + ? AccessTools.Method(patchesType, "Prefix_PawnGotoAction") + : null; + if (ceGotoPrefix == null) + return; + + MpCompat.harmony.Patch(ceGotoPrefix, + prefix: new HarmonyMethod(typeof(MoveWhileReloadingCompat), nameof(SkipCeGotoWhenNotReloading))); + } + + /// + /// Outside reload, CEMoveReload replaces FloatMenuMakerMap.PawnGotoAction with unsynced TryTakeOrderedJob calls. + /// Skip its prefix in MP so vanilla/MP job sync handles movement; keep reload-specific behavior intact. + /// + private static bool SkipCeGotoWhenNotReloading(Pawn pawn) + { + if (!MP.IsInMultiplayer) + return true; + + reloadWeaponJobDef ??= GetCeReloadJobDef(); + if (reloadWeaponJobDef != null && pawn.CurJobDef == reloadWeaponJobDef) + return true; + + return false; + } + + private static JobDef GetCeReloadJobDef() + { + var ceJobDefType = AccessTools.TypeByName("CombatExtended.CE_JobDefOf"); + if (ceJobDefType != null) + { + var field = AccessTools.Field(ceJobDefType, "ReloadWeapon"); + if (field != null) + return field.GetValue(null) as JobDef; + } + + return DefDatabase.GetNamedSilentFail("ReloadWeapon"); + } + + private static bool IsAnyRunAndGunActive() => + ModsConfig.IsActive("roolo.RunAndGun") + || ModsConfig.IsActive("roolo.RunAndGun.kotobike") + || ModsConfig.IsActive("memegoddess.RunAndGun"); +} diff --git a/Source/Mods/RunandGun.cs b/Source/Mods/RunandGun.cs index da3eff1b..260f504f 100644 --- a/Source/Mods/RunandGun.cs +++ b/Source/Mods/RunandGun.cs @@ -1,7 +1,7 @@ using System; -using Verse; using HarmonyLib; using Multiplayer.API; +using Verse; namespace Multiplayer.Compat { @@ -9,14 +9,45 @@ namespace Multiplayer.Compat /// /// /// + /// [MpCompatFor("roolo.RunAndGun")] + [MpCompatFor("roolo.RunAndGun.kotobike")] [MpCompatFor("memegoddess.RunAndGun")] class RunandGun { + [MpCompatSyncField("RunAndGun.CompRunAndGun", "isEnabled")] + protected static ISyncField runAndGunEnabledField; + public RunandGun(ModContentPack mod) { + MpCompatPatchLoader.LoadPatch(this); + MpCompat.RegisterLambdaDelegate("RunAndGun.Harmony.Pawn_DraftController_GetGizmos_Patch", "Postfix", 2); PatchingUtilities.PatchUnityRand("RunAndGun.Harmony.MentalStateHandler_TryStartMentalState:shouldRunAndGun", false); } + + [MpCompatPrefix("RunAndGun.Harmony.Pawn_DraftController_GetGizmos_Patch", "Postfix")] + private static void WatchRunAndGunEnabled(Pawn __instance) + { + if (!MP.IsInMultiplayer) + return; + + foreach (var comp in __instance.AllComps) + { + if (comp.GetType().Name != "CompRunAndGun") + continue; + + MP.WatchBegin(); + runAndGunEnabledField.Watch(comp); + break; + } + } + + [MpCompatPostfix("RunAndGun.Harmony.Pawn_DraftController_GetGizmos_Patch", "Postfix")] + private static void EndWatchRunAndGunEnabled() + { + if (MP.IsInMultiplayer) + MP.WatchEnd(); + } } } From 20afe07d9051dca13c81d857a7072e9d18bfc7e5 Mon Sep 17 00:00:00 2001 From: retr0 <45705896+1Anton10@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:21:32 +0500 Subject: [PATCH 6/9] Fix Exosuit lambda sync, RunAndGun toggle, and MP reconnect after restart Register iterator delegates via Sync.RegisterSyncDelegate(MethodInfo) instead of broken nested-type lookup. Harden Exosuit pawn gizmos, module damage, and gear job routing; sync RunAndGun isEnabled for kotobike; patch System.Random in flee logic; fix case-insensitive mod loader matching for Move While Reloading. Co-authored-by: Cursor --- Source/Mods/ExosuitFramework.cs | 152 +++++++++++++-- Source/Mods/MoveWhileReloading.cs | 15 +- Source/Mods/RunandGun.cs | 215 ++++++++++++++++++-- Source/MpCompat.cs | 35 +++- Source/MpCompatLoader.cs | 4 +- Source/MpMethodUtil.cs | 19 ++ Source/MultiplayerReconnectFix.cs | 314 ++++++++++++++++++++++++++++++ 7 files changed, 711 insertions(+), 43 deletions(-) create mode 100644 Source/MultiplayerReconnectFix.cs diff --git a/Source/Mods/ExosuitFramework.cs b/Source/Mods/ExosuitFramework.cs index fc6bb785..3aca1c5c 100644 --- a/Source/Mods/ExosuitFramework.cs +++ b/Source/Mods/ExosuitFramework.cs @@ -17,6 +17,7 @@ public class ExosuitFramework { private static JobDef wgSleepJobDef; private static JobDef wgGetInJobDef; + private static JobDef wgGetInNonDraftedJobDef; private static JobDef wgGetOffJobDef; private static Type exosuitCoreType; private static MethodInfo getClosestCoreForPawn; @@ -37,6 +38,7 @@ private static void LatePatch() PatchJobs(); PatchTurrets(); PatchCatapult(); + PatchExosuitDamage(); Log.Message("MPCompat :: Initialized compatibility for aoba.exosuit.framework"); } catch (Exception ex) @@ -62,14 +64,20 @@ private static void PatchRand() private static void PatchGizmos() { - TryRegisterLambdaMethod("Exosuit.Patch_Pawn_GetGizmos", "Postfix", 0, 1); + PatchExosuitPawnGetGizmosSafety(); - // Maintenance bay gizmos use Exosuit's own [SyncMethod] locals (MP.RegisterAll); no lambda ordinals here. + // Command_Action.action delegates — RegisterSyncDelegate so clients see and can use pawn get-in/out gizmos. + TryRegisterLambdaDelegate("Exosuit.Patch_Pawn_GetGizmos", "Postfix", 0, 1); + + // Module weapon float-menu gizmo on worn exosuit pieces. + TryRegisterLambdaDelegate("Exosuit.CompModuleWeapon", nameof(ThingComp.CompGetWornGizmosExtra), 0, 1, 2); + + // Maintenance bay building gizmos use Exosuit's own [SyncMethod] locals (MP.RegisterAll). var ejectorType = AccessTools.TypeByName("Exosuit.Building_EjectorBay"); if (ejectorType != null) { - var ejectorLambdas = TryRegisterLambdaMethod(ejectorType, nameof(Building.GetGizmos), 0, 1, 2); + var ejectorLambdas = TryRegisterLambdaDelegate(ejectorType, nameof(Building.GetGizmos), 0, 1, 2); if (ejectorLambdas != null) { if (ejectorLambdas.Length > 0) @@ -84,7 +92,43 @@ private static void PatchGizmos() var turretType = AccessTools.TypeByName("Mechsuit.CompTurretGun"); if (turretType != null) - TryRegisterLambdaMethod(turretType, "GetGizmos", 0, 1); + TryRegisterLambdaDelegate(turretType, "GetGizmos", 0, 1, 2, 3); + } + + /// + /// Exosuit postfix calls __result.ToList() without a null check — throws and wipes every pawn gizmo in MP. + /// Cannot prefix Exosuit's void Postfix (Harmony: "Cannot get result from void method"); guard on Pawn.GetGizmos instead. + /// + private static void PatchExosuitPawnGetGizmosSafety() + { + var getGizmos = AccessTools.Method(typeof(Pawn), nameof(Pawn.GetGizmos)); + if (getGizmos == null) + return; + + var guardPostfix = new HarmonyMethod(typeof(ExosuitFramework), nameof(GuardPawnGizmoResultBeforeExosuit)) + { + before = ["Exosuit.Patch_Pawn_GetGizmos"], + }; + MpCompat.harmony.Patch(getGizmos, + postfix: guardPostfix, + finalizer: new HarmonyMethod(typeof(ExosuitFramework), nameof(RecoverPawnGetGizmos))); + } + + private static void GuardPawnGizmoResultBeforeExosuit(ref IEnumerable __result) + { + if (__result == null) + __result = []; + } + + private static Exception RecoverPawnGetGizmos(Exception __exception, ref IEnumerable __result) + { + if (__exception == null) + return null; + + Log.Warning($"MPCompat :: Exosuit pawn GetGizmos failed — other gizmos kept: {__exception.Message}"); + if (__result == null) + __result = []; + return null; } private static void PatchEjectorBayLambdasCurrentMap(Type ejectorType) @@ -135,6 +179,48 @@ private static void PatchJobs() MpCompat.harmony.Patch(gearOn, prefix: new HarmonyMethod(typeof(ExosuitFramework), nameof(RedirectExosuitGearOn))); if (gearOff != null) MpCompat.harmony.Patch(gearOff, prefix: new HarmonyMethod(typeof(ExosuitFramework), nameof(RedirectExosuitGearOff))); + + // Pawn get-in/out gizmos call TryTakeOrderedJob (unsynced job ids on clients). + var tryTake = AccessTools.Method(typeof(Pawn_JobTracker), nameof(Pawn_JobTracker.TryTakeOrderedJob), + [typeof(Job), typeof(JobTag), typeof(bool)]); + if (tryTake != null) + MpCompat.harmony.Patch(tryTake, prefix: new HarmonyMethod(typeof(ExosuitFramework), nameof(RedirectExosuitTryTakeOrderedJob))); + } + + private static void PatchExosuitDamage() + { + var coreType = AccessTools.TypeByName("Exosuit.Exosuit_Core"); + if (coreType == null) + return; + + var healthField = AccessTools.Field(coreType, "healthInt"); + if (healthField != null) + MP.RegisterSyncField(healthField); + + var onHealthChanged = AccessTools.Method(coreType, "OnHealthChanged"); + if (onHealthChanged != null) + { + MpCompat.harmony.Patch(onHealthChanged, + prefix: new HarmonyMethod(typeof(ExosuitFramework), nameof(ApplyExosuitModuleDamageImmediately))); + } + } + + /// Exosuit defers module damage via LongEventHandler — causes visible hit delay in MP. + private static bool ApplyExosuitModuleDamageImmediately(float amount, Apparel __instance) + { + if (!MP.IsInMultiplayer) + return true; + + var healthProp = __instance.GetType().GetProperty("Health"); + if (healthProp != null && Convert.ToSingle(healthProp.GetValue(__instance)) <= 0f) + return true; + + if (amount <= 0f) + return true; + + var apply = AccessTools.Method(__instance.GetType(), "ApplyDamageToModules"); + apply?.Invoke(__instance, [amount]); + return false; } private static void PatchTurrets() @@ -187,16 +273,29 @@ private static ISyncMethod[] TryRegisterLambdaMethod(string parentType, string p return type == null ? null : TryRegisterLambdaMethod(type, parentMethod, lambdaOrdinals); } - private static void TryRegisterLambdaDelegate(Type parentType, string parentMethod, params int[] lambdaOrdinals) + private static ISyncDelegate[] TryRegisterLambdaDelegate(Type parentType, string parentMethod, params int[] lambdaOrdinals) { - try - { - MpCompat.RegisterLambdaDelegate(parentType, parentMethod, lambdaOrdinals); - } - catch (Exception ex) + var registered = new List(); + foreach (var ord in lambdaOrdinals) { - Log.Warning($"MPCompat :: Exosuit skipped lambda delegate for {parentType?.FullName}.{parentMethod}: {ex.Message}"); + try + { + registered.AddRange(MpCompat.RegisterLambdaDelegate(parentType, parentMethod, ord)); + } + catch (Exception ex) + { + Log.Warning($"MPCompat :: Exosuit skipped lambda delegate for {parentType?.FullName}.{parentMethod} ordinal {ord}: {ex.Message}"); + } } + + return registered.Count > 0 ? registered.ToArray() : null; + } + + private static void TryRegisterLambdaDelegate(string parentType, string parentMethod, params int[] lambdaOrdinals) + { + var type = AccessTools.TypeByName(parentType); + if (type != null) + TryRegisterLambdaDelegate(type, parentMethod, lambdaOrdinals); } private static void TryRegisterSyncMethod(Type parentType, string methodName, params Type[] args) @@ -260,7 +359,7 @@ private static bool RedirectExosuitGearOn(Pawn pawn) if (closest == null) return false; - TryStartExosuitGearJob(pawn, wgGetInJobDef, closest); + TryStartExosuitGearJob(pawn, wgGetInNonDraftedJobDef, closest); return false; } @@ -278,10 +377,30 @@ private static bool RedirectExosuitGearOff(Pawn pawn) return false; } - private static void EnsureGearJobHelpers() + private static void EnsureExosuitGizmoJobDefs() { - wgGetInJobDef ??= DefDatabase.GetNamedSilentFail("WG_GetInWalkerCore_NonDrafted"); + wgGetInJobDef ??= DefDatabase.GetNamedSilentFail("WG_GetInWalkerCore"); + wgGetInNonDraftedJobDef ??= DefDatabase.GetNamedSilentFail("WG_GetInWalkerCore_NonDrafted"); wgGetOffJobDef ??= DefDatabase.GetNamedSilentFail("WG_GetOffWalkerCore"); + } + + private static bool RedirectExosuitTryTakeOrderedJob(Pawn ___pawn, Job job, ref bool __result) + { + if (!MP.IsInMultiplayer || job == null || ___pawn?.jobs == null) + return true; + + EnsureExosuitGizmoJobDefs(); + if (job.def != wgGetInJobDef && job.def != wgGetOffJobDef && job.def != wgGetInNonDraftedJobDef) + return true; + + ___pawn.jobs.StartJob(job, JobCondition.InterruptOptional); + __result = true; + return false; + } + + private static void EnsureGearJobHelpers() + { + EnsureExosuitGizmoJobDefs(); var mechUtility = AccessTools.TypeByName("Exosuit.MechUtility"); if (mechUtility == null) @@ -325,9 +444,8 @@ private static void RegisterFloatMenuProviderLambdas() { foreach (var ord in new[] { 0, 1 }) { - var lambda = MpMethodUtil.GetLambda(providerType, pawnMethod.Name, MethodType.Normal, - [typeof(Pawn), floatMenuContextType], ord); - MP.RegisterSyncDelegate(providerType, lambda.DeclaringType!.Name, lambda.Name); + MpCompat.RegisterLambdaDelegateInternal(providerType, pawnMethod.Name, MethodType.Normal, null, ord, + [typeof(Pawn), floatMenuContextType]); } } catch (Exception ex) diff --git a/Source/Mods/MoveWhileReloading.cs b/Source/Mods/MoveWhileReloading.cs index 5cf148eb..59b4914e 100644 --- a/Source/Mods/MoveWhileReloading.cs +++ b/Source/Mods/MoveWhileReloading.cs @@ -99,8 +99,15 @@ private static JobDef GetCeReloadJobDef() return DefDatabase.GetNamedSilentFail("ReloadWeapon"); } - private static bool IsAnyRunAndGunActive() => - ModsConfig.IsActive("roolo.RunAndGun") - || ModsConfig.IsActive("roolo.RunAndGun.kotobike") - || ModsConfig.IsActive("memegoddess.RunAndGun"); + private static bool IsAnyRunAndGunActive() + { + foreach (var mod in LoadedModManager.RunningMods) + { + var id = mod.PackageId.NoModIdSuffix().ToLower(); + if (id is "roolo.runandgun" or "roolo.runandgun.kotobike" or "memegoddess.runandgun") + return true; + } + + return false; + } } diff --git a/Source/Mods/RunandGun.cs b/Source/Mods/RunandGun.cs index 260f504f..8f72de43 100644 --- a/Source/Mods/RunandGun.cs +++ b/Source/Mods/RunandGun.cs @@ -1,6 +1,11 @@ using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; using HarmonyLib; using Multiplayer.API; +using RimWorld; +using UnityEngine; using Verse; namespace Multiplayer.Compat @@ -15,39 +20,217 @@ namespace Multiplayer.Compat [MpCompatFor("memegoddess.RunAndGun")] class RunandGun { - [MpCompatSyncField("RunAndGun.CompRunAndGun", "isEnabled")] - protected static ISyncField runAndGunEnabledField; + private const string GizmoPatch = "RunAndGun.Harmony.Pawn_DraftController_GetGizmos_Patch"; + private const string RunAndGunIconPath = "UI/Buttons/enable_RG"; + + private static FieldInfo compIsEnabledField; + private static FieldInfo weaponForbidderField; + private static Texture2D runAndGunIcon; public RunandGun(ModContentPack mod) { - MpCompatPatchLoader.LoadPatch(this); + RegisterToggleSync(); + PatchPawnGetGizmos(); + PatchMentalStateRand(); + } + + private static void PatchMentalStateRand() + { + var patchType = AccessTools.TypeByName("RunAndGun.Harmony.MentalStateHandler_TryStartMentalState"); + if (patchType == null) + return; + + MethodInfo target = null; + try + { + target = MpMethodUtil.GetLocalFunc(patchType, "Postfix", localFunc: "shouldRunAndGun"); + } + catch (Exception) + { + // Roslyn local-function name may differ between builds + } + + target ??= AccessTools.GetDeclaredMethods(patchType) + .FirstOrDefault(m => m.Name.Contains("shouldRunAndGun", StringComparison.Ordinal)); + + if (target != null) + PatchingUtilities.PatchSystemRand(target, false); + } + + /// Fallback gizmo only — no MP.WatchBegin here (runs every GUI frame and breaks combat sync). + private static void PatchPawnGetGizmos() + { + var getGizmos = AccessTools.Method(typeof(Pawn), nameof(Pawn.GetGizmos)); + MpCompat.harmony.Patch(getGizmos, + postfix: new HarmonyMethod(typeof(RunandGun), nameof(PawnGetGizmosPostfix))); + } + + /// + /// Sync delegate must include the captured CompRunAndGun ("data") or the host cannot apply OFF. + /// kotobike/memegoddess: toggle is <Postfix>b__1; roolo 1.4 uses lambda ordinal 2. + /// + private static void RegisterToggleSync() + { + var patchType = AccessTools.TypeByName(GizmoPatch); + if (patchType == null) + return; + + var compType = AccessTools.TypeByName("RunAndGun.CompRunAndGun"); + if (compType != null) + MP.RegisterSyncField(compType, "isEnabled"); + + MP.RegisterSyncMethod(typeof(RunandGun), nameof(SyncSetRunAndGunEnabled)); + + string[] closureFields = ["data"]; + var registered = false; - MpCompat.RegisterLambdaDelegate("RunAndGun.Harmony.Pawn_DraftController_GetGizmos_Patch", "Postfix", 2); - PatchingUtilities.PatchUnityRand("RunAndGun.Harmony.MentalStateHandler_TryStartMentalState:shouldRunAndGun", false); + try + { + MP.RegisterSyncDelegate(patchType, "<>c__DisplayClass0_0", "b__1", closureFields); + registered = true; + } + catch (Exception) + { + // display class name differs between builds + } + + foreach (var ord in new[] { 1, 2 }) + { + try + { + MpCompat.RegisterLambdaDelegate(GizmoPatch, "Postfix", closureFields, ord); + registered = true; + } + catch (Exception) + { + // try next ordinal + } + } + + if (!registered) + Log.Warning("MPCompat :: RunAndGun toggle lambda not found (tried direct delegate and ordinals 1, 2)"); } - [MpCompatPrefix("RunAndGun.Harmony.Pawn_DraftController_GetGizmos_Patch", "Postfix")] - private static void WatchRunAndGunEnabled(Pawn __instance) + public static void SyncSetRunAndGunEnabled(Pawn pawn, bool enabled) { - if (!MP.IsInMultiplayer) + if (pawn == null) return; - foreach (var comp in __instance.AllComps) + foreach (var comp in pawn.AllComps) { if (comp.GetType().Name != "CompRunAndGun") continue; - MP.WatchBegin(); - runAndGunEnabledField.Watch(comp); - break; + compIsEnabledField ??= AccessTools.Field(comp.GetType(), "isEnabled"); + compIsEnabledField?.SetValue(comp, enabled); + return; + } + } + + private static void PawnGetGizmosPostfix(Pawn __instance, ref IEnumerable __result) + { + if (!MP.IsInMultiplayer || __instance == null || !__instance.Drafted) + return; + + try + { + if (__instance.Faction != Faction.OfPlayer || !PawnHasRangedWeapon(__instance)) + return; + + var comp = GetRunAndGunComp(__instance); + if (comp == null || IsWeaponForbidden(__instance)) + return; + + var list = __result?.ToList() ?? new List(); + if (HasRunAndGunGizmo(list)) + { + __result = list; + return; + } + + runAndGunIcon ??= ContentFinder.Get(RunAndGunIconPath, true); + var isEnabled = GetRunAndGunEnabled(comp); + list.Add(new Command_Toggle + { + defaultLabel = "RG_Action_Enable_Label".Translate(), + defaultDesc = (isEnabled ? "RG_Action_Disable_Description" : "RG_Action_Enable_Description").Translate(), + icon = runAndGunIcon, + isActive = () => GetRunAndGunEnabled(comp), + toggleAction = () => SyncSetRunAndGunEnabled(__instance, !GetRunAndGunEnabled(comp)), + }); + __result = list; + } + catch (Exception ex) + { + Log.Warning($"MPCompat :: RunAndGun pawn GetGizmos compat skipped: {ex.Message}"); } } - [MpCompatPostfix("RunAndGun.Harmony.Pawn_DraftController_GetGizmos_Patch", "Postfix")] - private static void EndWatchRunAndGunEnabled() + private static ThingComp GetRunAndGunComp(Pawn pawn) + { + foreach (var comp in pawn.AllComps) + { + if (comp.GetType().Name == "CompRunAndGun") + return comp; + } + + return null; + } + + private static bool GetRunAndGunEnabled(ThingComp comp) + { + compIsEnabledField ??= AccessTools.Field(comp.GetType(), "isEnabled"); + return compIsEnabledField != null && (bool)compIsEnabledField.GetValue(comp); + } + + private static bool HasRunAndGunGizmo(List gizmos) + { + runAndGunIcon ??= ContentFinder.Get(RunAndGunIconPath, true); + if (runAndGunIcon == null) + return false; + + var iconName = runAndGunIcon.name; + return gizmos.OfType().Any(g => g.icon != null && g.icon.name == iconName); + } + + private static bool PawnHasRangedWeapon(Pawn pawn) { - if (MP.IsInMultiplayer) - MP.WatchEnd(); + var primary = pawn.equipment?.Primary; + return primary?.def?.IsRangedWeapon == true; + } + + private static bool IsWeaponForbidden(Pawn pawn) + { + try + { + if (pawn.equipment?.Primary == null) + return false; + + var baseType = AccessTools.TypeByName("RunAndGun.Base"); + if (baseType == null) + return false; + + weaponForbidderField ??= AccessTools.Field(baseType, "weaponForbidder"); + var forbidder = weaponForbidderField?.GetValue(null); + if (forbidder == null) + return false; + + var innerListField = AccessTools.Field(forbidder.GetType(), "InnerList"); + var innerList = innerListField?.GetValue(forbidder) as System.Collections.IDictionary; + if (innerList == null) + return false; + + if (!innerList.Contains(pawn.equipment.Primary.def.defName)) + return false; + + var record = innerList[pawn.equipment.Primary.def.defName]; + var isSelectedField = AccessTools.Field(record.GetType(), "isSelected"); + return isSelectedField != null && (bool)isSelectedField.GetValue(record); + } + catch + { + return false; + } } } } diff --git a/Source/MpCompat.cs b/Source/MpCompat.cs index b47a3c88..bd68529c 100644 --- a/Source/MpCompat.cs +++ b/Source/MpCompat.cs @@ -24,6 +24,7 @@ public MpCompat(ModContentPack content) : base(content) MpCompatLoader.Load(content); harmony.PatchAll(); + MultiplayerReconnectFix.Apply(); } static IEnumerable RegisterLambdaMethod_Impl(Type parentType, string parentMethod, MethodType methodType, params int[] lambdaOrdinals) @@ -58,10 +59,36 @@ public static ISyncMethod[] RegisterLambdaMethod(string parentType, string paren static IEnumerable RegisterLambdaDelegate_Impl(Type parentType, string parentMethod, MethodType methodType, string[] fields, params int[] lambdaOrdinals) { foreach (int ord in lambdaOrdinals) - { - var method = MpMethodUtil.GetLambda(parentType, parentMethod, methodType, null, ord); - yield return MP.RegisterSyncDelegate(parentType, method.DeclaringType.Name, method.Name, fields); - } + yield return RegisterLambdaDelegateInternal(parentType, parentMethod, methodType, fields, ord); + } + + /// + /// Registers a compiler-generated delegate. Uses nested-type lookup only for direct children of + /// ; otherwise registers by (iterator / state-machine lambdas). + /// + internal static ISyncDelegate RegisterLambdaDelegateInternal(Type parentType, string parentMethod, MethodType methodType, string[] fields, int ord, Type[] parentArgs = null) + { + var method = MpMethodUtil.GetLambda(parentType, parentMethod, methodType, parentArgs, ord); + var declaringType = method.DeclaringType; + if (declaringType is { IsNested: true } && declaringType.DeclaringType == parentType) + return MP.RegisterSyncDelegate(parentType, declaringType.Name, method.Name, fields); + + return RegisterSyncDelegateDirect(method, fields); + } + + static ISyncDelegate RegisterSyncDelegateDirect(MethodInfo method, string[] fields) + { + var syncType = AccessTools.TypeByName("Multiplayer.Client.Sync"); + var direct = syncType == null + ? null + : AccessTools.Method(syncType, "RegisterSyncDelegate", [typeof(MethodInfo), typeof(string[])]); + if (direct != null) + return (ISyncDelegate)direct.Invoke(null, [method, fields]); + + if (fields is { Length: > 0 }) + throw new Exception($"Cannot register {method.DeclaringType}::{method.Name} with closure fields"); + + throw new Exception($"Multiplayer.Client.Sync.RegisterSyncDelegate(MethodInfo) not found for {method.DeclaringType}::{method.Name}"); } public static ISyncDelegate[] RegisterLambdaDelegate(Type parentType, string parentMethod, params int[] lambdaOrdinals) diff --git a/Source/MpCompatLoader.cs b/Source/MpCompatLoader.cs index d5cae69b..675e9c4a 100644 --- a/Source/MpCompatLoader.cs +++ b/Source/MpCompatLoader.cs @@ -44,7 +44,7 @@ static void LoadConditional(ModContentPack content) var anyMod = attr.Any(a => { var modId = ((string)a.ConstructorArguments.First().Value).ToLower(); - var mod = LoadedModManager.RunningMods.FirstOrDefault(m => m.PackageId.NoModIdSuffix() == modId); + var mod = LoadedModManager.RunningMods.FirstOrDefault(m => m.PackageId.NoModIdSuffix().ToLower() == modId); return mod != null; }); @@ -69,7 +69,7 @@ static void InitCompatInAsm(Assembly asm) ) .Join(LoadedModManager.RunningMods, box => box.compat.PackageId.ToLower(), - mod => mod.PackageId.NoModIdSuffix(), + mod => mod.PackageId.NoModIdSuffix().ToLower(), (box, mod) => new { box.type, mod }); foreach (var action in queue) diff --git a/Source/MpMethodUtil.cs b/Source/MpMethodUtil.cs index 20d1921b..e6e97b44 100644 --- a/Source/MpMethodUtil.cs +++ b/Source/MpMethodUtil.cs @@ -73,6 +73,25 @@ public static MethodInfo GetLambdaGeneric(Type parentType, string parentMethod = // Non-capturing lambda lambda ??= AccessTools.Method(parentType, lambdaNameFull); + // Iterator state machines nest display classes inside d__N + if (lambda == null) + { + var stateMachinePrefix = $"<{parent.Name}>{EnumerableStateMachineInfix}{parentId}"; + var stateMachine = parentType.GetNestedTypes(AccessTools.all) + .FirstOrDefault(t => t.Name.StartsWith(stateMachinePrefix)); + + if (stateMachine != null) + { + lambda = stateMachine.GetNestedTypes(AccessTools.all) + .Where(t => t.Name.StartsWith(displayClassPrefix)) + .SelectMany(AccessTools.GetDeclaredMethods) + .FirstOrDefault(m => m.Name == lambdaNameShort); + + lambda ??= AccessTools.GetDeclaredMethods(stateMachine) + .FirstOrDefault(m => m.Name == lambdaNameFull); + } + } + // Non-capturing cached lambda if (lambda == null && AccessTools.Inner(parentType, SharedDisplayClass) is { } sharedDisplayClass) lambda = AccessTools.Method(sharedDisplayClass, lambdaNameFull); diff --git a/Source/MultiplayerReconnectFix.cs b/Source/MultiplayerReconnectFix.cs new file mode 100644 index 00000000..2c728b18 --- /dev/null +++ b/Source/MultiplayerReconnectFix.cs @@ -0,0 +1,314 @@ +using System; +using System.Collections; +using System.Reflection; +using System.Threading; +using HarmonyLib; +using Multiplayer.API; +using Steamworks; +using Verse; +using Verse.Steam; + +namespace Multiplayer.Compat; + +/// +/// Fixes RimWorld Multiplayer reconnect after "fix and restart" on mod mismatch. +/// Host stale pendingSteam blocks repeat Steam P2P approval; lingering join slots block the same username. +/// +public static class MultiplayerReconnectFix +{ + private const string RestartDelayFlag = "MultiplayerCompatRestartReconnect"; + private const int RestartReconnectDelayMs = 4000; + + private static readonly MethodInfo AcceptPlayerJoinRequest = + AccessTools.Method("Multiplayer.Client.SteamIntegration:AcceptPlayerJoinRequest"); + + private static readonly MethodInfo StopMultiplayerAndClearAllWindows = + AccessTools.Method("Multiplayer.Client.Multiplayer:StopMultiplayerAndClearAllWindows"); + + private static readonly MethodInfo JoinIfApplicableMethod = + AccessTools.Method("Multiplayer.Client.Util.AutoJoinHandler:JoinIfApplicable"); + + private static readonly Type MpType = AccessTools.TypeByName("Multiplayer.Client.Multiplayer"); + private static readonly Type ServerType = AccessTools.TypeByName("Multiplayer.Common.MultiplayerServer"); + private static readonly Type DisconnectReasonType = AccessTools.TypeByName("Multiplayer.Common.MpDisconnectReason"); + private static readonly Type PendingPlayerWindowType = + AccessTools.TypeByName("Multiplayer.Client.Windows.PendingPlayerWindow"); + private static readonly Type RequestType = + PendingPlayerWindowType?.GetNestedType("Request", BindingFlags.Public); + + private static MethodInfo _enqueueJoinRequest; + private static Delegate _joinRequestCallback; + private static bool _p2pHandlerRegistered; + + public static void Apply() + { + if (MpType == null || ServerType == null) + { + Log.Warning("MPCompat :: Multiplayer reconnect fix skipped (Multiplayer types not found)."); + return; + } + + PatchServerUsername(); + PatchFixAndRestart(); + PatchRunCallback(); + PatchSetDisconnected(); + PatchAutoJoinHandler(); + LongEventHandler.ExecuteWhenFinished(RegisterP2PReconnectHandler); + Log.Message("MPCompat :: Multiplayer reconnect-after-restart fix loaded."); + } + + private static void PatchAutoJoinHandler() + { + if (JoinIfApplicableMethod == null) + return; + + MpCompat.harmony.Patch(JoinIfApplicableMethod, + prefix: new HarmonyMethod(typeof(MultiplayerReconnectFix), nameof(DelayRestartAutoJoin))); + } + + /// Give the host time to drop the old Steam/MP slot before the client reconnects. + private static bool DelayRestartAutoJoin() + { + if (Environment.GetEnvironmentVariable(RestartDelayFlag) is not "true") + return true; + + Environment.SetEnvironmentVariable(RestartDelayFlag, ""); + LongEventHandler.QueueLongEvent(DeferredRestartJoin, "MpConnecting", false, null); + return false; + } + + private static void DeferredRestartJoin() + { + Thread.Sleep(RestartReconnectDelayMs); + JoinIfApplicableMethod?.Invoke(null, null); + } + + private static void PatchServerUsername() + { + var joiningState = AccessTools.TypeByName("Multiplayer.Common.ServerJoiningState"); + var handleUsername = AccessTools.DeclaredMethod(joiningState, "HandleUsername"); + if (handleUsername == null) return; + + MpCompat.harmony.Patch(handleUsername, + prefix: new HarmonyMethod(typeof(MultiplayerReconnectFix), nameof(ReplaceStaleJoiningPlayer))); + } + + private static void PatchFixAndRestart() + { + var doRestart = AccessTools.DeclaredMethod( + AccessTools.TypeByName("Multiplayer.Client.JoinDataWindow+FixAndRestartWindow"), "DoRestart"); + if (doRestart == null) return; + + MpCompat.harmony.Patch(doRestart, + prefix: new HarmonyMethod(typeof(MultiplayerReconnectFix), nameof(DisconnectBeforeFixAndRestart)), + postfix: new HarmonyMethod(typeof(MultiplayerReconnectFix), nameof(MarkRestartReconnect))); + } + + private static void PatchRunCallback() + { + var runCallback = AccessTools.DeclaredMethod(RequestType, "RunCallback"); + if (runCallback == null) return; + + MpCompat.harmony.Patch(runCallback, + postfix: new HarmonyMethod(typeof(MultiplayerReconnectFix), nameof(CleanupPendingSteamOnReject))); + } + + private static void PatchSetDisconnected() + { + var setDisconnected = AccessTools.DeclaredMethod( + AccessTools.TypeByName("Multiplayer.Common.PlayerManager"), "SetDisconnected"); + if (setDisconnected == null) return; + + MpCompat.harmony.Patch(setDisconnected, + postfix: new HarmonyMethod(typeof(MultiplayerReconnectFix), nameof(RemovePendingSteamOnDisconnect))); + } + + private static void RegisterP2PReconnectHandler() + { + if (_p2pHandlerRegistered) + return; + + if (!SteamManager.Initialized) + { + Log.Warning("MPCompat :: Steam not ready; retrying reconnect P2P handler next frame."); + LongEventHandler.QueueLongEvent(RegisterP2PReconnectHandler, "MpCompatReconnect", false, null); + return; + } + + if (AcceptPlayerJoinRequest == null || RequestType == null || PendingPlayerWindowType == null) + return; + + _enqueueJoinRequest ??= AccessTools.DeclaredMethod(PendingPlayerWindowType, "EnqueueJoinRequest", + [typeof(CSteamID), typeof(Action<,>).MakeGenericType(RequestType, typeof(bool))]); + + if (_enqueueJoinRequest == null) + return; + + _joinRequestCallback ??= Delegate.CreateDelegate( + typeof(Action<,>).MakeGenericType(RequestType, typeof(bool)), + typeof(MultiplayerReconnectFix).GetMethod(nameof(SteamJoinRequestCallback), + BindingFlags.Static | BindingFlags.NonPublic)); + + Callback.Create(req => HandleP2PSessionRequest(req.m_steamIDRemote)); + _p2pHandlerRegistered = true; + Log.Message("MPCompat :: Steam P2P reconnect handler registered."); + } + + private static void DisconnectBeforeFixAndRestart() => + StopMultiplayerAndClearAllWindows?.Invoke(null, null); + + private static void MarkRestartReconnect() => + Environment.SetEnvironmentVariable(RestartDelayFlag, "true"); + + private static void ReplaceStaleJoiningPlayer(object packet) + { + var server = AccessTools.Property(ServerType, "instance")?.GetValue(null); + if (server == null) return; + + var username = AccessTools.Field(packet.GetType(), "username")?.GetValue(packet) as string; + if (username.NullOrEmpty()) return; + + var existing = AccessTools.Method(ServerType, "GetPlayer", [typeof(string)])?.Invoke(server, [username]); + if (existing == null) return; + + if (AccessTools.Property(existing.GetType(), "IsPlaying")?.GetValue(existing) is true) + return; + + var steamId = AccessTools.Field(existing.GetType(), "steamId")?.GetValue(existing); + if (steamId is ulong id && id != 0) + CleanupHostSteamSlot((CSteamID)id); + + var disconnect = AccessTools.Method(existing.GetType(), "Disconnect", [DisconnectReasonType, typeof(byte[])]); + var clientLeft = Enum.Parse(DisconnectReasonType, "ClientLeft"); + disconnect?.Invoke(existing, [clientLeft, null]); + } + + private static void CleanupPendingSteamOnReject(object __instance, bool accepted) + { + if (accepted) return; + + if (TryGetSteamId(__instance, out var steamId)) + CleanupHostSteamSlot(steamId); + } + + private static void RemovePendingSteamOnDisconnect(object conn) + { + var player = AccessTools.Field(conn.GetType(), "serverPlayer")?.GetValue(conn); + if (player == null) return; + + var steamId = AccessTools.Field(player.GetType(), "steamId")?.GetValue(player); + if (steamId is ulong id && id != 0) + CleanupHostSteamSlot((CSteamID)id); + } + + private static void HandleP2PSessionRequest(CSteamID remoteId) + { + if (!IsHostingSteam()) + return; + + var session = AccessTools.Field(MpType, "session")?.GetValue(null); + if (session == null) + return; + + var pendingSteam = AccessTools.Field(session.GetType(), "pendingSteam")?.GetValue(session) as IList; + if (pendingSteam == null || !pendingSteam.Contains(remoteId)) + return; + + // Vanilla MP ignores repeat requests while pendingSteam still contains this Steam ID. + pendingSteam.Remove(remoteId); + CloseP2PSession(remoteId); + ReenqueueSteamJoinRequest(remoteId); + } + + private static bool IsHostingSteam() + { + var localServer = AccessTools.Property(MpType, "LocalServer")?.GetValue(null); + if (localServer == null) return false; + + var settings = AccessTools.Field(localServer.GetType(), "settings")?.GetValue(localServer); + return settings != null && AccessTools.Field(settings.GetType(), "steam")?.GetValue(settings) is true; + } + + private static void ReenqueueSteamJoinRequest(CSteamID remoteId) + { + var mpSettings = AccessTools.Field(MpType, "settings")?.GetValue(null); + if (mpSettings != null && + AccessTools.Field(mpSettings.GetType(), "autoAcceptSteam")?.GetValue(mpSettings) is true) + { + AcceptPlayerJoinRequest?.Invoke(null, [remoteId]); + return; + } + + var session = AccessTools.Field(MpType, "session")?.GetValue(null); + var pendingSteam = AccessTools.Field(session?.GetType() ?? typeof(object), "pendingSteam") + ?.GetValue(session) as IList; + + if (pendingSteam != null && !pendingSteam.Contains(remoteId)) + pendingSteam.Add(remoteId); + + _enqueueJoinRequest?.Invoke(null, [remoteId, _joinRequestCallback]); + + var knownUsers = AccessTools.Field(session!.GetType(), "knownUsers")?.GetValue(session) as IList; + if (knownUsers != null && !knownUsers.Contains(remoteId)) + knownUsers.Add(remoteId); + + AccessTools.Method(session.GetType(), "NotifyChat")?.Invoke(session, null); + SteamFriends.RequestUserInformation(remoteId, true); + } + + private static void SteamJoinRequestCallback(object joinReq, bool accepted) + { + if (!accepted) + { + if (TryGetSteamId(joinReq, out var steamId)) + CleanupHostSteamSlot(steamId); + return; + } + + if (TryGetSteamId(joinReq, out var id)) + AcceptPlayerJoinRequest?.Invoke(null, [id]); + } + + private static void CleanupHostSteamSlot(CSteamID steamId) + { + RemoveFromPendingSteam(steamId); + CloseP2PSession(steamId); + } + + private static void RemoveFromPendingSteam(CSteamID steamId) + { + var session = AccessTools.Field(MpType, "session")?.GetValue(null); + (AccessTools.Field(session?.GetType() ?? typeof(object), "pendingSteam")?.GetValue(session) as IList) + ?.Remove(steamId); + } + + private static void CloseP2PSession(CSteamID steamId) + { + if (!SteamManager.Initialized) + return; + + try + { + SteamNetworking.CloseP2PSessionWithUser(steamId); + } + catch (Exception ex) + { + Log.Warning($"MPCompat :: CloseP2PSessionWithUser failed for {steamId}: {ex.Message}"); + } + } + + private static bool TryGetSteamId(object request, out CSteamID steamId) + { + steamId = default; + var nullable = AccessTools.Field(request.GetType(), "steamId")?.GetValue(request); + if (nullable == null) + return false; + + var nullableType = nullable.GetType(); + if (!(bool)nullableType.GetProperty("HasValue")!.GetValue(nullable)!) + return false; + + steamId = (CSteamID)nullableType.GetProperty("Value")!.GetValue(nullable)!; + return true; + } +} From fcf7dae96b2f90f0482632072371d95f2c2d9e00 Mon Sep 17 00:00:00 2001 From: retr0 <45705896+1Anton10@users.noreply.github.com> Date: Wed, 24 Jun 2026 02:40:05 +0500 Subject: [PATCH 7/9] Remove MpCompat reconnect workaround now fixed upstream in Multiplayer Steam P2P reconnect after fix-and-restart belongs in rwmt/Multiplayer, not the compatibility layer. Co-authored-by: Cursor --- Source/MpCompat.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Source/MpCompat.cs b/Source/MpCompat.cs index bd68529c..747f196b 100644 --- a/Source/MpCompat.cs +++ b/Source/MpCompat.cs @@ -24,7 +24,6 @@ public MpCompat(ModContentPack content) : base(content) MpCompatLoader.Load(content); harmony.PatchAll(); - MultiplayerReconnectFix.Apply(); } static IEnumerable RegisterLambdaMethod_Impl(Type parentType, string parentMethod, MethodType methodType, params int[] lambdaOrdinals) From 8e8446c9f4313dc80b06e5b7549be5bfad58c07d Mon Sep 17 00:00:00 2001 From: retr0 <45705896+1Anton10@users.noreply.github.com> Date: Wed, 24 Jun 2026 02:40:12 +0500 Subject: [PATCH 8/9] Delete MultiplayerReconnectFix.cs (moved to Multiplayer mod) Co-authored-by: Cursor --- Source/MultiplayerReconnectFix.cs | 314 ------------------------------ 1 file changed, 314 deletions(-) delete mode 100644 Source/MultiplayerReconnectFix.cs diff --git a/Source/MultiplayerReconnectFix.cs b/Source/MultiplayerReconnectFix.cs deleted file mode 100644 index 2c728b18..00000000 --- a/Source/MultiplayerReconnectFix.cs +++ /dev/null @@ -1,314 +0,0 @@ -using System; -using System.Collections; -using System.Reflection; -using System.Threading; -using HarmonyLib; -using Multiplayer.API; -using Steamworks; -using Verse; -using Verse.Steam; - -namespace Multiplayer.Compat; - -/// -/// Fixes RimWorld Multiplayer reconnect after "fix and restart" on mod mismatch. -/// Host stale pendingSteam blocks repeat Steam P2P approval; lingering join slots block the same username. -/// -public static class MultiplayerReconnectFix -{ - private const string RestartDelayFlag = "MultiplayerCompatRestartReconnect"; - private const int RestartReconnectDelayMs = 4000; - - private static readonly MethodInfo AcceptPlayerJoinRequest = - AccessTools.Method("Multiplayer.Client.SteamIntegration:AcceptPlayerJoinRequest"); - - private static readonly MethodInfo StopMultiplayerAndClearAllWindows = - AccessTools.Method("Multiplayer.Client.Multiplayer:StopMultiplayerAndClearAllWindows"); - - private static readonly MethodInfo JoinIfApplicableMethod = - AccessTools.Method("Multiplayer.Client.Util.AutoJoinHandler:JoinIfApplicable"); - - private static readonly Type MpType = AccessTools.TypeByName("Multiplayer.Client.Multiplayer"); - private static readonly Type ServerType = AccessTools.TypeByName("Multiplayer.Common.MultiplayerServer"); - private static readonly Type DisconnectReasonType = AccessTools.TypeByName("Multiplayer.Common.MpDisconnectReason"); - private static readonly Type PendingPlayerWindowType = - AccessTools.TypeByName("Multiplayer.Client.Windows.PendingPlayerWindow"); - private static readonly Type RequestType = - PendingPlayerWindowType?.GetNestedType("Request", BindingFlags.Public); - - private static MethodInfo _enqueueJoinRequest; - private static Delegate _joinRequestCallback; - private static bool _p2pHandlerRegistered; - - public static void Apply() - { - if (MpType == null || ServerType == null) - { - Log.Warning("MPCompat :: Multiplayer reconnect fix skipped (Multiplayer types not found)."); - return; - } - - PatchServerUsername(); - PatchFixAndRestart(); - PatchRunCallback(); - PatchSetDisconnected(); - PatchAutoJoinHandler(); - LongEventHandler.ExecuteWhenFinished(RegisterP2PReconnectHandler); - Log.Message("MPCompat :: Multiplayer reconnect-after-restart fix loaded."); - } - - private static void PatchAutoJoinHandler() - { - if (JoinIfApplicableMethod == null) - return; - - MpCompat.harmony.Patch(JoinIfApplicableMethod, - prefix: new HarmonyMethod(typeof(MultiplayerReconnectFix), nameof(DelayRestartAutoJoin))); - } - - /// Give the host time to drop the old Steam/MP slot before the client reconnects. - private static bool DelayRestartAutoJoin() - { - if (Environment.GetEnvironmentVariable(RestartDelayFlag) is not "true") - return true; - - Environment.SetEnvironmentVariable(RestartDelayFlag, ""); - LongEventHandler.QueueLongEvent(DeferredRestartJoin, "MpConnecting", false, null); - return false; - } - - private static void DeferredRestartJoin() - { - Thread.Sleep(RestartReconnectDelayMs); - JoinIfApplicableMethod?.Invoke(null, null); - } - - private static void PatchServerUsername() - { - var joiningState = AccessTools.TypeByName("Multiplayer.Common.ServerJoiningState"); - var handleUsername = AccessTools.DeclaredMethod(joiningState, "HandleUsername"); - if (handleUsername == null) return; - - MpCompat.harmony.Patch(handleUsername, - prefix: new HarmonyMethod(typeof(MultiplayerReconnectFix), nameof(ReplaceStaleJoiningPlayer))); - } - - private static void PatchFixAndRestart() - { - var doRestart = AccessTools.DeclaredMethod( - AccessTools.TypeByName("Multiplayer.Client.JoinDataWindow+FixAndRestartWindow"), "DoRestart"); - if (doRestart == null) return; - - MpCompat.harmony.Patch(doRestart, - prefix: new HarmonyMethod(typeof(MultiplayerReconnectFix), nameof(DisconnectBeforeFixAndRestart)), - postfix: new HarmonyMethod(typeof(MultiplayerReconnectFix), nameof(MarkRestartReconnect))); - } - - private static void PatchRunCallback() - { - var runCallback = AccessTools.DeclaredMethod(RequestType, "RunCallback"); - if (runCallback == null) return; - - MpCompat.harmony.Patch(runCallback, - postfix: new HarmonyMethod(typeof(MultiplayerReconnectFix), nameof(CleanupPendingSteamOnReject))); - } - - private static void PatchSetDisconnected() - { - var setDisconnected = AccessTools.DeclaredMethod( - AccessTools.TypeByName("Multiplayer.Common.PlayerManager"), "SetDisconnected"); - if (setDisconnected == null) return; - - MpCompat.harmony.Patch(setDisconnected, - postfix: new HarmonyMethod(typeof(MultiplayerReconnectFix), nameof(RemovePendingSteamOnDisconnect))); - } - - private static void RegisterP2PReconnectHandler() - { - if (_p2pHandlerRegistered) - return; - - if (!SteamManager.Initialized) - { - Log.Warning("MPCompat :: Steam not ready; retrying reconnect P2P handler next frame."); - LongEventHandler.QueueLongEvent(RegisterP2PReconnectHandler, "MpCompatReconnect", false, null); - return; - } - - if (AcceptPlayerJoinRequest == null || RequestType == null || PendingPlayerWindowType == null) - return; - - _enqueueJoinRequest ??= AccessTools.DeclaredMethod(PendingPlayerWindowType, "EnqueueJoinRequest", - [typeof(CSteamID), typeof(Action<,>).MakeGenericType(RequestType, typeof(bool))]); - - if (_enqueueJoinRequest == null) - return; - - _joinRequestCallback ??= Delegate.CreateDelegate( - typeof(Action<,>).MakeGenericType(RequestType, typeof(bool)), - typeof(MultiplayerReconnectFix).GetMethod(nameof(SteamJoinRequestCallback), - BindingFlags.Static | BindingFlags.NonPublic)); - - Callback.Create(req => HandleP2PSessionRequest(req.m_steamIDRemote)); - _p2pHandlerRegistered = true; - Log.Message("MPCompat :: Steam P2P reconnect handler registered."); - } - - private static void DisconnectBeforeFixAndRestart() => - StopMultiplayerAndClearAllWindows?.Invoke(null, null); - - private static void MarkRestartReconnect() => - Environment.SetEnvironmentVariable(RestartDelayFlag, "true"); - - private static void ReplaceStaleJoiningPlayer(object packet) - { - var server = AccessTools.Property(ServerType, "instance")?.GetValue(null); - if (server == null) return; - - var username = AccessTools.Field(packet.GetType(), "username")?.GetValue(packet) as string; - if (username.NullOrEmpty()) return; - - var existing = AccessTools.Method(ServerType, "GetPlayer", [typeof(string)])?.Invoke(server, [username]); - if (existing == null) return; - - if (AccessTools.Property(existing.GetType(), "IsPlaying")?.GetValue(existing) is true) - return; - - var steamId = AccessTools.Field(existing.GetType(), "steamId")?.GetValue(existing); - if (steamId is ulong id && id != 0) - CleanupHostSteamSlot((CSteamID)id); - - var disconnect = AccessTools.Method(existing.GetType(), "Disconnect", [DisconnectReasonType, typeof(byte[])]); - var clientLeft = Enum.Parse(DisconnectReasonType, "ClientLeft"); - disconnect?.Invoke(existing, [clientLeft, null]); - } - - private static void CleanupPendingSteamOnReject(object __instance, bool accepted) - { - if (accepted) return; - - if (TryGetSteamId(__instance, out var steamId)) - CleanupHostSteamSlot(steamId); - } - - private static void RemovePendingSteamOnDisconnect(object conn) - { - var player = AccessTools.Field(conn.GetType(), "serverPlayer")?.GetValue(conn); - if (player == null) return; - - var steamId = AccessTools.Field(player.GetType(), "steamId")?.GetValue(player); - if (steamId is ulong id && id != 0) - CleanupHostSteamSlot((CSteamID)id); - } - - private static void HandleP2PSessionRequest(CSteamID remoteId) - { - if (!IsHostingSteam()) - return; - - var session = AccessTools.Field(MpType, "session")?.GetValue(null); - if (session == null) - return; - - var pendingSteam = AccessTools.Field(session.GetType(), "pendingSteam")?.GetValue(session) as IList; - if (pendingSteam == null || !pendingSteam.Contains(remoteId)) - return; - - // Vanilla MP ignores repeat requests while pendingSteam still contains this Steam ID. - pendingSteam.Remove(remoteId); - CloseP2PSession(remoteId); - ReenqueueSteamJoinRequest(remoteId); - } - - private static bool IsHostingSteam() - { - var localServer = AccessTools.Property(MpType, "LocalServer")?.GetValue(null); - if (localServer == null) return false; - - var settings = AccessTools.Field(localServer.GetType(), "settings")?.GetValue(localServer); - return settings != null && AccessTools.Field(settings.GetType(), "steam")?.GetValue(settings) is true; - } - - private static void ReenqueueSteamJoinRequest(CSteamID remoteId) - { - var mpSettings = AccessTools.Field(MpType, "settings")?.GetValue(null); - if (mpSettings != null && - AccessTools.Field(mpSettings.GetType(), "autoAcceptSteam")?.GetValue(mpSettings) is true) - { - AcceptPlayerJoinRequest?.Invoke(null, [remoteId]); - return; - } - - var session = AccessTools.Field(MpType, "session")?.GetValue(null); - var pendingSteam = AccessTools.Field(session?.GetType() ?? typeof(object), "pendingSteam") - ?.GetValue(session) as IList; - - if (pendingSteam != null && !pendingSteam.Contains(remoteId)) - pendingSteam.Add(remoteId); - - _enqueueJoinRequest?.Invoke(null, [remoteId, _joinRequestCallback]); - - var knownUsers = AccessTools.Field(session!.GetType(), "knownUsers")?.GetValue(session) as IList; - if (knownUsers != null && !knownUsers.Contains(remoteId)) - knownUsers.Add(remoteId); - - AccessTools.Method(session.GetType(), "NotifyChat")?.Invoke(session, null); - SteamFriends.RequestUserInformation(remoteId, true); - } - - private static void SteamJoinRequestCallback(object joinReq, bool accepted) - { - if (!accepted) - { - if (TryGetSteamId(joinReq, out var steamId)) - CleanupHostSteamSlot(steamId); - return; - } - - if (TryGetSteamId(joinReq, out var id)) - AcceptPlayerJoinRequest?.Invoke(null, [id]); - } - - private static void CleanupHostSteamSlot(CSteamID steamId) - { - RemoveFromPendingSteam(steamId); - CloseP2PSession(steamId); - } - - private static void RemoveFromPendingSteam(CSteamID steamId) - { - var session = AccessTools.Field(MpType, "session")?.GetValue(null); - (AccessTools.Field(session?.GetType() ?? typeof(object), "pendingSteam")?.GetValue(session) as IList) - ?.Remove(steamId); - } - - private static void CloseP2PSession(CSteamID steamId) - { - if (!SteamManager.Initialized) - return; - - try - { - SteamNetworking.CloseP2PSessionWithUser(steamId); - } - catch (Exception ex) - { - Log.Warning($"MPCompat :: CloseP2PSessionWithUser failed for {steamId}: {ex.Message}"); - } - } - - private static bool TryGetSteamId(object request, out CSteamID steamId) - { - steamId = default; - var nullable = AccessTools.Field(request.GetType(), "steamId")?.GetValue(request); - if (nullable == null) - return false; - - var nullableType = nullable.GetType(); - if (!(bool)nullableType.GetProperty("HasValue")!.GetValue(nullable)!) - return false; - - steamId = (CSteamID)nullableType.GetProperty("Value")!.GetValue(nullable)!; - return true; - } -} From 0e5063ec6153d66b27cc4e0963f2a11da160d6eb Mon Sep 17 00:00:00 2001 From: retr0 <45705896+1Anton10@users.noreply.github.com> Date: Wed, 24 Jun 2026 02:53:47 +0500 Subject: [PATCH 9/9] Address PR #597 review: narrow Exosuit compat scope Align ExosuitFramework with maintainer feedback: selective gizmo sync, restore maintenance bay API sync, remove broad Rand/gizmo guards, cache ApplyDamageToModules via MethodInvoker, and drop catapult DoJump sync. Remove MoveWhileReloading and revert RunAndGun from this PR; those will follow as separate changes. Keep MpCompat lambda/state-machine helpers needed for Exosuit float menu provider sync. Co-authored-by: Cursor --- Source/Mods/ExosuitFramework.cs | 194 ++++++-------------------- Source/Mods/MoveWhileReloading.cs | 113 --------------- Source/Mods/RunandGun.cs | 220 +----------------------------- 3 files changed, 46 insertions(+), 481 deletions(-) delete mode 100644 Source/Mods/MoveWhileReloading.cs diff --git a/Source/Mods/ExosuitFramework.cs b/Source/Mods/ExosuitFramework.cs index 3aca1c5c..3500176f 100644 --- a/Source/Mods/ExosuitFramework.cs +++ b/Source/Mods/ExosuitFramework.cs @@ -22,6 +22,7 @@ public class ExosuitFramework private static Type exosuitCoreType; private static MethodInfo getClosestCoreForPawn; private static MethodInfo getClosestBay; + private static FastInvokeHandler applyDamageToModules; public ExosuitFramework(ModContentPack mod) { @@ -32,14 +33,12 @@ private static void LatePatch() { try { - PatchRand(); PatchGizmos(); PatchFloatMenus(); PatchJobs(); PatchTurrets(); PatchCatapult(); PatchExosuitDamage(); - Log.Message("MPCompat :: Initialized compatibility for aoba.exosuit.framework"); } catch (Exception ex) { @@ -47,88 +46,31 @@ private static void LatePatch() } } - private static void PatchRand() - { - // Must run on the main thread: patching Exosuit_Core triggers its static ctor, which creates textures. - PatchingUtilities.PatchPushPopRand([ - "Exosuit.Exosuit_Core:CheckPreAbsorbDamage", - "Exosuit.Exosuit_Core:GetPostArmorDamage", - "Exosuit.Exosuit_Core:ApplyDamageToModules", - "Exosuit.Exosuit_Core:ExosuitDestory", - "Exosuit.MechUtility:DissambleFrom", - "Exosuit.Verb_MeleeSweep:DoSweep", - "Exosuit.Building_AutoRepairArm:Tick", - "Mechsuit.AsyncShootVerb:TryCastShot", - ]); - } - private static void PatchGizmos() { - PatchExosuitPawnGetGizmosSafety(); - - // Command_Action.action delegates — RegisterSyncDelegate so clients see and can use pawn get-in/out gizmos. - TryRegisterLambdaDelegate("Exosuit.Patch_Pawn_GetGizmos", "Postfix", 0, 1); - - // Module weapon float-menu gizmo on worn exosuit pieces. - TryRegisterLambdaDelegate("Exosuit.CompModuleWeapon", nameof(ThingComp.CompGetWornGizmosExtra), 0, 1, 2); - - // Maintenance bay building gizmos use Exosuit's own [SyncMethod] locals (MP.RegisterAll). + // Pawn get-in / get-out buttons (Command_Action lambdas in Patch_Pawn_GetGizmos.Postfix). + MpCompat.RegisterLambdaDelegate("Exosuit.Patch_Pawn_GetGizmos", "Postfix", 0, 1); + // Eject to location (0), launch to map (1). Release (2) calls synced Eject on the core. var ejectorType = AccessTools.TypeByName("Exosuit.Building_EjectorBay"); if (ejectorType != null) { - var ejectorLambdas = TryRegisterLambdaDelegate(ejectorType, nameof(Building.GetGizmos), 0, 1, 2); - if (ejectorLambdas != null) - { - if (ejectorLambdas.Length > 0) - ejectorLambdas[0].SetContext(SyncContext.CurrentMap); - if (ejectorLambdas.Length > 1) - ejectorLambdas[1].SetContext(SyncContext.CurrentMap); - } + MpCompat.RegisterLambdaMethod(ejectorType, nameof(Building.GetGizmos), 0, 1) + .SetContext(SyncContext.CurrentMap); // Find.CurrentMap lives inside compiler-generated closures, not in GetGizmos itself. PatchEjectorBayLambdasCurrentMap(ejectorType); } - var turretType = AccessTools.TypeByName("Mechsuit.CompTurretGun"); - if (turretType != null) - TryRegisterLambdaDelegate(turretType, "GetGizmos", 0, 1, 2, 3); - } - - /// - /// Exosuit postfix calls __result.ToList() without a null check — throws and wipes every pawn gizmo in MP. - /// Cannot prefix Exosuit's void Postfix (Harmony: "Cannot get result from void method"); guard on Pawn.GetGizmos instead. - /// - private static void PatchExosuitPawnGetGizmosSafety() - { - var getGizmos = AccessTools.Method(typeof(Pawn), nameof(Pawn.GetGizmos)); - if (getGizmos == null) - return; - - var guardPostfix = new HarmonyMethod(typeof(ExosuitFramework), nameof(GuardPawnGizmoResultBeforeExosuit)) - { - before = ["Exosuit.Patch_Pawn_GetGizmos"], - }; - MpCompat.harmony.Patch(getGizmos, - postfix: guardPostfix, - finalizer: new HarmonyMethod(typeof(ExosuitFramework), nameof(RecoverPawnGetGizmos))); - } - - private static void GuardPawnGizmoResultBeforeExosuit(ref IEnumerable __result) - { - if (__result == null) - __result = []; - } - - private static Exception RecoverPawnGetGizmos(Exception __exception, ref IEnumerable __result) - { - if (__exception == null) - return null; + // Get in (0), toggle auto repair (2). Eject uses Exosuit_Core:Eject (synced in LatePatch). + var maintenanceBayType = AccessTools.TypeByName("Exosuit.Building_MaintenanceBay"); + if (maintenanceBayType != null) + MpCompat.RegisterLambdaMethod(maintenanceBayType, nameof(Building.GetGizmos), 0, 2); - Log.Warning($"MPCompat :: Exosuit pawn GetGizmos failed — other gizmos kept: {__exception.Message}"); - if (__result == null) - __result = []; - return null; + // Toggle safety (1). Synced for parity with other emergency UI; eject itself goes through Eject(). + var emergencyEjectType = AccessTools.TypeByName("Exosuit.ModuleComp_EmergencyEject"); + if (emergencyEjectType != null) + MpCompat.RegisterLambdaMethod(emergencyEjectType, nameof(ThingComp.CompGetWornGizmosExtra), 1); } private static void PatchEjectorBayLambdasCurrentMap(Type ejectorType) @@ -151,11 +93,16 @@ private static void PatchFloatMenus() { var maintenanceBayType = AccessTools.TypeByName("Exosuit.Building_MaintenanceBay"); if (maintenanceBayType != null) - TryRegisterLambdaDelegate(maintenanceBayType, nameof(Building.GetFloatMenuOptions), 0); + { + // Add/replace/remove modules, called from ITab_MechGear. + MP.RegisterSyncMethod(maintenanceBayType, "AddOrReplaceModule"); + MP.RegisterSyncMethod(maintenanceBayType, "RemoveModules"); + MpCompat.RegisterLambdaDelegate(maintenanceBayType, nameof(Building.GetFloatMenuOptions), 0); + } var ejectorType = AccessTools.TypeByName("Exosuit.Building_EjectorBay"); if (ejectorType != null) - TryRegisterLambdaDelegate(ejectorType, nameof(Building.GetFloatMenuOptions), 0); + MpCompat.RegisterLambdaDelegate(ejectorType, nameof(Building.GetFloatMenuOptions), 0); RegisterFloatMenuProviderLambdas(); } @@ -185,6 +132,11 @@ private static void PatchJobs() [typeof(Job), typeof(JobTag), typeof(bool)]); if (tryTake != null) MpCompat.harmony.Patch(tryTake, prefix: new HarmonyMethod(typeof(ExosuitFramework), nameof(RedirectExosuitTryTakeOrderedJob))); + + // Eject, only called from emergency UI. Safer to sync the method than every eject lambda. + var eject = AccessTools.DeclaredMethod("Exosuit.Exosuit_Core:Eject"); + if (eject != null) + MP.RegisterSyncMethod(eject); } private static void PatchExosuitDamage() @@ -197,6 +149,10 @@ private static void PatchExosuitDamage() if (healthField != null) MP.RegisterSyncField(healthField); + var applyMethod = AccessTools.Method(coreType, "ApplyDamageToModules"); + if (applyMethod != null) + applyDamageToModules = MethodInvoker.GetHandler(applyMethod); + var onHealthChanged = AccessTools.Method(coreType, "OnHealthChanged"); if (onHealthChanged != null) { @@ -218,8 +174,10 @@ private static bool ApplyExosuitModuleDamageImmediately(float amount, Apparel __ if (amount <= 0f) return true; - var apply = AccessTools.Method(__instance.GetType(), "ApplyDamageToModules"); - apply?.Invoke(__instance, [amount]); + if (applyDamageToModules == null) + return true; + + applyDamageToModules(__instance, amount); return false; } @@ -230,87 +188,21 @@ private static void PatchTurrets() return; // ITab/gizmo actions are already covered by Exosuit's MP.RegisterAll(); only sync explicit API entry points. - TryRegisterSyncMethod(turretType, "OrderAttack", typeof(LocalTargetInfo)); - TryRegisterSyncMethod(turretType, "ClearForcedTarget"); + var orderAttack = AccessTools.Method(turretType, "OrderAttack", [typeof(LocalTargetInfo)]); + if (orderAttack != null) + MP.RegisterSyncMethod(orderAttack); + + var clearTarget = AccessTools.Method(turretType, "ClearForcedTarget"); + if (clearTarget != null) + MP.RegisterSyncMethod(clearTarget); } private static void PatchCatapult() { + // WG_PawnFlyer catapult UI — not covered by ability sync. DoJump is already synced by MP ability sync. var flyerType = AccessTools.TypeByName("Exosuit.WG_PawnFlyer"); if (flyerType != null) - TryRegisterLambdaDelegate(flyerType, "GetOptionsForTile", 0, 1); - - var jumpType = AccessTools.TypeByName("Exosuit.WG_AbilityVerb_QuickJump"); - var doJump = AccessTools.Method(jumpType, "DoJump", - [ - typeof(Pawn), typeof(Map), typeof(LocalTargetInfo), typeof(LocalTargetInfo), typeof(bool), typeof(bool) - ]); - if (doJump != null) - MP.RegisterSyncMethod(doJump); - } - - private static ISyncMethod[] TryRegisterLambdaMethod(Type parentType, string parentMethod, params int[] lambdaOrdinals) - { - var registered = new List(); - foreach (var ord in lambdaOrdinals) - { - try - { - registered.AddRange(MpCompat.RegisterLambdaMethod(parentType, parentMethod, ord)); - } - catch (Exception ex) - { - Log.Warning($"MPCompat :: Exosuit skipped lambda sync for {parentType?.FullName}.{parentMethod} ordinal {ord}: {ex.Message}"); - } - } - - return registered.Count > 0 ? registered.ToArray() : null; - } - - private static ISyncMethod[] TryRegisterLambdaMethod(string parentType, string parentMethod, params int[] lambdaOrdinals) - { - var type = AccessTools.TypeByName(parentType); - return type == null ? null : TryRegisterLambdaMethod(type, parentMethod, lambdaOrdinals); - } - - private static ISyncDelegate[] TryRegisterLambdaDelegate(Type parentType, string parentMethod, params int[] lambdaOrdinals) - { - var registered = new List(); - foreach (var ord in lambdaOrdinals) - { - try - { - registered.AddRange(MpCompat.RegisterLambdaDelegate(parentType, parentMethod, ord)); - } - catch (Exception ex) - { - Log.Warning($"MPCompat :: Exosuit skipped lambda delegate for {parentType?.FullName}.{parentMethod} ordinal {ord}: {ex.Message}"); - } - } - - return registered.Count > 0 ? registered.ToArray() : null; - } - - private static void TryRegisterLambdaDelegate(string parentType, string parentMethod, params int[] lambdaOrdinals) - { - var type = AccessTools.TypeByName(parentType); - if (type != null) - TryRegisterLambdaDelegate(type, parentMethod, lambdaOrdinals); - } - - private static void TryRegisterSyncMethod(Type parentType, string methodName, params Type[] args) - { - var method = args.Length == 0 - ? AccessTools.Method(parentType, methodName) - : AccessTools.Method(parentType, methodName, args); - - if (method == null) - { - Log.Warning($"MPCompat :: Exosuit skipped missing sync method {parentType?.FullName}.{methodName}"); - return; - } - - MP.RegisterSyncMethod(method); + MpCompat.RegisterLambdaDelegate(flyerType, "GetOptionsForTile", 0, 1); } private static void RewriteExosuitSleepJob(ref Job newJob, Pawn ___pawn) diff --git a/Source/Mods/MoveWhileReloading.cs b/Source/Mods/MoveWhileReloading.cs deleted file mode 100644 index 59b4914e..00000000 --- a/Source/Mods/MoveWhileReloading.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System.Linq; -using HarmonyLib; -using Multiplayer.API; -using RimWorld; -using Verse; -using Verse.AI; - -namespace Multiplayer.Compat; - -/// CE - Move while reloading (Continue) by himawari -/// -[MpCompatFor("himawari.moveWhileReloading")] -public class MoveWhileReloadingCompat -{ - private static JobDef reloadWeaponJobDef; - - public MoveWhileReloadingCompat(ModContentPack mod) - { - LongEventHandler.ExecuteWhenFinished(LatePatch); - } - - private static void LatePatch() - { - try - { - reloadWeaponJobDef = GetCeReloadJobDef(); - EnsureRunAndGunBurstShotPatch(); - PatchPawnGotoForMultiplayer(); - Log.Message("MPCompat :: Initialized compatibility for himawari.moveWhileReloading"); - } - catch (System.Exception ex) - { - Log.Warning($"MPCompat :: Failed to finish Move While Reloading compat setup: {ex}"); - } - } - - /// - /// CEMoveReload only checks roolo.RunAndGun at static init; kotobike/memegoddess forks never get TryCastNextBurstShot patched. - /// - private static void EnsureRunAndGunBurstShotPatch() - { - if (!IsAnyRunAndGunActive()) - return; - - var verbMethod = AccessTools.Method(typeof(Verb), nameof(Verb.TryCastNextBurstShot)); - var patchesType = AccessTools.TypeByName("CEMoveReload.HarmonyPatches"); - var prefix = patchesType != null - ? AccessTools.Method(patchesType, "Prefix_TryCastNextBurstShot") - : null; - if (verbMethod == null || prefix == null) - return; - - var patchInfo = Harmony.GetPatchInfo(verbMethod); - if (patchInfo?.Prefixes?.Any(p => p.PatchMethod == prefix) == true) - return; - - MpCompat.harmony.Patch(verbMethod, prefix: new HarmonyMethod(prefix)); - } - - private static void PatchPawnGotoForMultiplayer() - { - var patchesType = AccessTools.TypeByName("CEMoveReload.HarmonyPatches"); - var ceGotoPrefix = patchesType != null - ? AccessTools.Method(patchesType, "Prefix_PawnGotoAction") - : null; - if (ceGotoPrefix == null) - return; - - MpCompat.harmony.Patch(ceGotoPrefix, - prefix: new HarmonyMethod(typeof(MoveWhileReloadingCompat), nameof(SkipCeGotoWhenNotReloading))); - } - - /// - /// Outside reload, CEMoveReload replaces FloatMenuMakerMap.PawnGotoAction with unsynced TryTakeOrderedJob calls. - /// Skip its prefix in MP so vanilla/MP job sync handles movement; keep reload-specific behavior intact. - /// - private static bool SkipCeGotoWhenNotReloading(Pawn pawn) - { - if (!MP.IsInMultiplayer) - return true; - - reloadWeaponJobDef ??= GetCeReloadJobDef(); - if (reloadWeaponJobDef != null && pawn.CurJobDef == reloadWeaponJobDef) - return true; - - return false; - } - - private static JobDef GetCeReloadJobDef() - { - var ceJobDefType = AccessTools.TypeByName("CombatExtended.CE_JobDefOf"); - if (ceJobDefType != null) - { - var field = AccessTools.Field(ceJobDefType, "ReloadWeapon"); - if (field != null) - return field.GetValue(null) as JobDef; - } - - return DefDatabase.GetNamedSilentFail("ReloadWeapon"); - } - - private static bool IsAnyRunAndGunActive() - { - foreach (var mod in LoadedModManager.RunningMods) - { - var id = mod.PackageId.NoModIdSuffix().ToLower(); - if (id is "roolo.runandgun" or "roolo.runandgun.kotobike" or "memegoddess.runandgun") - return true; - } - - return false; - } -} diff --git a/Source/Mods/RunandGun.cs b/Source/Mods/RunandGun.cs index 8f72de43..da3eff1b 100644 --- a/Source/Mods/RunandGun.cs +++ b/Source/Mods/RunandGun.cs @@ -1,12 +1,7 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; +using Verse; using HarmonyLib; using Multiplayer.API; -using RimWorld; -using UnityEngine; -using Verse; namespace Multiplayer.Compat { @@ -14,223 +9,14 @@ namespace Multiplayer.Compat /// /// /// - /// [MpCompatFor("roolo.RunAndGun")] - [MpCompatFor("roolo.RunAndGun.kotobike")] [MpCompatFor("memegoddess.RunAndGun")] class RunandGun { - private const string GizmoPatch = "RunAndGun.Harmony.Pawn_DraftController_GetGizmos_Patch"; - private const string RunAndGunIconPath = "UI/Buttons/enable_RG"; - - private static FieldInfo compIsEnabledField; - private static FieldInfo weaponForbidderField; - private static Texture2D runAndGunIcon; - public RunandGun(ModContentPack mod) { - RegisterToggleSync(); - PatchPawnGetGizmos(); - PatchMentalStateRand(); - } - - private static void PatchMentalStateRand() - { - var patchType = AccessTools.TypeByName("RunAndGun.Harmony.MentalStateHandler_TryStartMentalState"); - if (patchType == null) - return; - - MethodInfo target = null; - try - { - target = MpMethodUtil.GetLocalFunc(patchType, "Postfix", localFunc: "shouldRunAndGun"); - } - catch (Exception) - { - // Roslyn local-function name may differ between builds - } - - target ??= AccessTools.GetDeclaredMethods(patchType) - .FirstOrDefault(m => m.Name.Contains("shouldRunAndGun", StringComparison.Ordinal)); - - if (target != null) - PatchingUtilities.PatchSystemRand(target, false); - } - - /// Fallback gizmo only — no MP.WatchBegin here (runs every GUI frame and breaks combat sync). - private static void PatchPawnGetGizmos() - { - var getGizmos = AccessTools.Method(typeof(Pawn), nameof(Pawn.GetGizmos)); - MpCompat.harmony.Patch(getGizmos, - postfix: new HarmonyMethod(typeof(RunandGun), nameof(PawnGetGizmosPostfix))); - } - - /// - /// Sync delegate must include the captured CompRunAndGun ("data") or the host cannot apply OFF. - /// kotobike/memegoddess: toggle is <Postfix>b__1; roolo 1.4 uses lambda ordinal 2. - /// - private static void RegisterToggleSync() - { - var patchType = AccessTools.TypeByName(GizmoPatch); - if (patchType == null) - return; - - var compType = AccessTools.TypeByName("RunAndGun.CompRunAndGun"); - if (compType != null) - MP.RegisterSyncField(compType, "isEnabled"); - - MP.RegisterSyncMethod(typeof(RunandGun), nameof(SyncSetRunAndGunEnabled)); - - string[] closureFields = ["data"]; - var registered = false; - - try - { - MP.RegisterSyncDelegate(patchType, "<>c__DisplayClass0_0", "b__1", closureFields); - registered = true; - } - catch (Exception) - { - // display class name differs between builds - } - - foreach (var ord in new[] { 1, 2 }) - { - try - { - MpCompat.RegisterLambdaDelegate(GizmoPatch, "Postfix", closureFields, ord); - registered = true; - } - catch (Exception) - { - // try next ordinal - } - } - - if (!registered) - Log.Warning("MPCompat :: RunAndGun toggle lambda not found (tried direct delegate and ordinals 1, 2)"); - } - - public static void SyncSetRunAndGunEnabled(Pawn pawn, bool enabled) - { - if (pawn == null) - return; - - foreach (var comp in pawn.AllComps) - { - if (comp.GetType().Name != "CompRunAndGun") - continue; - - compIsEnabledField ??= AccessTools.Field(comp.GetType(), "isEnabled"); - compIsEnabledField?.SetValue(comp, enabled); - return; - } - } - - private static void PawnGetGizmosPostfix(Pawn __instance, ref IEnumerable __result) - { - if (!MP.IsInMultiplayer || __instance == null || !__instance.Drafted) - return; - - try - { - if (__instance.Faction != Faction.OfPlayer || !PawnHasRangedWeapon(__instance)) - return; - - var comp = GetRunAndGunComp(__instance); - if (comp == null || IsWeaponForbidden(__instance)) - return; - - var list = __result?.ToList() ?? new List(); - if (HasRunAndGunGizmo(list)) - { - __result = list; - return; - } - - runAndGunIcon ??= ContentFinder.Get(RunAndGunIconPath, true); - var isEnabled = GetRunAndGunEnabled(comp); - list.Add(new Command_Toggle - { - defaultLabel = "RG_Action_Enable_Label".Translate(), - defaultDesc = (isEnabled ? "RG_Action_Disable_Description" : "RG_Action_Enable_Description").Translate(), - icon = runAndGunIcon, - isActive = () => GetRunAndGunEnabled(comp), - toggleAction = () => SyncSetRunAndGunEnabled(__instance, !GetRunAndGunEnabled(comp)), - }); - __result = list; - } - catch (Exception ex) - { - Log.Warning($"MPCompat :: RunAndGun pawn GetGizmos compat skipped: {ex.Message}"); - } - } - - private static ThingComp GetRunAndGunComp(Pawn pawn) - { - foreach (var comp in pawn.AllComps) - { - if (comp.GetType().Name == "CompRunAndGun") - return comp; - } - - return null; - } - - private static bool GetRunAndGunEnabled(ThingComp comp) - { - compIsEnabledField ??= AccessTools.Field(comp.GetType(), "isEnabled"); - return compIsEnabledField != null && (bool)compIsEnabledField.GetValue(comp); - } - - private static bool HasRunAndGunGizmo(List gizmos) - { - runAndGunIcon ??= ContentFinder.Get(RunAndGunIconPath, true); - if (runAndGunIcon == null) - return false; - - var iconName = runAndGunIcon.name; - return gizmos.OfType().Any(g => g.icon != null && g.icon.name == iconName); - } - - private static bool PawnHasRangedWeapon(Pawn pawn) - { - var primary = pawn.equipment?.Primary; - return primary?.def?.IsRangedWeapon == true; - } - - private static bool IsWeaponForbidden(Pawn pawn) - { - try - { - if (pawn.equipment?.Primary == null) - return false; - - var baseType = AccessTools.TypeByName("RunAndGun.Base"); - if (baseType == null) - return false; - - weaponForbidderField ??= AccessTools.Field(baseType, "weaponForbidder"); - var forbidder = weaponForbidderField?.GetValue(null); - if (forbidder == null) - return false; - - var innerListField = AccessTools.Field(forbidder.GetType(), "InnerList"); - var innerList = innerListField?.GetValue(forbidder) as System.Collections.IDictionary; - if (innerList == null) - return false; - - if (!innerList.Contains(pawn.equipment.Primary.def.defName)) - return false; - - var record = innerList[pawn.equipment.Primary.def.defName]; - var isSelectedField = AccessTools.Field(record.GetType(), "isSelected"); - return isSelectedField != null && (bool)isSelectedField.GetValue(record); - } - catch - { - return false; - } + MpCompat.RegisterLambdaDelegate("RunAndGun.Harmony.Pawn_DraftController_GetGizmos_Patch", "Postfix", 2); + PatchingUtilities.PatchUnityRand("RunAndGun.Harmony.MentalStateHandler_TryStartMentalState:shouldRunAndGun", false); } } }