diff --git a/README.md b/README.md
index 9c8dcf8..15a72aa 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 87af6eb..3500176 100644
--- a/Source/Mods/ExosuitFramework.cs
+++ b/Source/Mods/ExosuitFramework.cs
@@ -1,67 +1,348 @@
-using System.Linq;
+using System;
+using System.Collections.Generic;
+using System.Reflection;
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 JobDef wgGetInJobDef;
+ private static JobDef wgGetInNonDraftedJobDef;
+ private static JobDef wgGetOffJobDef;
+ private static Type exosuitCoreType;
+ private static MethodInfo getClosestCoreForPawn;
+ private static MethodInfo getClosestBay;
+ private static FastInvokeHandler applyDamageToModules;
+
public ExosuitFramework(ModContentPack mod)
{
LongEventHandler.ExecuteWhenFinished(LatePatch);
+ }
- #region Gizmos
+ private static void LatePatch()
+ {
+ try
+ {
+ PatchGizmos();
+ PatchFloatMenus();
+ PatchJobs();
+ PatchTurrets();
+ PatchCatapult();
+ PatchExosuitDamage();
+ }
+ catch (Exception ex)
+ {
+ Log.Warning($"MPCompat :: Failed to finish Exosuit Framework compat setup: {ex}");
+ }
+ }
+
+ private static void PatchGizmos()
+ {
+ // 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)
{
- // 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);
+ MpCompat.RegisterLambdaMethod(ejectorType, nameof(Building.GetGizmos), 0, 1)
+ .SetContext(SyncContext.CurrentMap);
+
+ // Find.CurrentMap lives inside compiler-generated closures, not in GetGizmos itself.
+ PatchEjectorBayLambdasCurrentMap(ejectorType);
}
- #endregion
+ // 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);
- #region Float Menus
+ // 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)
+ {
+ 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");
+ if (maintenanceBayType != null)
{
- 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);
+ // Add/replace/remove modules, called from ITab_MechGear.
+ MP.RegisterSyncMethod(maintenanceBayType, "AddOrReplaceModule");
+ MP.RegisterSyncMethod(maintenanceBayType, "RemoveModules");
+ MpCompat.RegisterLambdaDelegate(maintenanceBayType, nameof(Building.GetFloatMenuOptions), 0);
}
- #endregion
+ var ejectorType = AccessTools.TypeByName("Exosuit.Building_EjectorBay");
+ if (ejectorType != null)
+ MpCompat.RegisterLambdaDelegate(ejectorType, nameof(Building.GetFloatMenuOptions), 0);
- // 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.
+ RegisterFloatMenuProviderLambdas();
}
- private static void LatePatch()
+ 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 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(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)));
+
+ // 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()
+ {
+ var coreType = AccessTools.TypeByName("Exosuit.Exosuit_Core");
+ if (coreType == null)
+ return;
+
+ var healthField = AccessTools.Field(coreType, "healthInt");
+ 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)
+ {
+ 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;
+
+ if (applyDamageToModules == null)
+ return true;
+
+ applyDamageToModules(__instance, amount);
+ return false;
+ }
+
+ private static void PatchTurrets()
+ {
+ var turretType = AccessTools.TypeByName("Mechsuit.CompTurretGun");
+ if (turretType == null)
+ return;
+
+ // ITab/gizmo actions are already covered by Exosuit's MP.RegisterAll(); only sync explicit API entry points.
+ 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)
+ MpCompat.RegisterLambdaDelegate(flyerType, "GetOptionsForTile", 0, 1);
+ }
+
+ private static void RewriteExosuitSleepJob(ref Job newJob, Pawn ___pawn)
{
- #region Gizmos
+ 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)
{
- // 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"));
+ if (exosuitCoreType.IsInstanceOfType(apparel))
+ return true;
}
- #endregion
+ return false;
+ }
+
+ /// Blocks exosuit patches that create jobs outside of MP's synced StartJob path.
+ 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, wgGetInNonDraftedJobDef, 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 EnsureExosuitGizmoJobDefs()
+ {
+ 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)
+ 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()
+ {
+ 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;
+
+ try
+ {
+ foreach (var ord in new[] { 0, 1 })
+ {
+ MpCompat.RegisterLambdaDelegateInternal(providerType, pawnMethod.Name, MethodType.Normal, null, ord,
+ [typeof(Pawn), floatMenuContextType]);
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Warning($"MPCompat :: Exosuit skipped float menu provider sync: {ex.Message}");
+ }
}
-}
\ No newline at end of file
+}
diff --git a/Source/MpCompat.cs b/Source/MpCompat.cs
index b47a3c8..747f196 100644
--- a/Source/MpCompat.cs
+++ b/Source/MpCompat.cs
@@ -58,10 +58,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 d5cae69..675e9c4 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 20d1921..e6e97b4 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);