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