-
-
Notifications
You must be signed in to change notification settings - Fork 43
Fix Exosuit Framework multiplayer desyncs #597
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
1Anton10
wants to merge
9
commits into
rwmt:master
Choose a base branch
from
1Anton10:fix/exosuit-framework-mp-compat
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
cf84789
Fix Exosuit Framework multiplayer desyncs
1Anton10 a24ccff
Fix Exosuit compat init crash on load
1Anton10 766b2b5
Harden Exosuit compat startup against missing sync targets
1Anton10 8c7598f
Fix Exosuit compat thread safety and gizmo patching
1Anton10 4b2ecbc
Add CE/RunAndGun/MWR MP compat; fix Exosuit gear jobs via StartJob
1Anton10 20afe07
Fix Exosuit lambda sync, RunAndGun toggle, and MP reconnect after res…
1Anton10 fcf7dae
Remove MpCompat reconnect workaround now fixed upstream in Multiplayer
1Anton10 8e8446c
Delete MultiplayerReconnectFix.cs (moved to Multiplayer mod)
1Anton10 0e5063e
Address PR #597 review: narrow Exosuit compat scope
1Anton10 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
|
||
| /// <summary>Exosuit Framework by AobaKuma</summary> | ||
| /// <summary>Exosuit Framework (MechsuitFramework) by AobaKuma</summary> | ||
| /// <see href="https://steamcommunity.com/sharedfiles/filedetails/?id=3352894993"/> | ||
| /// <see href="https://github.com/AobaKuma/MechsuitFramework"/> | ||
| [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"); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Where did all this go? |
||
| // 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))); | ||
| } | ||
| } | ||
|
|
||
| /// <summary>Exosuit defers module damage via LongEventHandler — causes visible hit delay in MP.</summary> | ||
| 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<JobDef>.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; | ||
| } | ||
|
|
||
| /// <summary>Blocks exosuit patches that create jobs outside of MP's synced StartJob path.</summary> | ||
| 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<JobDef>.GetNamedSilentFail("WG_GetInWalkerCore"); | ||
| wgGetInNonDraftedJobDef ??= DefDatabase<JobDef>.GetNamedSilentFail("WG_GetInWalkerCore_NonDrafted"); | ||
| wgGetOffJobDef ??= DefDatabase<JobDef>.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}"); | ||
| } | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do not believe every gizmo should be synced. Double check this.