From 4d19b02ccd047ba5944b0fa1aff019f87596be62 Mon Sep 17 00:00:00 2001 From: SokyranTheDragon Date: Wed, 3 Jan 2024 19:37:12 +0100 Subject: [PATCH] Added compat for XVI-MECHFRAME One thing I didn't realize when starting the work on this compat was that (apparently) this mod has a bit of issues (with 1.4? or mod conflicts?)... I decided there's no point in deleting it once it was 90% done, and that I may as well finish it. However, be aware that there may some issues with the mod itself. There's 2 versions on workshop, the original (2844617885) and the unofficial 1.4 release (3108258542). The original is on 1.4 now as well, so that's the release I tested everything with. Although there shouldn't be much of a difference, I've only tested the official release so - use the unofficial one at own risk. The mod includes separate .dll file for both vanilla and Combat Extended. They seem very similar on the first glance and CE seemed to work fine, however - I've only tested the non-CE version. Use CE on your own risk. --- Source/Mods/XVIMechframe.cs | 543 ++++++++++++++++++++++++++++++++++++ Source/PatchingUtilities.cs | 37 ++- 2 files changed, 579 insertions(+), 1 deletion(-) create mode 100644 Source/Mods/XVIMechframe.cs diff --git a/Source/Mods/XVIMechframe.cs b/Source/Mods/XVIMechframe.cs new file mode 100644 index 0000000..9533d95 --- /dev/null +++ b/Source/Mods/XVIMechframe.cs @@ -0,0 +1,543 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Emit; +using HarmonyLib; +using Multiplayer.API; +using RimWorld; +using Verse; + +namespace Multiplayer.Compat +{ + /// XVI-MECHFRAME by 旋风(andery233xj), 青叶(AOBA),Bill Doors(3HST有限公司), more + /// + /// + [MpCompatFor("andery233xj.mod.MechanicalPoweredArmor")] + [MpCompatFor("glencoe2004.MechanicalPoweredArmor")] + public class XVIMechframe + { + #region Fields + + // AI combat + private static AccessTools.FieldRef aiTrackerParentField; + private static AccessTools.FieldRef mechAiTrackerField; + + // Skill system + // CompSkills + private static Type compSkillsType; + private static AccessTools.FieldRef compSkillSkillsList; + // SkillObject + private static AccessTools.FieldRef skillObjectParentCompField; + private static AccessTools.FieldRef skillObjectSkillField; + private static FastInvokeHandler skillVerbTrackerGetter; + // Skill + private static AccessTools.FieldRef skillIdField; + + // MP + private static AccessTools.FieldRef> prevSelectedField; + + #endregion + + public XVIMechframe(ModContentPack mod) + { + Type type; + + #region RNG + + { + PatchingUtilities.PatchPushPopRand(new[] + { + "MParmorLibrary.DamageWorker_ExplosionWithDirection_Ice:ExplosionAffectCell", + // Used by a def in an "Unused" directory... + // Def is most likely unused in the mod, but let's not risk it. + "MParmorLibrary.Unused.DamageWorker_ExplosionWithDirection:ExplosionAffectCell", + }); + } + + #endregion + + #region Gizmos + + { + // Dev charge + MpCompat.RegisterLambdaMethod("MParmorLibrary.Building_ChargingStation", nameof(Building.GetGizmos), 0).SetDebugOnly(); + + MP.RegisterSyncMethod(AccessTools.DeclaredMethod("MParmorLibrary.Building_FabricationPit:StopFabricating")); + + // Set charging power, called from Command_SetChargingPower + type = AccessTools.TypeByName("MParmorLibrary.IChargingEquipment"); + MP.RegisterSyncWorker(SyncIChargingEquipment, type, isImplicit: true); + // type.AllSubclasses doesn't work when used on an interface, so either + // manually go through each implementation and sync them, or implement + // something like `AllSubclasses` but for interfaces. The second one + // is too much work to only use once for 2 classes, soooo... Simple solution it is! + MP.RegisterSyncMethod(AccessTools.PropertySetter("MParmorLibrary.Building_ChargingStation:ChargingPower")); + MP.RegisterSyncMethod(AccessTools.PropertySetter("MParmorLibrary.Building_FabricationPit:ChargingPower")); + + // Dev charge battery 10000/1000/1 + MpCompat.RegisterLambdaMethod("MParmorLibrary.CompMParmorBuilding", "CompGetGizmosExtra", 0, 1, 2).SetDebugOnly(); + + // Attack (0)/defend (1) specific target + MpCompat.RegisterLambdaMethod("MParmorLibrary.Drone", nameof(Pawn.GetGizmos), 0, 2); + + // Health_Shiled [sic], no reference to the parent, + // will have to check selected object to find the parent. + type = AccessTools.TypeByName("MParmorLibrary.Health_Shiled"); + MP.RegisterSyncWorker(SyncHealthShield, type); + // Dev reset shield + MP.RegisterSyncMethodLambda(type, "GetGizmos", 0); + + // Getting out, called from gizmo + type = AccessTools.TypeByName("MParmorLibrary.MParmorCore"); + mechAiTrackerField = AccessTools.FieldRefAccess(type, "aiTracker"); + MP.RegisterSyncMethod(type, "GetOutOfMParmor"); + + // Exit and self destruct, called from gizmo + MP.RegisterSyncMethod(AccessTools.DeclaredMethod("MParmorLibrary.MParmorSelfDestruct:SelfDestruct")); + + // Toggle AI for suit + type = AccessTools.TypeByName("MParmorLibrary.MParmorT_AITracker"); + aiTrackerParentField = AccessTools.FieldRefAccess(type, "core"); + // Toggle active + MpCompat.RegisterLambdaMethod(type, "GetSwitchGizmo", 1); + MP.RegisterSyncWorker(SyncMechAITracker, type); + + // Change the type + var field = AccessTools.DeclaredField("Multiplayer.Client.AsyncTimeComp:prevSelected"); + if (field != null) + prevSelectedField = AccessTools.StaticFieldRefAccess>(field); + else + Log.Warning("Couldn't find field Multiplayer.Client.AsyncTimeComp:prevSelected, re-selection of descending wall on use won't work."); + + type = AccessTools.TypeByName("MParmorLibrary.Wall_DescendingWall"); + var method = MpMethodUtil.GetLambda(type, "Change", lambdaOrdinal: 0); + MP.RegisterSyncMethod(method); + + if (prevSelectedField != null) + { + MpCompat.harmony.Patch(method, + transpiler: new HarmonyMethod(typeof(XVIMechframe), nameof(DescendingWallInterceptNewWall))); + } + + // Toggle self repair + MpCompat.RegisterLambdaMethod("MParmorLibrary.Wall_SelfRepairing", nameof(Building.GetGizmos), 1); + + // Skills + compSkillSkillsList = AccessTools.FieldRefAccess("MParmorLibrary.SkillSystem.CompSkills:skills"); + + type = AccessTools.TypeByName("MParmorLibrary.SkillSystem.SkillObject"); + skillObjectParentCompField = AccessTools.FieldRefAccess(type, "parent"); + skillObjectSkillField = AccessTools.FieldRefAccess(type, "skill"); + skillVerbTrackerGetter = MethodInvoker.GetHandler(AccessTools.DeclaredPropertyGetter(type, "VerbTracker")); + MP.RegisterSyncWorker(SyncSkillObject, type); + MP.RegisterSyncMethodLambda(type, "GetGizmoPetSkill", 0); + } + + #endregion + + #region Dynamic Defs + + { + // MP syncs defs by using their short hash. + // Defs generated by this mod are missing the short hash, + // thus causing MP to fail. Give them hashes to fix this. + // The defs aren't given a short hash by RimWorld due to + // the mod creating those defs in [StaticConstructorOnStartup], + // which happens to run way after hashes are given. + + type = AccessTools.TypeByName("MParmorLibrary.RecipeGenerator"); + MpCompat.harmony.Patch(AccessTools.DeclaredMethod(type, "GenerateImpliedDefs_PreResolve"), + postfix: new HarmonyMethod(typeof(XVIMechframe), nameof(ReinitializeRecipeDefShortHashDictionary))); + MpCompat.harmony.Patch(AccessTools.DeclaredMethod(type, "DefsFromRecipeMakers"), + postfix: new HarmonyMethod(typeof(XVIMechframe), nameof(GiveHashesToDefs))); + } + + #endregion + + #region Caches + + // The mod uses quite a lot of static caches. Thankfully, it + // seems that they should be MP safe as they operate on them + // smartly (add on spawn, remove on despawn). Not only that, + // they also clear most of their caches on ExposeData + // (specifically, in post load init). + + // Bugfix: ShieldsBarrierCircle adds istelf to cache both + // on spawn and despawn, but never removes itself. + // Reporting it doesn't look like it'll achieve much, as + // the mod seems to not really receive much support at the + // moment. On top of that, it's closed source, so even if + // we wanted to - we can't really contribute ourselves. + // Probably the simplest way to fix this universally would + // be to release a separate bugfix mod that would tackle + // issues with the mod itself. + MpCompat.harmony.Patch(AccessTools.DeclaredMethod("MParmorLibrary.ShieldsBarrierCircle:DeSpawn"), + transpiler: new HarmonyMethod(typeof(XVIMechframe), nameof(FixCacheBug))); + + #endregion + + #region Verb Owner Syncing + + { + // Insert VehicleHandler as supported thing holder for syncing. + // The mod uses VehicleHandler as IThingHolder and ends up being synced. + // We should add support for adding more supported thing holders soon... I think the PokéWorld mod would benefit from it as well. + const string supportedVerbOwnersFieldPath = "Multiplayer.Client.RwImplSerialization:supportedVerbOwnerTypes"; + var supportedThingHoldersField = AccessTools.DeclaredField(supportedVerbOwnersFieldPath); + var targetVerbOwnerType = AccessTools.TypeByName("MParmorLibrary.SkillSystem.SkillObject"); + if (supportedThingHoldersField == null) + Log.Error($"Trying to access {supportedVerbOwnersFieldPath} failed, field is null."); + else if (!supportedThingHoldersField.IsStatic) + Log.Error($"Trying to access {supportedVerbOwnersFieldPath} failed, field is non-static."); + else if (supportedThingHoldersField.GetValue(null) is not Type[] array) + Log.Error($"Trying to access {supportedVerbOwnersFieldPath} failed, the value is null or not Type[]. Value={supportedThingHoldersField.GetValue(null)}"); + else if (targetVerbOwnerType == null) + Log.Error($"Trying to insert into {supportedVerbOwnersFieldPath} failed, target type is null."); + else + { + // Increase size by 1 + Array.Resize(ref array, array.Length + 1); + // Fill the last element + array[array.Length - 1] = targetVerbOwnerType; + // Set the original field to the value we set up + supportedThingHoldersField.SetValue(null, array); + } + } + + #endregion + + #region Shared Cross Refs + + { + skillIdField = AccessTools.FieldRefAccess("MParmorLibrary.SkillSystem.Skill:id"); + + type = compSkillsType = AccessTools.TypeByName("MParmorLibrary.SkillSystem.CompSkills"); + PatchingUtilities.InitializeSharedCrossRefs(); + + MpCompat.harmony.Patch(AccessTools.DeclaredMethod(type, "ResetAllSkills"), + prefix: new HarmonyMethod(typeof(XVIMechframe), nameof(PreResetAllSkills)), + postfix: new HarmonyMethod(typeof(XVIMechframe), nameof(PostResetAllSkills))); + MpCompat.harmony.Patch(AccessTools.DeclaredMethod(type, "PostExposeData"), + postfix: new HarmonyMethod(typeof(XVIMechframe), nameof(PostCompSkillExposeData))); + MpCompat.harmony.Patch(AccessTools.DeclaredMethod("MParmorLibrary.MParmorCore:GetOutOfMParmorForce"), + postfix: new HarmonyMethod(typeof(XVIMechframe), nameof(PostGetOutOfArmor))); + MpCompat.harmony.Patch(AccessTools.DeclaredMethod("MParmorLibrary.MParmorCore:SelfDestruct"), + postfix: new HarmonyMethod(typeof(XVIMechframe), nameof(PostGetOutOfArmor))); + + // Make verb ID truly unique + MpCompat.harmony.Patch(AccessTools.DeclaredMethod("MParmorLibrary.SkillSystem.SkillObject:UniqueVerbOwnerID"), + prefix: new HarmonyMethod(typeof(XVIMechframe), nameof(PreSkillObjectUniqueVerbOwnerID))); + } + + #endregion + } + + #region Sync workers + + private static void SyncIChargingEquipment(SyncWorker sync, ref object equipment) + { + if (sync.isWriting) + { + // Should only ever be a subtype of Thing, likely it should never be null + if (equipment is Thing t) + { + sync.Write(t); + } + else + { + if (equipment != null) + Log.Error($"Trying to sync IChargingEquipment, but it's not of type (or a subtype of) Thing. Actual type={equipment.GetType()}, object={equipment}"); + sync.Write(null); + } + } + else + { + equipment = sync.Read(); + } + } + + private static void SyncHealthShield(SyncWorker sync, ref object shield) + { + // Normally I'd use FieldRef, but there's multiple types + // that could be handled here, each has the same name + // for the field... Plus, it's only for dev mode gizmo + // anyway, so not bothering optimizing this sync worker. + static object TryGetShield(Thing t) => Traverse.Create(t).Field("shieldClass").GetValue(); + + if (sync.isWriting) + { + if (shield == null) + { + sync.Write(null); + } + else + { + foreach (var selected in Find.Selector.SelectedObjects) + { + if (selected is Thing thing && shield == TryGetShield(thing)) + { + sync.Write(thing); + break; + } + } + } + } + else + { + var parent = sync.Read(); + if (parent != null) + shield = TryGetShield(parent); + } + } + + private static void SyncMechAITracker(SyncWorker sync, ref object aiTracker) + { + if (sync.isWriting) + sync.Write(aiTrackerParentField(aiTracker)); + else + aiTracker = mechAiTrackerField(sync.Read()); + } + + private static void SyncSkillObject(SyncWorker sync, ref object skillObject) + { + if (sync.isWriting) + { + if (skillObject == null) + { + sync.Write(byte.MaxValue); + return; + } + + var comp = skillObjectParentCompField(skillObject); + if (comp == null) + { + Log.Error($"Trying to sync SkillObject with no parent comp, target={skillObject}"); + sync.Write(byte.MaxValue); + return; + } + + var index = compSkillSkillsList(comp).IndexOf(skillObject); + if (index < 0) + { + Log.Error($"Trying to sync SkillObject but the parent comp doesn't contain it, target={skillObject}, comp={comp}"); + sync.Write(byte.MaxValue); + return; + } + + // Sync the index as a byte, as there's always less than 10 abilities in the parent + sync.Write((byte)index); + sync.Write(comp); + } + else + { + var index = sync.Read(); + if (index != byte.MaxValue) + skillObject = compSkillSkillsList(sync.Read())[index]; + } + } + + #endregion + + #region Dynamic Def + + private static IEnumerable GiveHashesToDefs(IEnumerable defs) + { + if (!ShortHashGiver.takenHashesPerDeftype.TryGetValue(typeof(RecipeDef), out var usedHashes)) + { + usedHashes = new HashSet(); + ShortHashGiver.takenHashesPerDeftype.Add(typeof(RecipeDef), usedHashes); + } + + foreach (var def in defs) + { + if (def.shortHash != default) + { + // This shouldn't ever happen unless the mod actually decides to fix it. + Log.Warning($"Trying to give a new {nameof(Def.shortHash)} to {def.defName}, but it already has one: {def.shortHash}"); + } + else + { + // Second parameter is unused by the method. + // May as well provide it just in case. + ShortHashGiver.GiveShortHash(def, typeof(RecipeDef), usedHashes); + } + + // Since it's a pass through postfix, we need to yield return the values. + yield return def; + } + + // Can't reinitialize the dictionary here, as the defs + // haven't been added to database yet. + } + + private static void ReinitializeRecipeDefShortHashDictionary() + => DefDatabase.InitializeShortHashDictionary(); + + #endregion + + #region Selector + + private static void InterceptNewWall(Building oldWall, Building newWall) + { + if (!MP.IsInMultiplayer) + return; + + // Find.Selector will return an empty list of selected objects + // due to us being in a synced method, same with Pre/Post invoke. + // Likewise, if we select something, that will end up being discarded. + // As a workaround, we directly check and modify original selector list + // that MP stores in a separate field. + // + // An alternative approach to this would be to use some other workaround, + // like checking all selected objects each tick or storing a reference to + // the true selector here that MP won't be able to modify. + var prevSelector = prevSelectedField(); + if (prevSelector != null && prevSelector.Contains(oldWall)) + prevSelector.Add(newWall); // The old wall will be auto-deselected + } + + private static IEnumerable DescendingWallInterceptNewWall(IEnumerable instr, MethodBase baseMethod) + { + var patchedAnything = false; + + foreach (var ci in instr) + { + yield return ci; + + // There should ever be a single Stloc_2 call + if (!patchedAnything && ci.opcode == OpCodes.Stloc_2) + { + patchedAnything = true; + + // Push `this` to the top of the stack + yield return new CodeInstruction(OpCodes.Ldarg_0); + // Push the new `Wall_DescendingWall` object to the top of the stack + yield return new CodeInstruction(OpCodes.Ldloc_2); + // Call our method to get the newly created wall + yield return CodeInstruction.Call(typeof(XVIMechframe), nameof(InterceptNewWall)); + } + } + + if (!patchedAnything) + { + var name = (baseMethod.DeclaringType?.Namespace).NullOrEmpty() ? baseMethod.Name : $"{baseMethod.DeclaringType!.Name}:{baseMethod.Name}"; + Log.Warning($"Failed to intercept assignment to Wall_DescendingWall for method {name}"); + } + } + + #endregion + + #region Shared Cross Refs + + private static bool PreSkillObjectUniqueVerbOwnerID(object __instance, ThingComp ___parent, ref string __result) + { + // SkillObject uses "MParmorSkillSystem_" + `Thing.ThingID` as its ID. + // Due to each comp having multiple skills, this results in multiple + // verbs sharing the same ID, causing issues with MP syncing. + + // Use the ID that the skills are assigned for the extra part in the ID. + var skillId = skillIdField(skillObjectSkillField(__instance)); + __result = $"MParmorSkillSystem_{skillId}_{___parent.parent.ThingID}"; + return false; + } + + private static void PreResetAllSkills(IList ___skills) + { + // Check for inactive scribe, method could be called from PostExposeData + if (MP.IsInMultiplayer && Scribe.mode == LoadSaveMode.Inactive) + UnregisterCrossRefs(___skills); // The method clears the skill list + } + + private static void PostResetAllSkills(IList ___skills) + { + // Check for inactive scribe, method could be called from PostExposeData + if (MP.IsInMultiplayer && Scribe.mode == LoadSaveMode.Inactive) + RegisterCrossRefs(___skills); // And recreates it afterwards + } + + private static void PostCompSkillExposeData(IList ___skills) + { + if (MP.IsInMultiplayer && Scribe.mode == LoadSaveMode.PostLoadInit) + RegisterCrossRefs(___skills); + } + + private static void PostGetOutOfArmor(Apparel __instance) + { + if (!MP.IsInMultiplayer || !MP.IsExecutingSyncCommand) + return; + + foreach (var comp in __instance.AllComps) + { + if (compSkillsType.IsInstanceOfType(comp)) + UnregisterCrossRefs(compSkillSkillsList(comp)); + } + } + + private static void RegisterCrossRefs(IList skills) + { + foreach (var skill in skills) + { + // We could technically (un)register the skill object here + // as well. However, it would end up being completely pointless. + if (skillVerbTrackerGetter(skill) is VerbTracker verbs) + { + foreach (var verb in verbs.AllVerbs) + PatchingUtilities.RegisterSharedCrossRef(verb); + } + } + } + + private static void UnregisterCrossRefs(IList skills) + { + foreach (var skill in skills) + { + // We could technically (un)register the skill object here + // as well. However, it would end up being completely pointless. + if (skillVerbTrackerGetter(skill) is VerbTracker verbs) + { + foreach (var verb in verbs.AllVerbs) + PatchingUtilities.UnregisterSharedCrossRef(verb); + } + } + } + + #endregion + + #region Bugfix + + private static IEnumerable FixCacheBug(IEnumerable instr, MethodBase baseMethod) + { + var type = AccessTools.TypeByName("MParmorLibrary.Intercepts"); + var target = AccessTools.DeclaredMethod(type, "AddNewInstance"); + var replacement = AccessTools.DeclaredMethod(type, "RemoveInstance"); + var safePatch = target != null && replacement != null; + var replacedCount = 0; + + foreach (var ci in instr) + { + if (safePatch && ci.opcode == OpCodes.Call && ci.operand as MethodInfo == target) + { + ci.operand = replacement; + replacedCount++; + } + + yield return ci; + } + + const int expected = 1; + if (!safePatch) + { + var name = (baseMethod.DeclaringType?.Namespace).NullOrEmpty() ? baseMethod.Name : $"{baseMethod.DeclaringType!.Name}:{baseMethod.Name}"; + Log.Warning($"Failed to replace Intercepts.AddNewInstance calls to Intercepts.RemoveInstance (Add null={target == null}, Remove null={replacement == null}) for method {name}"); + } + else if (replacedCount != expected) + { + var name = (baseMethod.DeclaringType?.Namespace).NullOrEmpty() ? baseMethod.Name : $"{baseMethod.DeclaringType!.Name}:{baseMethod.Name}"; + Log.Warning($"Replaced incorrect number of Intercepts.AddNewInstance calls to Intercepts.RemoveInstance (is it still needed?) (replaced {replacedCount}, expected {expected}) for method {name}"); + } + } + + #endregion + } +} \ No newline at end of file diff --git a/Source/PatchingUtilities.cs b/Source/PatchingUtilities.cs index c79ed73..e31cb8e 100644 --- a/Source/PatchingUtilities.cs +++ b/Source/PatchingUtilities.cs @@ -645,7 +645,7 @@ private static CodeInstruction GetLdargForIndex(MethodBase method, int index) } #endregion - + #region TryGainMemory early thought init public delegate bool TryHandleGainMemory(Thought_Memory thought); @@ -693,5 +693,40 @@ private static void PostTryGainMemory(Thought_Memory newThought) } #endregion + + #region Shared Cross Refs + + private static bool initializedSharedCrossRefs = false; + // Get SharedCrossRefs, an MP subtype of LoadedObjectDirectory + private static FastInvokeHandler sharedCrossRefsGetter; + // Unregister method added in the MP subtype + private static FastInvokeHandler sharedCrossRefsUnregisterMethod; + + public static void InitializeSharedCrossRefs() + { + if (initializedSharedCrossRefs) + return; + + initializedSharedCrossRefs = true; + + sharedCrossRefsGetter = MethodInvoker.GetHandler( + AccessTools.DeclaredPropertyGetter("Multiplayer.Client.ScribeUtil:sharedCrossRefs")); + sharedCrossRefsUnregisterMethod = MethodInvoker.GetHandler( + AccessTools.DeclaredMethod("Multiplayer.Client.SharedCrossRefs:Unregister")); + } + + public static void RegisterSharedCrossRef(ILoadReferenceable reffable) + { + (sharedCrossRefsGetter(null) as LoadedObjectDirectory)?.RegisterLoaded(reffable); + } + + public static void UnregisterSharedCrossRef(ILoadReferenceable reffable) + { + var crossRefs = sharedCrossRefsGetter(null); + if (crossRefs != null) + sharedCrossRefsUnregisterMethod(crossRefs, reffable); + } + + #endregion } } \ No newline at end of file