using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Threading.Tasks; using HarmonyLib; using MegaCrit.Sts2.Core.Commands; using MegaCrit.Sts2.Core.Entities.Creatures; using MegaCrit.Sts2.Core.Entities.Players; using MegaCrit.Sts2.Core.GameActions.Multiplayer; using MegaCrit.Sts2.Core.Localization; using MegaCrit.Sts2.Core.Nodes.CommonUi; using MegaCrit.Sts2.Core.Logging; using MegaCrit.Sts2.Core.Models; namespace StS2Sim; /// /// We run outside the Godot engine, so any code path that hits the Godot /// native interop (Godot.OS, ResourceLoader, etc.) crashes with 0xC0000005. /// We Harmony-patch the offending entry points to be no-ops so the game's /// pure-logic types can be used without a SceneTree. /// /// Each patch should be the smallest possible "lie": say the equivalent of /// "we're not in the editor / there's no command line / no resource available" /// so behavior elsewhere stays sane. /// internal static class GodotShims { public static void Apply() { var harmony = new Harmony("StS2Sim.GodotShims"); // Direct typeof() reference is safe: it returns Type metadata without // triggering the type's static constructor. PatchPrefix(harmony, typeof(Logger), "GetIsRunningFromGodotEditor", nameof(IsRunningFromGodotEditor_Prefix)); // Game logs go through Godot.GD.Print which P/Invokes — redirect to .NET console. PatchPrefix(harmony, typeof(ConsoleLogPrinter), "Print", nameof(ConsoleLogPrinter_Print_Prefix)); // Godot.Time.GetTicksMsec() goes through native interop. Game uses it for // animation duration math which collapses to zero in NonInteractiveMode anyway. PatchPrefix(harmony, typeof(Godot.Time), "GetTicksMsec", nameof(Time_GetTicksMsec_Prefix)); // CardCmd.Preview is called by cards like Hidden Gem to highlight the card // they buffed. Capture the previewed card as the "subject" of the last event // so the UI can display "Hidden Gem → Shiv+". harmony.Patch( AccessTools.Method(typeof(CardCmd), "Preview", new[] { typeof(CardModel), typeof(float), typeof(CardPreviewStyle) }), prefix: new HarmonyMethod(GetPrefix(nameof(CardCmd_Preview_Prefix)))); // CardCmd.AutoPlay fires for any auto-played card (Hellraiser strikes, // Havoc top-of-deck plays, etc.). The policy loop only sees plays it // chose itself, so without this autoplays would go unrecorded in the // turn log even though they deal real damage. PatchPrefix(harmony, typeof(CardCmd), "AutoPlay", nameof(CardCmd_AutoPlay_Prefix)); // Hook.AfterCardDrawn fires once per drawn card from CardPileCmd.Draw. // Patching it as a prefix means we record the draw BEFORE Hellraiser- // style autoplay fires, giving us a chronological event timeline: // "drew Pommel Strike" → "played Pommel Strike (auto)" → "drew Strike". PatchPrefix(harmony, typeof(MegaCrit.Sts2.Core.Hooks.Hook), "AfterCardDrawn", nameof(Hook_AfterCardDrawn_Prefix)); // LocManager.Instance is null in headless mode (never initialized without Godot). // Cards like Nightmare call SelectionScreenPrompt which calls LocString.Exists(), // and some paths call GetFormattedText/GetRawText for display strings. // Return safe fallbacks so cards with selection prompts can still be played. harmony.Patch( AccessTools.Method(typeof(LocString), "Exists", new[] { typeof(string), typeof(string) }), prefix: new HarmonyMethod(GetPrefix(nameof(LocString_Exists_Prefix)))); harmony.Patch( AccessTools.Method(typeof(LocString), "GetFormattedText"), prefix: new HarmonyMethod(GetPrefix(nameof(LocString_GetText_Prefix)))); harmony.Patch( AccessTools.Method(typeof(LocString), "GetRawText"), prefix: new HarmonyMethod(GetPrefix(nameof(LocString_GetText_Prefix)))); // CardPileCmd.Shuffle calls Engine.GetMainLoop() for animation pacing // (sleeps between adding cards back to draw pile). We replace the whole // method with a synchronous shuffle that does the same logical work // without ever touching SceneTree. PatchPrefix(harmony, typeof(CardPileCmd), "Shuffle", nameof(CardPileCmd_Shuffle_Prefix)); } /// /// Localization isn't initialized in headless mode, so Creature.ToString() (which /// hits LocString.GetFormattedText()) throws. Patch it to return a safe identifier. /// Applied after ModelDb.Init so it can target the resolved type. /// public static void ApplyLocalizationShim() { var harmony = new Harmony("StS2Sim.LocShim"); harmony.Patch( AccessTools.Method(typeof(Creature), nameof(object.ToString)), prefix: new HarmonyMethod(GetPrefix(nameof(Creature_ToString_Prefix)))); } private static void PatchPrefix(Harmony harmony, Type? type, string methodName, string prefixName) { if (type == null) throw new InvalidOperationException("Type not found for patch: " + methodName); var target = AccessTools.Method(type, methodName); if (target == null) throw new InvalidOperationException($"Method {type.FullName}.{methodName} not found for patch"); harmony.Patch(target, prefix: new HarmonyMethod(GetPrefix(prefixName))); } private static MethodInfo GetPrefix(string name) => typeof(GodotShims).GetMethod(name, BindingFlags.Static | BindingFlags.NonPublic) ?? throw new InvalidOperationException($"Prefix method {name} not found"); // ─── Prefixes ────────────────────────────────────────────────────────── private static bool IsRunningFromGodotEditor_Prefix(ref bool __result) { __result = false; return false; // false = skip original } private static bool ConsoleLogPrinter_Print_Prefix(LogLevel logLevel, string text) { Console.WriteLine($"[{logLevel.ToString().ToUpperInvariant()}] {text}"); return false; } private static bool Time_GetTicksMsec_Prefix(ref ulong __result) { __result = 0; return false; } private static bool CardCmd_Preview_Prefix(CardModel card) { PlayCapture.RecordEffectSubject(card); return false; // skip visual preview entirely in headless mode } private static void CardCmd_AutoPlay_Prefix(CardModel card) => PlayCapture.RecordAutoPlay(card); private static void Hook_AfterCardDrawn_Prefix(CardModel card) => PlayCapture.RecordDraw(card); private static bool LocString_Exists_Prefix(ref bool __result) { if (LocManager.Instance != null) return true; // let original run __result = true; // pretend every key exists so callers don't throw return false; } private static bool LocString_GetText_Prefix(ref string __result) { if (LocManager.Instance != null) return true; __result = string.Empty; return false; } private static bool Creature_ToString_Prefix(Creature __instance, ref string __result) { __result = __instance.IsMonster ? $"" : $""; return false; } // Replacement for CardPileCmd.Shuffle that skips the per-card animation wait // (which calls Engine.GetMainLoop() and crashes outside Godot). Logic mirrors // the original: pull discards, shuffle by player's RNG, re-add to draw pile. private static bool CardPileCmd_Shuffle_Prefix(PlayerChoiceContext choiceContext, Player player, ref Task __result) { __result = ShuffleSync(choiceContext, player); return false; } private static Task ShuffleSync(PlayerChoiceContext choiceContext, Player player) { var pcs = player.PlayerCombatState; if (pcs == null) return Task.CompletedTask; var draw = pcs.DrawPile; var discard = pcs.DiscardPile; var combined = new List(discard.Cards); var inDraw = new HashSet(draw.Cards); foreach (var c in inDraw) { draw.RemoveInternal(c, silent: true); combined.Add(c); } // Fisher-Yates with the player's shuffle RNG. Sufficient for our purposes — // the game's StableShuffle has tie-breaking semantics for IComparable // that CardModel doesn't satisfy, but card identity isn't tied here. var rng = player.RunState.Rng.Shuffle; for (int i = combined.Count - 1; i > 0; i--) { int j = rng.NextInt(0, i + 1); (combined[i], combined[j]) = (combined[j], combined[i]); } // Re-add to draw pile (we drop the per-card wait because it's animation-only). foreach (var c in combined) { if (inDraw.Contains(c)) draw.AddInternal(c, -1, silent: true); else { discard.RemoveInternal(c, silent: true); draw.AddInternal(c, -1, silent: true); } } // Fire AfterShuffle on each listener — StratagemPower, BiiigHug, TheAbacus // hook here. Without this, those cards/relics are silent during sims. return FireAfterShuffle(choiceContext, player); } private static async Task FireAfterShuffle(PlayerChoiceContext choiceContext, Player player) { var combat = player.Creature.CombatState; if (combat == null) return; foreach (var listener in combat.IterateHookListeners().ToList()) await listener.AfterShuffle(choiceContext, player); } }