diff --git a/Abstracts/CustomAncientModel.cs b/Abstracts/CustomAncientModel.cs index e37c10f3..3c3bcfb7 100644 --- a/Abstracts/CustomAncientModel.cs +++ b/Abstracts/CustomAncientModel.cs @@ -23,7 +23,9 @@ public CustomAncientModel(bool autoAdd = true, bool logDialogueLoad = false) public virtual List<(string, string)>? Localization => null; /// - /// Suggested to check act.ActNumber == 2 or 3. + /// Suggested to check act.ActNumber == 1 or 2 or 3. + /// + /// If you want the Ancient to spawn in Act 1, additionally override IsAct1Ancient and return true. /// /// If you are overriding ShouldForceSpawn, you should override this and return false. /// @@ -39,6 +41,12 @@ public CustomAncientModel(bool autoAdd = true, bool logDialogueLoad = false) /// public virtual bool ShouldForceSpawn(ActModel act, AncientEventModel? rngChosenAncient) => false; + /// + /// Return true if this should be an Act 1 Ancient. + /// + /// + public virtual bool IsAct1Ancient() => false; + /// /// Set up a new OptionPools with 1, 2, or 3 pools using MakePool for each pool. /// If there is 1 pool, all ancient options will be chosen randomly from this pool. diff --git a/Patches/Content/ContentPatches.cs b/Patches/Content/ContentPatches.cs index 8dc8c0a5..34b8ec48 100644 --- a/Patches/Content/ContentPatches.cs +++ b/Patches/Content/ContentPatches.cs @@ -1,4 +1,6 @@ using System.Reflection; +using System.Reflection.Emit; +using System.Runtime.CompilerServices; using System.Text; using BaseLib.Abstracts; using BaseLib.Extensions; @@ -10,6 +12,7 @@ using MegaCrit.Sts2.Core.Modding; using MegaCrit.Sts2.Core.Models; using MegaCrit.Sts2.Core.Models.Acts; +using MegaCrit.Sts2.Core.Models.Events; using MegaCrit.Sts2.Core.Models.Relics; using MegaCrit.Sts2.Core.Random; using MegaCrit.Sts2.Core.Rooms; @@ -290,6 +293,227 @@ static void ForceAncientToSpawn(ActModel __instance) } } +[HarmonyPatch] +static class AddAct1AncientsToModelPool +{ + [HarmonyTargetMethods] + public static IEnumerable TargetMethods() + { + MethodInfo abstractMethod = AccessTools.DeclaredMethod( + typeof(ActModel), + nameof(ActModel.GetUnlockedAncients), + [typeof(UnlockState)]); + + return AccessTools.AllTypes() + .Where(type => + type != typeof(ActModel) && + typeof(ActModel).IsAssignableFrom(type)) + .Select(type => AccessTools.Method( + type, + nameof(ActModel.GetUnlockedAncients), + [typeof(UnlockState)])) + .Where(method => + method is not null && + !method.IsAbstract && + method.GetBaseDefinition() == abstractMethod) + .Distinct(); + } + + [HarmonyPostfix] + private static IEnumerable Postfix( + IEnumerable ancients, + ActModel __instance) + { + if (__instance.ActNumber() != 1) + return ancients; + + List result = ancients.ToList(); + + foreach (CustomAncientModel ancient in CustomContentDictionary.CustomAncients) + { + if (ancient.IsAct1Ancient() && ancient.IsValidForAct(__instance)) + { + result.Add(ancient); + } + } + + return result; + } +} + +/// +/// In base game the check for Neow is hardcoded. This patch is needed to properly set up the run. +/// +[HarmonyPatch] +public static class AddAct1Ancients_NeowChecks +{ + private static readonly MethodInfo IsNeowLikeMethod = + AccessTools.Method( + typeof(AddAct1Ancients_NeowChecks), + nameof(IsNeowLike)) + ?? throw new MissingMethodException( + nameof(AddAct1Ancients_NeowChecks), + nameof(IsNeowLike)); + + /// + /// BeforeEventStarted is async, so its actual body is inside the + /// compiler-generated IAsyncStateMachine.MoveNext method. + /// + [HarmonyTargetMethod] + private static MethodBase TargetMethod() + { + MethodInfo beforeEventStarted = + AccessTools.Method( + typeof(AncientEventModel), + "BeforeEventStarted", + [typeof(bool)]) + ?? throw new MissingMethodException( + typeof(AncientEventModel).FullName, + "BeforeEventStarted"); + + AsyncStateMachineAttribute stateMachineAttribute = + beforeEventStarted.GetCustomAttribute() + ?? throw new InvalidOperationException( + "BeforeEventStarted does not have an AsyncStateMachineAttribute."); + + return AccessTools.Method( + stateMachineAttribute.StateMachineType, + nameof(IAsyncStateMachine.MoveNext)) + ?? throw new MissingMethodException( + stateMachineAttribute.StateMachineType.FullName, + nameof(IAsyncStateMachine.MoveNext)); + } + + [HarmonyTranspiler] + private static IEnumerable Transpiler( + IEnumerable instructions) + { + int replacements = 0; + + foreach (CodeInstruction instruction in instructions) + { + if (instruction.opcode == OpCodes.Isinst && + instruction.operand is Type checkedType && + checkedType == typeof(Neow)) + { + /* + * Original: + * + * ancient + * isinst Neow + * + * Replacement: + * + * ancient + * dup + * isinst Neow + * call IsNeowLike + * + * Stack: + * + * ancient + * ancient, ancient + * ancient, originalResult + * extendedResult + * + * The original `isinst Neow` instruction remains unchanged, + * so transpilers running after this one can still find it. + */ + var duplicateInstruction = + new CodeInstruction(OpCodes.Dup); + + var extendResultInstruction = + new CodeInstruction( + OpCodes.Call, + IsNeowLikeMethod); + + /* + * A branch targeting the original isinst must execute the + * inserted dup first. + */ + instruction.MoveLabelsTo(duplicateInstruction); + + /* + * Keep the complete inserted sequence inside the same + * exception region: + * + * - beginning boundaries go on the first instruction; + * - ending boundaries go on the final instruction. + */ + foreach (ExceptionBlock block in instruction.ExtractBlocks()) + { + if (block.blockType == ExceptionBlockType.EndExceptionBlock) + { + extendResultInstruction.blocks.Add(block); + } + else + { + duplicateInstruction.blocks.Add(block); + } + } + + yield return duplicateInstruction; + yield return instruction; + yield return extendResultInstruction; + + replacements++; + continue; + } + + yield return instruction; + } + + if (replacements != 2) + { + throw new InvalidOperationException( + $"Expected to extend 2 Neow checks in " + + $"{nameof(AncientEventModel)}.BeforeEventStarted, " + + $"but extended {replacements}. The game method or another " + + $"transpiler may have changed."); + } + } + + /// + /// Extends the result of the original `isinst Neow` instruction. + /// A non-null original result is retained. This also composes with + /// another transpiler that replaces the preserved isinst with a + /// compatible object-to-object check. + /// + private static object? IsNeowLike( + object? ancient, + object? originalResult) + { + return originalResult + ?? (CustomContentDictionary.CustomAncients.Any(customAncient => + customAncient.IsAct1Ancient() + && customAncient.GetType().IsInstanceOfType(ancient)) + ? ancient + : null); + } +} + +/// +/// Ensures that Neow is always used as the Act 1 Ancient in Custom and Daily runs. +/// +/// Earlier patches cannot access the RunState, and their owner is always null, so this behavior must be applied by +/// patching RunManager.GenerateRooms. +/// +/// If the run has at least one modifier, this patch overrides the Act 1 Ancient with Neow. +/// +[HarmonyPatch(typeof(RunManager), nameof(RunManager.GenerateRooms))] +class AddAct1Ancients_AlwaysUseNeowInCustomRuns +{ + [HarmonyPostfix] + private static void Postfix(RunManager __instance) + { + if (__instance.State is { Modifiers.Count: > 0, Acts.Count: > 0 } && + __instance.State.Acts[0].Ancient is not Neow) + { + __instance.State.Acts[0]._rooms.Ancient = ModelDb.Event(); + } + } +} + [HarmonyPatch(typeof(ModelDb), nameof(ModelDb.AllSharedEvents), MethodType.Getter)] class CustomSharedEvents {