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
{