From 7faabdad6fb84e3d11f4fe36a69195446b793c55 Mon Sep 17 00:00:00 2001 From: SokyranTheDragon <36712560+SokyranTheDragon@users.noreply.github.com> Date: Fri, 30 Aug 2024 08:53:44 +0200 Subject: [PATCH] Sync VEF graphic customization and Vanilla Persona Weapons Expanded (#466) Updated graphic customization component from Vanilla Expanded Framework. This includes sync worker for the dialog and a couple of types used by it, syncing confirmation button, and closing the dialog/stopping synced methods if the dialog data is no longer valid. Vanilla Persona Weapons Expanded was also synced due to being based on graphic customization from VEF. It wasn't as straightforward as graphic customization was. The sync worker for the dialog reuses part of the code from VEF graphic customization one. It also required quite a bit more patches, for example to ensure the letter is not removed too early in MP, as well as some bug fixes for the mod itself (when selecting persona weapons without customization). --- Source/Mods/VanillaExpandedFramework.cs | 177 +++++++++++++++ Source/Mods/VanillaPersonaWeaponsExpanded.cs | 221 +++++++++++++++++++ 2 files changed, 398 insertions(+) create mode 100644 Source/Mods/VanillaPersonaWeaponsExpanded.cs diff --git a/Source/Mods/VanillaExpandedFramework.cs b/Source/Mods/VanillaExpandedFramework.cs index 8176f4c..185ac99 100644 --- a/Source/Mods/VanillaExpandedFramework.cs +++ b/Source/Mods/VanillaExpandedFramework.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Reflection; using System.Reflection.Emit; +using System.Runtime.Serialization; using HarmonyLib; using Multiplayer.API; using RimWorld; @@ -48,6 +49,7 @@ public VanillaExpandedFramework(ModContentPack mod) (PatchWorkGiverDeliverResources, "Building stuff requiring non-construction skill", false), (PatchExpandableProjectile, "Expandable projectile", false), (PatchStaticCaches, "Static caches", false), + (PatchGraphicCustomizationDialog, "Graphic Customization Dialog", true), ]; foreach (var (patchMethod, componentName, latePatch) in patches) @@ -1521,5 +1523,180 @@ private static bool NeverTimeoutDueToRealTime(ref bool __result, ref int ___Upda } #endregion + + #region Graphic Customization Dialog + + // Dialog_GraphicCustomization + private static Type graphicCustomizationDialogType; + internal static AccessTools.FieldRef graphicCustomizationCompField; + private static AccessTools.FieldRef graphicCustomizationGeneratedNamesCompField; + private static AccessTools.FieldRef graphicCustomizationPawnField; + private static AccessTools.FieldRef graphicCustomizationCurrentVariantsField; + private static AccessTools.FieldRef graphicCustomizationCurrentNameField; + + // TextureVariant + private static SyncType variantsListType; + private static AccessTools.FieldRef textureVariantTexNameField; + private static AccessTools.FieldRef textureVariantTextureField; + private static AccessTools.FieldRef textureVariantOutlineField; + private static AccessTools.FieldRef textureVariantTextureVariantOverrideField; + private static AccessTools.FieldRef textureVariantChanceOverrideField; + + // TextureVariantOverride + private static SyncType textureVariantOverrideType; + private static AccessTools.FieldRef textureVariantOverrideChanceField; + private static AccessTools.FieldRef textureVariantOverrideGroupNameField; + private static AccessTools.FieldRef textureVariantOverrideTexNameField; + + private static void PatchGraphicCustomizationDialog() + { + var type = graphicCustomizationDialogType = AccessTools.TypeByName("GraphicCustomization.Dialog_GraphicCustomization"); + graphicCustomizationCompField = AccessTools.FieldRefAccess(type, "comp"); + graphicCustomizationGeneratedNamesCompField = AccessTools.FieldRefAccess(type, "compGeneratedName"); + graphicCustomizationPawnField = AccessTools.FieldRefAccess(type, "pawn"); + graphicCustomizationCurrentVariantsField = AccessTools.FieldRefAccess(type, "currentVariants"); + graphicCustomizationCurrentNameField = AccessTools.FieldRefAccess(type, "currentName"); + // Accept customization + var method = MpMethodUtil.GetLambda(type, nameof(Window.DoWindowContents), 0); + MP.RegisterSyncMethod(method); + MpCompat.RegisterLambdaMethod(type, nameof(Window.DoWindowContents), 0); + MP.RegisterSyncWorker(SyncGraphicCustomizationDialog, type, true); + MpCompat.harmony.Patch(AccessTools.DeclaredMethod(type, "DrawConfirmButton"), + prefix: new HarmonyMethod(CloseGraphicCustomizationDialogOnAccept)); + MpCompat.harmony.Patch(AccessTools.DeclaredMethod(type, nameof(Window.DoWindowContents)), + prefix: new HarmonyMethod(CloseGraphicCustomizationWhenNoLongerValid)); + + type = AccessTools.TypeByName("GraphicCustomization.TextureVariant"); + variantsListType = typeof(List<>).MakeGenericType(type); + textureVariantTexNameField = AccessTools.FieldRefAccess(type, "texName"); + textureVariantTextureField = AccessTools.FieldRefAccess(type, "texture"); + textureVariantOutlineField = AccessTools.FieldRefAccess(type, "outline"); + textureVariantChanceOverrideField = AccessTools.FieldRefAccess(type, "chanceOverride"); + textureVariantTextureVariantOverrideField = AccessTools.FieldRefAccess(type, "textureVariantOverride"); + MP.RegisterSyncWorker(SyncTextureVariant, type, shouldConstruct: true); + + textureVariantOverrideType = type = AccessTools.TypeByName("GraphicCustomization.TextureVariantOverride"); + textureVariantOverrideChanceField = AccessTools.FieldRefAccess(type, "chance"); + textureVariantOverrideGroupNameField = AccessTools.FieldRefAccess(type, "groupName"); + textureVariantOverrideTexNameField = AccessTools.FieldRefAccess(type, "texName"); + MP.RegisterSyncWorker(SyncTextureVariantOverride, type, shouldConstruct: true); + } + + private static void CloseGraphicCustomizationDialogOnAccept(Window __instance, ref Action action) + { + // The action is a synced method, and it handles closing the dialog. Since + // the method ends up being synced the closing does not happen. This will + // ensure that the dialog is closed when the accept button is pressed + // and the proper method will be synced as well. + var nonRefAction = action; + action = (() => __instance.Close()) + nonRefAction; + } + + private static void CloseGraphicCustomizationWhenNoLongerValid(Window __instance, ThingComp ___comp, Pawn ___pawn) + { + // Since the dialog doesn't pause, ensure we close it if the comp's parent + // or the pawn ever get destroyed, despawned, or end up on different maps. + if (MP.IsInMultiplayer && !IsGraphicCustomizationDialogValid(___comp, ___pawn)) + __instance.Close(); + } + + private static bool CancelGraphicCustomizationExecutionIfNoLongerValid(ThingComp ___comp, Pawn ___pawn) + => !MP.IsInMultiplayer || IsGraphicCustomizationDialogValid(___comp, ___pawn); + + private static bool IsGraphicCustomizationDialogValid(ThingComp comp, Pawn pawn) + => comp.parent != null && pawn != null && + comp.parent.Spawned && pawn.Spawned && + !comp.parent.Destroyed && !pawn.Destroyed && + !pawn.Dead && comp.parent.Map == pawn.Map; + + private static void SyncGraphicCustomizationDialog(SyncWorker sync, ref Window dialog) + { + ThingComp comp = null; + + if (sync.isWriting) + { + sync.Write(graphicCustomizationCompField(dialog)); + } + else + { + // Skip constructor since it has a bunch of initialization we don't care about. + dialog = (Window)FormatterServices.GetUninitializedObject(graphicCustomizationDialogType); + + comp = sync.Read(); + graphicCustomizationCompField(dialog) = comp; + } + + SyncGraphicCustomizationDialog(sync, ref dialog, comp); + } + + internal static void SyncGraphicCustomizationDialog(SyncWorker sync, ref Window dialog, ThingComp graphicCustomizationComp) + { + if (sync.isWriting) + { + sync.Write(graphicCustomizationPawnField(dialog)); + sync.Write(graphicCustomizationCurrentVariantsField(dialog), variantsListType); + sync.Write(graphicCustomizationCurrentNameField(dialog)); + } + else + { + graphicCustomizationGeneratedNamesCompField(dialog) = graphicCustomizationComp?.parent.GetComp(); + graphicCustomizationPawnField(dialog) = sync.Read(); + graphicCustomizationCurrentVariantsField(dialog) = sync.Read(variantsListType); + graphicCustomizationCurrentNameField(dialog) = sync.Read(); + } + } + + private static void SyncTextureVariant(SyncWorker sync, ref object variant) + { + if (sync.isWriting) + { + if (variant == null) + { + sync.Write(false); + return; + } + + sync.Write(true); + + sync.Write(textureVariantTexNameField(variant)); + sync.Write(textureVariantTextureField(variant)); + sync.Write(textureVariantOutlineField(variant)); + sync.Write(textureVariantTextureVariantOverrideField(variant), textureVariantOverrideType); + sync.Write(textureVariantChanceOverrideField(variant)); + } + else if (sync.Read()) + { + textureVariantTexNameField(variant) = sync.Read(); + textureVariantTextureField(variant) = sync.Read(); + textureVariantOutlineField(variant) = sync.Read(); + textureVariantTextureVariantOverrideField(variant) = sync.Read(textureVariantOverrideType); + textureVariantChanceOverrideField(variant) = sync.Read(); + } + } + + private static void SyncTextureVariantOverride(SyncWorker sync, ref object variantOverride) + { + if (sync.isWriting) + { + if (variantOverride == null) + { + sync.Write(false); + return; + } + + sync.Write(true); + sync.Write(textureVariantOverrideChanceField(variantOverride)); + sync.Write(textureVariantOverrideGroupNameField(variantOverride)); + sync.Write(textureVariantOverrideTexNameField(variantOverride)); + } + else if (sync.Read()) + { + textureVariantOverrideChanceField(variantOverride) = sync.Read(); + textureVariantOverrideGroupNameField(variantOverride) = sync.Read(); + textureVariantOverrideTexNameField(variantOverride) = sync.Read(); + } + } + + #endregion } } \ No newline at end of file diff --git a/Source/Mods/VanillaPersonaWeaponsExpanded.cs b/Source/Mods/VanillaPersonaWeaponsExpanded.cs new file mode 100644 index 0000000..4aeb96a --- /dev/null +++ b/Source/Mods/VanillaPersonaWeaponsExpanded.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Emit; +using System.Runtime.Serialization; +using HarmonyLib; +using Multiplayer.API; +using RimWorld; +using Verse; + +namespace Multiplayer.Compat; + +/// Vanilla Persona Weapons Expanded by Oskar Potocki, Taranchuk +/// +/// +[MpCompatFor("VanillaExpanded.VPersonaWeaponsE")] +public class VanillaPersonaWeaponsExpanded +{ + #region Fields + + // CompGraphicCustomization + private static Type compGraphicCustomizationType; + // Dialog_ChoosePersonaWeapon + private static Type personaWeaponDialogType; + private static FastInvokeHandler sendWeaponMethod; + private static AccessTools.FieldRef weaponCustomizationCurrentWeaponField; + private static AccessTools.FieldRef weaponCustomizationCurrentWeaponTraitField; + private static AccessTools.FieldRef weaponCustomizationCurrentPsycastField; + private static AccessTools.FieldRef weaponCustomizationChoiceLetterField; + + #endregion + + #region Main Patch + + public VanillaPersonaWeaponsExpanded(ModContentPack mod) + { + LongEventHandler.ExecuteWhenFinished(LatePatch); + + compGraphicCustomizationType = AccessTools.TypeByName("GraphicCustomization.CompGraphicCustomization"); + } + + private static void LatePatch() + { + MpCompatPatchLoader.LoadPatch(); + + var type = personaWeaponDialogType = AccessTools.TypeByName("VanillaPersonaWeaponsExpanded.Dialog_ChoosePersonaWeapon"); + sendWeaponMethod = MethodInvoker.GetHandler(AccessTools.DeclaredMethod(type, "SendWeapon")); + weaponCustomizationCurrentWeaponField = AccessTools.FieldRefAccess(type, "currentWeapon"); + weaponCustomizationCurrentWeaponTraitField = AccessTools.FieldRefAccess(type, "currentWeaponTrait"); + weaponCustomizationCurrentPsycastField = AccessTools.FieldRefAccess(type, "currentPsycast"); + weaponCustomizationChoiceLetterField = AccessTools.FieldRefAccess(type, "choiceLetter"); + // Accept customization + MpCompat.RegisterLambdaMethod(type, nameof(Window.DoWindowContents), 3); + } + + #endregion + + #region Sync Workers + + [MpCompatSyncWorker("VanillaPersonaWeaponsExpanded.Dialog_ChoosePersonaWeapon")] + private static void SyncPersonaWeaponCustomizationDialog(SyncWorker sync, ref Window dialog) + { + ThingComp comp = null; + + if (sync.isWriting) + { + sync.Write(weaponCustomizationChoiceLetterField(dialog)); + + // The weapon was created on client-side, and this + // has client-side IDs. Send the def, so we'll re-make + // the weapon after syncing it. Let's just hope that + // creating the weapons client-side interface won't cause issues. + sync.Write(weaponCustomizationCurrentWeaponField(dialog).def); + sync.Write(weaponCustomizationCurrentWeaponTraitField(dialog)); + sync.Write(weaponCustomizationCurrentPsycastField(dialog)); + } + else + { + // Skip constructor since it has a bunch of initialization we don't care about. + dialog = (Window)FormatterServices.GetUninitializedObject(personaWeaponDialogType); + + var letter = sync.Read(); + weaponCustomizationChoiceLetterField(dialog) = letter; + + var def = sync.Read(); + + // Don't bother with making the weapon if the letter was archived, + // at this point the dialog doesn't matter at all. + if (letter is { ArchivedOnly: false }) + { + var weapon = ThingMaker.MakeThing(def, GenStuff.DefaultStuffFor(def)); + + weaponCustomizationCurrentWeaponField(dialog) = weapon; + if (weapon is ThingWithComps twc) + { + foreach (var c in twc.AllComps) + { + if (compGraphicCustomizationType.IsInstanceOfType(c)) + comp = VanillaExpandedFramework.graphicCustomizationCompField(dialog) = c; + } + } + } + + weaponCustomizationCurrentWeaponTraitField(dialog) = sync.Read(); + weaponCustomizationCurrentPsycastField(dialog) = sync.Read(); + } + + VanillaExpandedFramework.SyncGraphicCustomizationDialog(sync, ref dialog, comp); + } + + #endregion + + #region Dialog archived letter handling + + // Close the dialog if the letter is archived. + [MpCompatPrefix("VanillaPersonaWeaponsExpanded.Dialog_ChoosePersonaWeapon", nameof(Window.DoWindowContents))] + private static void CloseDialogIfLetterArchived(Window __instance, ChoiceLetter ___choiceLetter) + { + if (MP.IsInMultiplayer && (___choiceLetter == null || ___choiceLetter.ArchivedOnly)) + __instance.Close(); + } + + // Cancel if letter is archived or there's no map to deliver to. + [MpCompatPrefix("VanillaPersonaWeaponsExpanded.Dialog_ChoosePersonaWeapon", nameof(Window.DoWindowContents), 3)] + private static bool PreSyncAcceptPersonaWeapon(Window __instance, ChoiceLetter ___choiceLetter, Pawn ___pawn) + { + if (!MP.IsInMultiplayer) + return true; + // If the letter is archived, cancel execution. A weapon was selected, + // the letter was postponed, the letter expired (was forcibly postponed). + if (___choiceLetter is not { ArchivedOnly: false }) + return false; + // If the pawn doesn't have a map and there's no home maps, cancel execution. + // The call would fail due to no map to deliver to. + if ((___pawn.MapHeld ?? Find.AnyPlayerHomeMap) == null) + return false; + + // Cleanup the letter + if (MP.IsExecutingSyncCommand) + Find.LetterStack.RemoveLetter(___choiceLetter); + + return true; + } + + #endregion + + #region Letter removing patches + + // The postpone button removes the dialog for 7 days. This is a bit disruptive + // in MP if one of the players closes the letter while another one has the + // customization dialog open. This will cause postpone to just hide the letter. + [MpCompatPrefix("VanillaPersonaWeaponsExpanded.ChoiceLetter_ChoosePersonaWeapon", "Choices", 0, MethodType.Getter)] + private static bool DontRemoveLetterOnPostpone() => !MP.IsInMultiplayer; + + private static void DontRemoveLetterInMp(LetterStack letterStack, Letter letter) + { + if (!MP.IsInMultiplayer) + letterStack.RemoveLetter(letter); + } + + [MpCompatTranspiler("VanillaPersonaWeaponsExpanded.ChoiceLetter_ChoosePersonaWeapon", "OpenChooseDialog")] + private static IEnumerable ReplaceLetterRemoval(IEnumerable instr, MethodBase baseMethod) + { + var target = AccessTools.DeclaredMethod(typeof(LetterStack), nameof(LetterStack.RemoveLetter)); + var replacement = MpMethodUtil.MethodOf(DontRemoveLetterInMp); + var replacedCount = 0; + + foreach (var ci in instr) + { + if (ci.Calls(target)) + { + ci.opcode = OpCodes.Call; + ci.operand = replacement; + + replacedCount++; + } + + yield return ci; + } + + const int expected = 1; + if (replacedCount != expected) + { + var name = (baseMethod.DeclaringType?.Namespace).NullOrEmpty() ? baseMethod.Name : $"{baseMethod.DeclaringType!.Name}:{baseMethod.Name}"; + Log.Warning($"Patched incorrect number of Find.LetterStack.RemoveLetter calls (patched {replacedCount}, expected {expected}) for method {name}"); + } + } + + #endregion + + #region Sync selection if no customization options + + [MpCompatPrefix("VanillaPersonaWeaponsExpanded.ChoiceLetter_ChoosePersonaWeapon", "OpenChooseDialog")] + private static bool SyncSelectionIfNoCustomizationOptions(ChoiceLetter __instance, Pawn ___pawn, ThingDef weaponDef) + { + if (!MP.IsInMultiplayer) + return true; + // If the weapon has props whose type is a subclass of CompGraphicCustomization + // we let it run as normal, as it'll open up a cancelable dialog. + if (weaponDef.comps.Any(props => compGraphicCustomizationType.IsAssignableFrom(props.compClass))) + return true; + + // If the weapon is missing that comp it's selected + // immediately, so we need to properly sync that. + SyncedChooseNoCustomizationWeapon(__instance, ___pawn, weaponDef); + return false; + } + + [MpCompatSyncMethod] + private static void SyncedChooseNoCustomizationWeapon(ChoiceLetter letter, Pawn pawn, ThingDef weaponDef) + { + if (letter == null || letter.ArchivedOnly || pawn == null || (pawn.MapHeld ?? Find.AnyPlayerHomeMap) == null) + return; + + var weapon = ThingMaker.MakeThing(weaponDef, GenStuff.DefaultStuffFor(weaponDef)); + Find.LetterStack.RemoveLetter(letter); + sendWeaponMethod(null, pawn, weapon.TryGetComp(), weapon); + } + + #endregion +} \ No newline at end of file