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);