diff options
Diffstat (limited to 'src/SMAPI.Mods.ErrorHandler')
19 files changed, 664 insertions, 880 deletions
diff --git a/src/SMAPI.Mods.ErrorHandler/ModEntry.cs b/src/SMAPI.Mods.ErrorHandler/ModEntry.cs index d9426d75..067f6a8d 100644 --- a/src/SMAPI.Mods.ErrorHandler/ModEntry.cs +++ b/src/SMAPI.Mods.ErrorHandler/ModEntry.cs @@ -1,8 +1,7 @@ +using System; using System.Reflection; using StardewModdingAPI.Events; -using StardewModdingAPI.Framework; -using StardewModdingAPI.Framework.Logging; -using StardewModdingAPI.Framework.Patching; +using StardewModdingAPI.Internal.Patching; using StardewModdingAPI.Mods.ErrorHandler.Patches; using StardewValley; @@ -29,15 +28,17 @@ namespace StardewModdingAPI.Mods.ErrorHandler IMonitor monitorForGame = this.GetMonitorForGame(); // apply patches - new GamePatcher(this.Monitor).Apply( - new DialogueErrorPatch(monitorForGame, this.Helper.Reflection), - new EventPatches(monitorForGame), - new GameLocationPatches(monitorForGame), - new ObjectErrorPatch(), - new LoadErrorPatch(this.Monitor, this.OnSaveContentRemoved), - new ScheduleErrorPatch(monitorForGame), - new SpriteBatchValidationPatches(), - new UtilityErrorPatches() + HarmonyPatcher.Apply(this.ModManifest.UniqueID, this.Monitor, + new DialoguePatcher(monitorForGame, this.Helper.Reflection), + new DictionaryPatcher(this.Helper.Reflection), + new EventPatcher(monitorForGame), + new GameLocationPatcher(monitorForGame), + new IClickableMenuPatcher(), + new NpcPatcher(monitorForGame), + new ObjectPatcher(), + new SaveGamePatcher(this.Monitor, this.OnSaveContentRemoved), + new SpriteBatchPatcher(), + new UtilityPatcher() ); // hook events @@ -70,12 +71,17 @@ namespace StardewModdingAPI.Mods.ErrorHandler /// <summary>Get the monitor with which to log game errors.</summary> private IMonitor GetMonitorForGame() { - SCore core = SCore.Instance; - LogManager logManager = core.GetType().GetField("LogManager", BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(core) as LogManager; - if (logManager == null) - this.Monitor.Log("Can't access SMAPI's internal log manager. Some game errors may be reported as being from Error Handler.", LogLevel.Error); + // get SMAPI core + Type coreType = Type.GetType("StardewModdingAPI.Framework.SCore, StardewModdingAPI", throwOnError: false) + ?? throw new InvalidOperationException("Can't access SMAPI's core type. This mod may not work correctly."); + object core = coreType.GetProperty("Instance", BindingFlags.Static | BindingFlags.NonPublic)?.GetValue(null) + ?? throw new InvalidOperationException("Can't access SMAPI's core instance. This mod may not work correctly."); - return logManager?.MonitorForGame ?? this.Monitor; + // get monitor + MethodInfo getMonitorForGame = coreType.GetMethod("GetMonitorForGame") + ?? throw new InvalidOperationException("Can't access the SMAPI's 'GetMonitorForGame' method. This mod may not work correctly."); + + return (IMonitor)getMonitorForGame.Invoke(core, new object[0]) ?? this.Monitor; } } } diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/DialogueErrorPatch.cs b/src/SMAPI.Mods.ErrorHandler/Patches/DialogueErrorPatch.cs deleted file mode 100644 index cce13064..00000000 --- a/src/SMAPI.Mods.ErrorHandler/Patches/DialogueErrorPatch.cs +++ /dev/null @@ -1,184 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using StardewModdingAPI.Framework.Patching; -using StardewValley; -#if HARMONY_2 -using HarmonyLib; -using StardewModdingAPI.Framework; -#else -using System.Reflection; -using Harmony; -#endif - -namespace StardewModdingAPI.Mods.ErrorHandler.Patches -{ - /// <summary>A Harmony patch for the <see cref="Dialogue"/> constructor which intercepts invalid dialogue lines and logs an error instead of crashing.</summary> - /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks> - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - internal class DialogueErrorPatch : IHarmonyPatch - { - /********* - ** Fields - *********/ - /// <summary>Writes messages to the console and log file on behalf of the game.</summary> - private static IMonitor MonitorForGame; - - /// <summary>Simplifies access to private code.</summary> - private static IReflectionHelper Reflection; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="monitorForGame">Writes messages to the console and log file on behalf of the game.</param> - /// <param name="reflector">Simplifies access to private code.</param> - public DialogueErrorPatch(IMonitor monitorForGame, IReflectionHelper reflector) - { - DialogueErrorPatch.MonitorForGame = monitorForGame; - DialogueErrorPatch.Reflection = reflector; - } - - - /// <inheritdoc /> -#if HARMONY_2 - public void Apply(Harmony harmony) - { - harmony.Patch( - original: AccessTools.Constructor(typeof(Dialogue), new[] { typeof(string), typeof(NPC) }), - finalizer: new HarmonyMethod(this.GetType(), nameof(DialogueErrorPatch.Finalize_Dialogue_Constructor)) - ); - harmony.Patch( - original: AccessTools.Property(typeof(NPC), nameof(NPC.CurrentDialogue)).GetMethod, - finalizer: new HarmonyMethod(this.GetType(), nameof(DialogueErrorPatch.Finalize_NPC_CurrentDialogue)) - ); - } -#else - public void Apply(HarmonyInstance harmony) - { - harmony.Patch( - original: AccessTools.Constructor(typeof(Dialogue), new[] { typeof(string), typeof(NPC) }), - prefix: new HarmonyMethod(this.GetType(), nameof(DialogueErrorPatch.Before_Dialogue_Constructor)) - ); - harmony.Patch( - original: AccessTools.Property(typeof(NPC), nameof(NPC.CurrentDialogue)).GetMethod, - prefix: new HarmonyMethod(this.GetType(), nameof(DialogueErrorPatch.Before_NPC_CurrentDialogue)) - ); - } -#endif - - - /********* - ** Private methods - *********/ -#if HARMONY_2 - /// <summary>The method to call after the Dialogue constructor.</summary> - /// <param name="__instance">The instance being patched.</param> - /// <param name="masterDialogue">The dialogue being parsed.</param> - /// <param name="speaker">The NPC for which the dialogue is being parsed.</param> - /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> - /// <returns>Returns the exception to throw, if any.</returns> - private static Exception Finalize_Dialogue_Constructor(Dialogue __instance, string masterDialogue, NPC speaker, Exception __exception) - { - if (__exception != null) - { - // log message - string name = !string.IsNullOrWhiteSpace(speaker?.Name) ? speaker.Name : null; - DialogueErrorPatch.MonitorForGame.Log($"Failed parsing dialogue string{(name != null ? $" for {name}" : "")}:\n{masterDialogue}\n{__exception.GetLogSummary()}", LogLevel.Error); - - // set default dialogue - IReflectedMethod parseDialogueString = DialogueErrorPatch.Reflection.GetMethod(__instance, "parseDialogueString"); - IReflectedMethod checkForSpecialDialogueAttributes = DialogueErrorPatch.Reflection.GetMethod(__instance, "checkForSpecialDialogueAttributes"); - parseDialogueString.Invoke("..."); - checkForSpecialDialogueAttributes.Invoke(); - } - - return null; - } - - /// <summary>The method to call after <see cref="NPC.CurrentDialogue"/>.</summary> - /// <param name="__instance">The instance being patched.</param> - /// <param name="__result">The return value of the original method.</param> - /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> - /// <returns>Returns the exception to throw, if any.</returns> - private static Exception Finalize_NPC_CurrentDialogue(NPC __instance, ref Stack<Dialogue> __result, Exception __exception) - { - if (__exception == null) - return null; - - DialogueErrorPatch.MonitorForGame.Log($"Failed loading current dialogue for NPC {__instance.Name}:\n{__exception.GetLogSummary()}", LogLevel.Error); - __result = new Stack<Dialogue>(); - - return null; - } -#else - - /// <summary>The method to call instead of the Dialogue constructor.</summary> - /// <param name="__instance">The instance being patched.</param> - /// <param name="masterDialogue">The dialogue being parsed.</param> - /// <param name="speaker">The NPC for which the dialogue is being parsed.</param> - /// <returns>Returns whether to execute the original method.</returns> - private static bool Before_Dialogue_Constructor(Dialogue __instance, string masterDialogue, NPC speaker) - { - // get private members - bool nameArraysTranslated = DialogueErrorPatch.Reflection.GetField<bool>(typeof(Dialogue), "nameArraysTranslated").GetValue(); - IReflectedMethod translateArraysOfStrings = DialogueErrorPatch.Reflection.GetMethod(typeof(Dialogue), "TranslateArraysOfStrings"); - IReflectedMethod parseDialogueString = DialogueErrorPatch.Reflection.GetMethod(__instance, "parseDialogueString"); - IReflectedMethod checkForSpecialDialogueAttributes = DialogueErrorPatch.Reflection.GetMethod(__instance, "checkForSpecialDialogueAttributes"); - - // replicate base constructor - __instance.dialogues ??= new List<string>(); - - // duplicate code with try..catch - try - { - if (!nameArraysTranslated) - translateArraysOfStrings.Invoke(); - __instance.speaker = speaker; - parseDialogueString.Invoke(masterDialogue); - checkForSpecialDialogueAttributes.Invoke(); - } - catch (Exception baseEx) when (baseEx.InnerException is TargetInvocationException invocationEx && invocationEx.InnerException is Exception ex) - { - string name = !string.IsNullOrWhiteSpace(speaker?.Name) ? speaker.Name : null; - DialogueErrorPatch.MonitorForGame.Log($"Failed parsing dialogue string{(name != null ? $" for {name}" : "")}:\n{masterDialogue}\n{ex}", LogLevel.Error); - - parseDialogueString.Invoke("..."); - checkForSpecialDialogueAttributes.Invoke(); - } - - return false; - } - - /// <summary>The method to call instead of <see cref="NPC.CurrentDialogue"/>.</summary> - /// <param name="__instance">The instance being patched.</param> - /// <param name="__result">The return value of the original method.</param> - /// <param name="__originalMethod">The method being wrapped.</param> - /// <returns>Returns whether to execute the original method.</returns> - private static bool Before_NPC_CurrentDialogue(NPC __instance, ref Stack<Dialogue> __result, MethodInfo __originalMethod) - { - const string key = nameof(DialogueErrorPatch.Before_NPC_CurrentDialogue); - if (!PatchHelper.StartIntercept(key)) - return true; - - try - { - __result = (Stack<Dialogue>)__originalMethod.Invoke(__instance, new object[0]); - return false; - } - catch (TargetInvocationException ex) - { - DialogueErrorPatch.MonitorForGame.Log($"Failed loading current dialogue for NPC {__instance.Name}:\n{ex.InnerException ?? ex}", LogLevel.Error); - __result = new Stack<Dialogue>(); - return false; - } - finally - { - PatchHelper.StopIntercept(key); - } - } -#endif - } -} diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/DialoguePatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/DialoguePatcher.cs new file mode 100644 index 00000000..7a3af39c --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/Patches/DialoguePatcher.cs @@ -0,0 +1,75 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using HarmonyLib; +using StardewModdingAPI.Internal; +using StardewModdingAPI.Internal.Patching; +using StardewValley; + +namespace StardewModdingAPI.Mods.ErrorHandler.Patches +{ + /// <summary>Harmony patches for <see cref="Dialogue"/> which intercept invalid dialogue lines and logs an error instead of crashing.</summary> + /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks> + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] + internal class DialoguePatcher : BasePatcher + { + /********* + ** Fields + *********/ + /// <summary>Writes messages to the console and log file on behalf of the game.</summary> + private static IMonitor MonitorForGame; + + /// <summary>Simplifies access to private code.</summary> + private static IReflectionHelper Reflection; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="monitorForGame">Writes messages to the console and log file on behalf of the game.</param> + /// <param name="reflector">Simplifies access to private code.</param> + public DialoguePatcher(IMonitor monitorForGame, IReflectionHelper reflector) + { + DialoguePatcher.MonitorForGame = monitorForGame; + DialoguePatcher.Reflection = reflector; + } + + /// <inheritdoc /> + public override void Apply(Harmony harmony, IMonitor monitor) + { + harmony.Patch( + original: this.RequireConstructor<Dialogue>(typeof(string), typeof(NPC)), + finalizer: this.GetHarmonyMethod(nameof(DialoguePatcher.Finalize_Constructor)) + ); + } + + + /********* + ** Private methods + *********/ + /// <summary>The method to call when the Dialogue constructor throws an exception.</summary> + /// <param name="__instance">The instance being patched.</param> + /// <param name="masterDialogue">The dialogue being parsed.</param> + /// <param name="speaker">The NPC for which the dialogue is being parsed.</param> + /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> + /// <returns>Returns the exception to throw, if any.</returns> + private static Exception Finalize_Constructor(Dialogue __instance, string masterDialogue, NPC speaker, Exception __exception) + { + if (__exception != null) + { + // log message + string name = !string.IsNullOrWhiteSpace(speaker?.Name) ? speaker.Name : null; + DialoguePatcher.MonitorForGame.Log($"Failed parsing dialogue string{(name != null ? $" for {name}" : "")}:\n{masterDialogue}\n{__exception.GetLogSummary()}", LogLevel.Error); + + // set default dialogue + IReflectedMethod parseDialogueString = DialoguePatcher.Reflection.GetMethod(__instance, "parseDialogueString"); + IReflectedMethod checkForSpecialDialogueAttributes = DialoguePatcher.Reflection.GetMethod(__instance, "checkForSpecialDialogueAttributes"); + parseDialogueString.Invoke("..."); + checkForSpecialDialogueAttributes.Invoke(); + } + + return null; + } + } +} diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/DictionaryPatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/DictionaryPatcher.cs new file mode 100644 index 00000000..6ad64e16 --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/Patches/DictionaryPatcher.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using HarmonyLib; +using StardewModdingAPI.Internal.Patching; +using StardewValley.GameData; +using StardewValley.GameData.HomeRenovations; +using StardewValley.GameData.Movies; + +namespace StardewModdingAPI.Mods.ErrorHandler.Patches +{ + /// <summary>Harmony patches for <see cref="Dictionary{TKey,TValue}"/> which add the accessed key to <see cref="KeyNotFoundException"/> exceptions.</summary> + /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks> + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] + internal class DictionaryPatcher : BasePatcher + { + /********* + ** Fields + *********/ + /// <summary>Simplifies access to private code.</summary> + private static IReflectionHelper Reflection; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="reflector">Simplifies access to private code.</param> + public DictionaryPatcher(IReflectionHelper reflector) + { + DictionaryPatcher.Reflection = reflector; + } + + /// <inheritdoc /> + public override void Apply(Harmony harmony, IMonitor monitor) + { + Type[] keyTypes = { typeof(int), typeof(string) }; + Type[] valueTypes = { typeof(int), typeof(string), typeof(HomeRenovation), typeof(MovieData), typeof(SpecialOrderData) }; + + foreach (Type keyType in keyTypes) + { + foreach (Type valueType in valueTypes) + { + Type dictionaryType = typeof(Dictionary<,>).MakeGenericType(keyType, valueType); + + harmony.Patch( + original: AccessTools.Method(dictionaryType, "get_Item") ?? throw new InvalidOperationException($"Can't find method {PatchHelper.GetMethodString(dictionaryType, "get_Item")} to patch."), + finalizer: this.GetHarmonyMethod(nameof(DictionaryPatcher.Finalize_GetItem)) + ); + } + } + } + + + /********* + ** Private methods + *********/ + /// <summary>The method to call after the dictionary indexer throws an exception.</summary> + /// <param name="key">The dictionary key being fetched.</param> + /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> + /// <returns>Returns the exception to throw, if any.</returns> + private static Exception Finalize_GetItem(object key, Exception __exception) + { + if (__exception is KeyNotFoundException) + { + DictionaryPatcher.Reflection + .GetField<string>(__exception, "_message") + .SetValue($"{__exception.Message}\nkey: '{key}'"); + } + + return __exception; + } + } +} diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/EventPatches.cs b/src/SMAPI.Mods.ErrorHandler/Patches/EventPatcher.cs index 72863d17..1b706147 100644 --- a/src/SMAPI.Mods.ErrorHandler/Patches/EventPatches.cs +++ b/src/SMAPI.Mods.ErrorHandler/Patches/EventPatcher.cs @@ -1,11 +1,7 @@ using System; using System.Diagnostics.CodeAnalysis; -#if HARMONY_2 using HarmonyLib; -#else -using Harmony; -#endif -using StardewModdingAPI.Framework.Patching; +using StardewModdingAPI.Internal.Patching; using StardewValley; namespace StardewModdingAPI.Mods.ErrorHandler.Patches @@ -14,7 +10,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks> [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - internal class EventPatches : IHarmonyPatch + internal class EventPatcher : BasePatcher { /********* ** Fields @@ -28,21 +24,17 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches *********/ /// <summary>Construct an instance.</summary> /// <param name="monitorForGame">Writes messages to the console and log file on behalf of the game.</param> - public EventPatches(IMonitor monitorForGame) + public EventPatcher(IMonitor monitorForGame) { - EventPatches.MonitorForGame = monitorForGame; + EventPatcher.MonitorForGame = monitorForGame; } /// <inheritdoc /> -#if HARMONY_2 - public void Apply(Harmony harmony) -#else - public void Apply(HarmonyInstance harmony) -#endif + public override void Apply(Harmony harmony, IMonitor monitor) { harmony.Patch( - original: AccessTools.Method(typeof(Event), nameof(Event.LogErrorAndHalt)), - postfix: new HarmonyMethod(this.GetType(), nameof(EventPatches.After_Event_LogErrorAndHalt)) + original: this.RequireMethod<Event>(nameof(Event.LogErrorAndHalt)), + postfix: this.GetHarmonyMethod(nameof(EventPatcher.After_LogErrorAndHalt)) ); } @@ -52,9 +44,9 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches *********/ /// <summary>The method to call after <see cref="Event.LogErrorAndHalt"/>.</summary> /// <param name="e">The exception being logged.</param> - private static void After_Event_LogErrorAndHalt(Exception e) + private static void After_LogErrorAndHalt(Exception e) { - EventPatches.MonitorForGame.Log(e.ToString(), LogLevel.Error); + EventPatcher.MonitorForGame.Log(e.ToString(), LogLevel.Error); } } } diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/GameLocationPatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/GameLocationPatcher.cs new file mode 100644 index 00000000..7df6b0a2 --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/Patches/GameLocationPatcher.cs @@ -0,0 +1,79 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using HarmonyLib; +using StardewModdingAPI.Internal.Patching; +using StardewValley; +using xTile; + +namespace StardewModdingAPI.Mods.ErrorHandler.Patches +{ + /// <summary>Harmony patches for <see cref="GameLocation"/> which intercept errors instead of crashing.</summary> + /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks> + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] + internal class GameLocationPatcher : BasePatcher + { + /********* + ** Fields + *********/ + /// <summary>Writes messages to the console and log file on behalf of the game.</summary> + private static IMonitor MonitorForGame; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="monitorForGame">Writes messages to the console and log file on behalf of the game.</param> + public GameLocationPatcher(IMonitor monitorForGame) + { + GameLocationPatcher.MonitorForGame = monitorForGame; + } + + /// <inheritdoc /> + public override void Apply(Harmony harmony, IMonitor monitor) + { + harmony.Patch( + original: this.RequireMethod<GameLocation>(nameof(GameLocation.checkEventPrecondition)), + finalizer: this.GetHarmonyMethod(nameof(GameLocationPatcher.Finalize_CheckEventPrecondition)) + ); + harmony.Patch( + original: this.RequireMethod<GameLocation>(nameof(GameLocation.updateSeasonalTileSheets)), + finalizer: this.GetHarmonyMethod(nameof(GameLocationPatcher.Finalize_UpdateSeasonalTileSheets)) + ); + } + + + /********* + ** Private methods + *********/ + /// <summary>The method to call when <see cref="GameLocation.checkEventPrecondition"/> throws an exception.</summary> + /// <param name="__result">The return value of the original method.</param> + /// <param name="precondition">The precondition to be parsed.</param> + /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> + /// <returns>Returns the exception to throw, if any.</returns> + private static Exception Finalize_CheckEventPrecondition(ref int __result, string precondition, Exception __exception) + { + if (__exception != null) + { + __result = -1; + GameLocationPatcher.MonitorForGame.Log($"Failed parsing event precondition ({precondition}):\n{__exception.InnerException}", LogLevel.Error); + } + + return null; + } + + /// <summary>The method to call when <see cref="GameLocation.updateSeasonalTileSheets"/> throws an exception.</summary> + /// <param name="__instance">The instance being patched.</param> + /// <param name="map">The map whose tilesheets to update.</param> + /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> + /// <returns>Returns the exception to throw, if any.</returns> + private static Exception Finalize_UpdateSeasonalTileSheets(GameLocation __instance, Map map, Exception __exception) + { + if (__exception != null) + GameLocationPatcher.MonitorForGame.Log($"Failed updating seasonal tilesheets for location '{__instance.NameOrUniqueName}': \n{__exception}", LogLevel.Error); + + return null; + } + } +} diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/GameLocationPatches.cs b/src/SMAPI.Mods.ErrorHandler/Patches/GameLocationPatches.cs deleted file mode 100644 index 7a48133e..00000000 --- a/src/SMAPI.Mods.ErrorHandler/Patches/GameLocationPatches.cs +++ /dev/null @@ -1,158 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -#if HARMONY_2 -using System; -using HarmonyLib; -#else -using System.Reflection; -using Harmony; -#endif -using StardewModdingAPI.Framework.Patching; -using StardewValley; -using xTile; - -namespace StardewModdingAPI.Mods.ErrorHandler.Patches -{ - /// <summary>Harmony patches for <see cref="GameLocation.checkEventPrecondition"/> and <see cref="GameLocation.updateSeasonalTileSheets"/> which intercept errors instead of crashing.</summary> - /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks> - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - internal class GameLocationPatches : IHarmonyPatch - { - /********* - ** Fields - *********/ - /// <summary>Writes messages to the console and log file on behalf of the game.</summary> - private static IMonitor MonitorForGame; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="monitorForGame">Writes messages to the console and log file on behalf of the game.</param> - public GameLocationPatches(IMonitor monitorForGame) - { - GameLocationPatches.MonitorForGame = monitorForGame; - } - - /// <inheritdoc /> -#if HARMONY_2 - public void Apply(Harmony harmony) - { - harmony.Patch( - original: AccessTools.Method(typeof(GameLocation), nameof(GameLocation.checkEventPrecondition)), - finalizer: new HarmonyMethod(this.GetType(), nameof(EventErrorPatch.Finalize_GameLocation_CheckEventPrecondition)) - ); -harmony.Patch( - original: AccessTools.Method(typeof(GameLocation), nameof(GameLocation.updateSeasonalTileSheets)), - finalizer: new HarmonyMethod(this.GetType(), nameof(EventErrorPatch.Before_GameLocation_UpdateSeasonalTileSheets)) - ); - } -#else - public void Apply(HarmonyInstance harmony) - { - harmony.Patch( - original: AccessTools.Method(typeof(GameLocation), nameof(GameLocation.checkEventPrecondition)), - prefix: new HarmonyMethod(this.GetType(), nameof(GameLocationPatches.Before_GameLocation_CheckEventPrecondition)) - ); - harmony.Patch( - original: AccessTools.Method(typeof(GameLocation), nameof(GameLocation.updateSeasonalTileSheets)), - prefix: new HarmonyMethod(this.GetType(), nameof(GameLocationPatches.Before_GameLocation_UpdateSeasonalTileSheets)) - ); - } -#endif - - - /********* - ** Private methods - *********/ -#if HARMONY_2 - /// <summary>The method to call instead of GameLocation.checkEventPrecondition.</summary> - /// <param name="__result">The return value of the original method.</param> - /// <param name="precondition">The precondition to be parsed.</param> - /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> - /// <returns>Returns the exception to throw, if any.</returns> - private static Exception Finalize_GameLocation_CheckEventPrecondition(ref int __result, string precondition, Exception __exception) - { - if (__exception != null) - { - __result = -1; - EventErrorPatch.MonitorForGame.Log($"Failed parsing event precondition ({precondition}):\n{__exception.InnerException}", LogLevel.Error); - } - - return null; - } -#else - /// <summary>The method to call instead of <see cref="GameLocation.checkEventPrecondition"/>.</summary> - /// <param name="__instance">The instance being patched.</param> - /// <param name="__result">The return value of the original method.</param> - /// <param name="precondition">The precondition to be parsed.</param> - /// <param name="__originalMethod">The method being wrapped.</param> - /// <returns>Returns whether to execute the original method.</returns> - private static bool Before_GameLocation_CheckEventPrecondition(GameLocation __instance, ref int __result, string precondition, MethodInfo __originalMethod) - { - const string key = nameof(GameLocationPatches.Before_GameLocation_CheckEventPrecondition); - if (!PatchHelper.StartIntercept(key)) - return true; - - try - { - __result = (int)__originalMethod.Invoke(__instance, new object[] { precondition }); - return false; - } - catch (TargetInvocationException ex) - { - __result = -1; - GameLocationPatches.MonitorForGame.Log($"Failed parsing event precondition ({precondition}):\n{ex.InnerException}", LogLevel.Error); - return false; - } - finally - { - PatchHelper.StopIntercept(key); - } - } -#endif - -#if HARMONY_2 - /// <summary>The method to call instead of <see cref="GameLocation.updateSeasonalTileSheets"/>.</summary> - /// <param name="__instance">The instance being patched.</param> - /// <param name="map">The map whose tilesheets to update.</param> - /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> - /// <returns>Returns the exception to throw, if any.</returns> - private static Exception Before_GameLocation_UpdateSeasonalTileSheets(GameLocation __instance, Map map, Exception __exception) - { - if (__exception != null) - GameLocationPatches.MonitorForGame.Log($"Failed updating seasonal tilesheets for location '{__instance.NameOrUniqueName}': \n{__exception}", LogLevel.Error); - - return null; - } -#else - /// <summary>The method to call instead of <see cref="GameLocation.updateSeasonalTileSheets"/>.</summary> - /// <param name="__instance">The instance being patched.</param> - /// <param name="map">The map whose tilesheets to update.</param> - /// <param name="__originalMethod">The method being wrapped.</param> - /// <returns>Returns whether to execute the original method.</returns> - private static bool Before_GameLocation_UpdateSeasonalTileSheets(GameLocation __instance, Map map, MethodInfo __originalMethod) - { - const string key = nameof(GameLocationPatches.Before_GameLocation_UpdateSeasonalTileSheets); - if (!PatchHelper.StartIntercept(key)) - return true; - - try - { - __originalMethod.Invoke(__instance, new object[] { map }); - return false; - } - catch (TargetInvocationException ex) - { - GameLocationPatches.MonitorForGame.Log($"Failed updating seasonal tilesheets for location '{__instance.NameOrUniqueName}'. Technical details:\n{ex.InnerException}", LogLevel.Error); - return false; - } - finally - { - PatchHelper.StopIntercept(key); - } - } -#endif - } -} diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/IClickableMenuPatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/IClickableMenuPatcher.cs new file mode 100644 index 00000000..b65a695a --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/Patches/IClickableMenuPatcher.cs @@ -0,0 +1,44 @@ +using System.Diagnostics.CodeAnalysis; +using HarmonyLib; +using StardewModdingAPI.Internal.Patching; +using StardewValley; +using StardewValley.Menus; +using SObject = StardewValley.Object; + +namespace StardewModdingAPI.Mods.ErrorHandler.Patches +{ + /// <summary>Harmony patches for <see cref="IClickableMenu"/> which intercept crashes due to invalid items.</summary> + /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks> + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] + internal class IClickableMenuPatcher : BasePatcher + { + /********* + ** Public methods + *********/ + /// <inheritdoc /> + public override void Apply(Harmony harmony, IMonitor monitor) + { + harmony.Patch( + original: this.RequireMethod<IClickableMenu>(nameof(IClickableMenu.drawToolTip)), + prefix: this.GetHarmonyMethod(nameof(IClickableMenuPatcher.Before_DrawTooltip)) + ); + } + + + /********* + ** Private methods + *********/ + /// <summary>The method to call instead of <see cref="IClickableMenu.drawToolTip"/>.</summary> + /// <param name="hoveredItem">The item for which to draw a tooltip.</param> + /// <returns>Returns whether to execute the original method.</returns> + private static bool Before_DrawTooltip(Item hoveredItem) + { + // invalid edible item cause crash when drawing tooltips + if (hoveredItem is SObject obj && obj.Edibility != -300 && !Game1.objectInformation.ContainsKey(obj.ParentSheetIndex)) + return false; + + return true; + } + } +} diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/LoadErrorPatch.cs b/src/SMAPI.Mods.ErrorHandler/Patches/LoadErrorPatch.cs deleted file mode 100644 index 52d5f5a1..00000000 --- a/src/SMAPI.Mods.ErrorHandler/Patches/LoadErrorPatch.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -#if HARMONY_2 -using HarmonyLib; -#else -using Harmony; -#endif -using StardewModdingAPI.Framework.Exceptions; -using StardewModdingAPI.Framework.Patching; -using StardewValley; -using StardewValley.Buildings; -using StardewValley.Locations; - -namespace StardewModdingAPI.Mods.ErrorHandler.Patches -{ - /// <summary>A Harmony patch for <see cref="SaveGame"/> which prevents some errors due to broken save data.</summary> - /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks> - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - internal class LoadErrorPatch : IHarmonyPatch - { - /********* - ** Fields - *********/ - /// <summary>Writes messages to the console and log file.</summary> - private static IMonitor Monitor; - - /// <summary>A callback invoked when custom content is removed from the save data to avoid a crash.</summary> - private static Action OnContentRemoved; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="monitor">Writes messages to the console and log file.</param> - /// <param name="onContentRemoved">A callback invoked when custom content is removed from the save data to avoid a crash.</param> - public LoadErrorPatch(IMonitor monitor, Action onContentRemoved) - { - LoadErrorPatch.Monitor = monitor; - LoadErrorPatch.OnContentRemoved = onContentRemoved; - } - - - /// <inheritdoc /> -#if HARMONY_2 - public void Apply(Harmony harmony) -#else - public void Apply(HarmonyInstance harmony) -#endif - { - harmony.Patch( - original: AccessTools.Method(typeof(SaveGame), nameof(SaveGame.loadDataToLocations)), - prefix: new HarmonyMethod(this.GetType(), nameof(LoadErrorPatch.Before_SaveGame_LoadDataToLocations)) - ); - } - - - /********* - ** Private methods - *********/ - /// <summary>The method to call instead of <see cref="SaveGame.loadDataToLocations"/>.</summary> - /// <param name="gamelocations">The game locations being loaded.</param> - /// <returns>Returns whether to execute the original method.</returns> - private static bool Before_SaveGame_LoadDataToLocations(List<GameLocation> gamelocations) - { - bool removedAny = - LoadErrorPatch.RemoveBrokenBuildings(gamelocations) - | LoadErrorPatch.RemoveInvalidNpcs(gamelocations); - - if (removedAny) - LoadErrorPatch.OnContentRemoved(); - - return true; - } - - /// <summary>Remove buildings which don't exist in the game data.</summary> - /// <param name="locations">The current game locations.</param> - private static bool RemoveBrokenBuildings(IEnumerable<GameLocation> locations) - { - bool removedAny = false; - - foreach (BuildableGameLocation location in locations.OfType<BuildableGameLocation>()) - { - foreach (Building building in location.buildings.ToArray()) - { - try - { - BluePrint _ = new BluePrint(building.buildingType.Value); - } - catch (SContentLoadException) - { - LoadErrorPatch.Monitor.Log($"Removed invalid building type '{building.buildingType.Value}' in {location.Name} ({building.tileX}, {building.tileY}) to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom building mod?)", LogLevel.Warn); - location.buildings.Remove(building); - removedAny = true; - } - } - } - - return removedAny; - } - - /// <summary>Remove NPCs which don't exist in the game data.</summary> - /// <param name="locations">The current game locations.</param> - private static bool RemoveInvalidNpcs(IEnumerable<GameLocation> locations) - { - bool removedAny = false; - - IDictionary<string, string> data = Game1.content.Load<Dictionary<string, string>>("Data\\NPCDispositions"); - foreach (GameLocation location in LoadErrorPatch.GetAllLocations(locations)) - { - foreach (NPC npc in location.characters.ToArray()) - { - if (npc.isVillager() && !data.ContainsKey(npc.Name)) - { - try - { - npc.reloadSprite(); // this won't crash for special villagers like Bouncer - } - catch - { - LoadErrorPatch.Monitor.Log($"Removed invalid villager '{npc.Name}' in {location.Name} ({npc.getTileLocation()}) to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom NPC mod?)", LogLevel.Warn); - location.characters.Remove(npc); - removedAny = true; - } - } - } - } - - return removedAny; - } - - /// <summary>Get all locations, including building interiors.</summary> - /// <param name="locations">The main game locations.</param> - private static IEnumerable<GameLocation> GetAllLocations(IEnumerable<GameLocation> locations) - { - foreach (GameLocation location in locations) - { - yield return location; - if (location is BuildableGameLocation buildableLocation) - { - foreach (GameLocation interior in buildableLocation.buildings.Select(p => p.indoors.Value).Where(p => p != null)) - yield return interior; - } - } - } - } -} diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/NpcPatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/NpcPatcher.cs new file mode 100644 index 00000000..275bb5bf --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/Patches/NpcPatcher.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using HarmonyLib; +using StardewModdingAPI.Internal; +using StardewModdingAPI.Internal.Patching; +using StardewValley; + +namespace StardewModdingAPI.Mods.ErrorHandler.Patches +{ + /// <summary>Harmony patches for <see cref="NPC"/> which intercept crashes due to invalid schedule data.</summary> + /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks> + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] + internal class NpcPatcher : BasePatcher + { + /********* + ** Fields + *********/ + /// <summary>Writes messages to the console and log file on behalf of the game.</summary> + private static IMonitor MonitorForGame; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="monitorForGame">Writes messages to the console and log file on behalf of the game.</param> + public NpcPatcher(IMonitor monitorForGame) + { + NpcPatcher.MonitorForGame = monitorForGame; + } + + /// <inheritdoc /> + public override void Apply(Harmony harmony, IMonitor monitor) + { + harmony.Patch( + original: this.RequireMethod<NPC>($"get_{nameof(NPC.CurrentDialogue)}"), + finalizer: this.GetHarmonyMethod(nameof(NpcPatcher.Finalize_CurrentDialogue)) + ); + + harmony.Patch( + original: this.RequireMethod<NPC>(nameof(NPC.parseMasterSchedule)), + finalizer: this.GetHarmonyMethod(nameof(NpcPatcher.Finalize_ParseMasterSchedule)) + ); + } + + + /********* + ** Private methods + *********/ + /// <summary>The method to call when <see cref="NPC.CurrentDialogue"/> throws an exception.</summary> + /// <param name="__instance">The instance being patched.</param> + /// <param name="__result">The return value of the original method.</param> + /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> + /// <returns>Returns the exception to throw, if any.</returns> + private static Exception Finalize_CurrentDialogue(NPC __instance, ref Stack<Dialogue> __result, Exception __exception) + { + if (__exception == null) + return null; + + NpcPatcher.MonitorForGame.Log($"Failed loading current dialogue for NPC {__instance.Name}:\n{__exception.GetLogSummary()}", LogLevel.Error); + __result = new Stack<Dialogue>(); + + return null; + } + + /// <summary>The method to call instead of <see cref="NPC.parseMasterSchedule"/>.</summary> + /// <param name="rawData">The raw schedule data to parse.</param> + /// <param name="__instance">The instance being patched.</param> + /// <param name="__result">The patched method's return value.</param> + /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> + /// <returns>Returns the exception to throw, if any.</returns> + private static Exception Finalize_ParseMasterSchedule(string rawData, NPC __instance, ref Dictionary<int, SchedulePathDescription> __result, Exception __exception) + { + if (__exception != null) + { + NpcPatcher.MonitorForGame.Log($"Failed parsing schedule for NPC {__instance.Name}:\n{rawData}\n{__exception.GetLogSummary()}", LogLevel.Error); + __result = new Dictionary<int, SchedulePathDescription>(); + } + + return null; + } + } +} diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/ObjectErrorPatch.cs b/src/SMAPI.Mods.ErrorHandler/Patches/ObjectErrorPatch.cs deleted file mode 100644 index 9f8a98cd..00000000 --- a/src/SMAPI.Mods.ErrorHandler/Patches/ObjectErrorPatch.cs +++ /dev/null @@ -1,136 +0,0 @@ -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using StardewModdingAPI.Framework.Patching; -using StardewValley; -using StardewValley.Menus; -using SObject = StardewValley.Object; -#if HARMONY_2 -using System; -using HarmonyLib; -#else -using System.Reflection; -using Harmony; -#endif - -namespace StardewModdingAPI.Mods.ErrorHandler.Patches -{ - /// <summary>A Harmony patch for <see cref="SObject.getDescription"/> which intercepts crashes due to the item no longer existing.</summary> - /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks> - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - internal class ObjectErrorPatch : IHarmonyPatch - { - /********* - ** Public methods - *********/ - /// <inheritdoc /> -#if HARMONY_2 - public void Apply(Harmony harmony) -#else - public void Apply(HarmonyInstance harmony) -#endif - { - // object.getDescription - harmony.Patch( - original: AccessTools.Method(typeof(SObject), nameof(SObject.getDescription)), - prefix: new HarmonyMethod(this.GetType(), nameof(ObjectErrorPatch.Before_Object_GetDescription)) - ); - - // object.getDisplayName - harmony.Patch( - original: AccessTools.Method(typeof(SObject), "loadDisplayName"), -#if HARMONY_2 - finalizer: new HarmonyMethod(this.GetType(), nameof(ObjectErrorPatch.Finalize_Object_loadDisplayName)) -#else - prefix: new HarmonyMethod(this.GetType(), nameof(ObjectErrorPatch.Before_Object_loadDisplayName)) -#endif - ); - - // IClickableMenu.drawToolTip - harmony.Patch( - original: AccessTools.Method(typeof(IClickableMenu), nameof(IClickableMenu.drawToolTip)), - prefix: new HarmonyMethod(this.GetType(), nameof(ObjectErrorPatch.Before_IClickableMenu_DrawTooltip)) - ); - } - - - /********* - ** Private methods - *********/ - /// <summary>The method to call instead of <see cref="StardewValley.Object.getDescription"/>.</summary> - /// <param name="__instance">The instance being patched.</param> - /// <param name="__result">The patched method's return value.</param> - /// <returns>Returns whether to execute the original method.</returns> - private static bool Before_Object_GetDescription(SObject __instance, ref string __result) - { - // invalid bigcraftables crash instead of showing '???' like invalid non-bigcraftables - if (!__instance.IsRecipe && __instance.bigCraftable.Value && !Game1.bigCraftablesInformation.ContainsKey(__instance.ParentSheetIndex)) - { - __result = "???"; - return false; - } - - return true; - } - -#if HARMONY_2 - /// <summary>The method to call after <see cref="StardewValley.Object.loadDisplayName"/>.</summary> - /// <param name="__result">The patched method's return value.</param> - /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> - /// <returns>Returns the exception to throw, if any.</returns> - private static Exception Finalize_Object_loadDisplayName(ref string __result, Exception __exception) - { - if (__exception is KeyNotFoundException) - { - __result = "???"; - return null; - } - - return __exception; - } -#else - /// <summary>The method to call instead of <see cref="StardewValley.Object.loadDisplayName"/>.</summary> - /// <param name="__instance">The instance being patched.</param> - /// <param name="__result">The patched method's return value.</param> - /// <param name="__originalMethod">The method being wrapped.</param> - /// <returns>Returns whether to execute the original method.</returns> - private static bool Before_Object_loadDisplayName(SObject __instance, ref string __result, MethodInfo __originalMethod) - { - const string key = nameof(ObjectErrorPatch.Before_Object_loadDisplayName); - if (!PatchHelper.StartIntercept(key)) - return true; - - try - { - __result = (string)__originalMethod.Invoke(__instance, new object[0]); - return false; - } - catch (TargetInvocationException ex) when (ex.InnerException is KeyNotFoundException) - { - __result = "???"; - return false; - } - catch - { - return true; - } - finally - { - PatchHelper.StopIntercept(key); - } - } -#endif - - /// <summary>The method to call instead of <see cref="IClickableMenu.drawToolTip"/>.</summary> - /// <param name="hoveredItem">The item for which to draw a tooltip.</param> - /// <returns>Returns whether to execute the original method.</returns> - private static bool Before_IClickableMenu_DrawTooltip(Item hoveredItem) - { - // invalid edible item cause crash when drawing tooltips - if (hoveredItem is SObject obj && obj.Edibility != -300 && !Game1.objectInformation.ContainsKey(obj.ParentSheetIndex)) - return false; - - return true; - } - } -} diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/ObjectPatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/ObjectPatcher.cs new file mode 100644 index 00000000..fd4ea35c --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/Patches/ObjectPatcher.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using HarmonyLib; +using StardewModdingAPI.Internal.Patching; +using StardewValley; +using SObject = StardewValley.Object; + +namespace StardewModdingAPI.Mods.ErrorHandler.Patches +{ + /// <summary>Harmony patches for <see cref="SObject"/> which intercept crashes due to invalid items.</summary> + /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks> + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] + internal class ObjectPatcher : BasePatcher + { + /********* + ** Public methods + *********/ + /// <inheritdoc /> + public override void Apply(Harmony harmony, IMonitor monitor) + { + // object.getDescription + harmony.Patch( + original: this.RequireMethod<SObject>(nameof(SObject.getDescription)), + prefix: this.GetHarmonyMethod(nameof(ObjectPatcher.Before_Object_GetDescription)) + ); + + // object.getDisplayName + harmony.Patch( + original: this.RequireMethod<SObject>("loadDisplayName"), + finalizer: this.GetHarmonyMethod(nameof(ObjectPatcher.Finalize_Object_loadDisplayName)) + ); + } + + + /********* + ** Private methods + *********/ + /// <summary>The method to call instead of <see cref="StardewValley.Object.getDescription"/>.</summary> + /// <param name="__instance">The instance being patched.</param> + /// <param name="__result">The patched method's return value.</param> + /// <returns>Returns whether to execute the original method.</returns> + private static bool Before_Object_GetDescription(SObject __instance, ref string __result) + { + // invalid bigcraftables crash instead of showing '???' like invalid non-bigcraftables + if (!__instance.IsRecipe && __instance.bigCraftable.Value && !Game1.bigCraftablesInformation.ContainsKey(__instance.ParentSheetIndex)) + { + __result = "???"; + return false; + } + + return true; + } + + /// <summary>The method to call after <see cref="StardewValley.Object.loadDisplayName"/>.</summary> + /// <param name="__result">The patched method's return value.</param> + /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> + /// <returns>Returns the exception to throw, if any.</returns> + private static Exception Finalize_Object_loadDisplayName(ref string __result, Exception __exception) + { + if (__exception is KeyNotFoundException) + { + __result = "???"; + return null; + } + + return __exception; + } + } +} diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/SaveGamePatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/SaveGamePatcher.cs new file mode 100644 index 00000000..2a43cb10 --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/Patches/SaveGamePatcher.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using HarmonyLib; +using Microsoft.Xna.Framework.Content; +using StardewModdingAPI.Internal.Patching; +using StardewValley; +using StardewValley.Buildings; +using StardewValley.Locations; +using SObject = StardewValley.Object; + +namespace StardewModdingAPI.Mods.ErrorHandler.Patches +{ + /// <summary>Harmony patches for <see cref="SaveGame"/> which prevent some errors due to broken save data.</summary> + /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks> + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] + internal class SaveGamePatcher : BasePatcher + { + /********* + ** Fields + *********/ + /// <summary>Writes messages to the console and log file.</summary> + private static IMonitor Monitor; + + /// <summary>A callback invoked when custom content is removed from the save data to avoid a crash.</summary> + private static Action OnContentRemoved; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="onContentRemoved">A callback invoked when custom content is removed from the save data to avoid a crash.</param> + public SaveGamePatcher(IMonitor monitor, Action onContentRemoved) + { + SaveGamePatcher.Monitor = monitor; + SaveGamePatcher.OnContentRemoved = onContentRemoved; + } + + /// <inheritdoc /> + public override void Apply(Harmony harmony, IMonitor monitor) + { + harmony.Patch( + original: this.RequireMethod<SaveGame>(nameof(SaveGame.loadDataToLocations)), + prefix: this.GetHarmonyMethod(nameof(SaveGamePatcher.Before_LoadDataToLocations)) + ); + } + + + /********* + ** Private methods + *********/ + /// <summary>The method to call instead of <see cref="SaveGame.loadDataToLocations"/>.</summary> + /// <param name="gamelocations">The game locations being loaded.</param> + /// <returns>Returns whether to execute the original method.</returns> + private static bool Before_LoadDataToLocations(List<GameLocation> gamelocations) + { + IDictionary<string, string> npcs = Game1.content.Load<Dictionary<string, string>>("Data\\NPCDispositions"); + + if (SaveGamePatcher.RemoveBrokenContent(gamelocations, npcs)) + SaveGamePatcher.OnContentRemoved(); + + return true; + } + + /// <summary>Remove content which no longer exists in the game data.</summary> + /// <param name="locations">The current game locations.</param> + /// <param name="npcs">The NPC data.</param> + private static bool RemoveBrokenContent(IEnumerable<GameLocation> locations, IDictionary<string, string> npcs) + { + bool removedAny = false; + + foreach (GameLocation location in locations) + removedAny |= SaveGamePatcher.RemoveBrokenContent(location, npcs); + + return removedAny; + } + + /// <summary>Remove content which no longer exists in the game data.</summary> + /// <param name="location">The current game location.</param> + /// <param name="npcs">The NPC data.</param> + private static bool RemoveBrokenContent(GameLocation location, IDictionary<string, string> npcs) + { + bool removedAny = false; + if (location == null) + return false; + + // check buildings + if (location is BuildableGameLocation buildableLocation) + { + foreach (Building building in buildableLocation.buildings.ToArray()) + { + try + { + BluePrint _ = new BluePrint(building.buildingType.Value); + } + catch (ContentLoadException) + { + SaveGamePatcher.Monitor.Log($"Removed invalid building type '{building.buildingType.Value}' in {location.Name} ({building.tileX}, {building.tileY}) to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom building mod?)", LogLevel.Warn); + buildableLocation.buildings.Remove(building); + removedAny = true; + continue; + } + + SaveGamePatcher.RemoveBrokenContent(building.indoors.Value, npcs); + } + } + + // check NPCs + foreach (NPC npc in location.characters.ToArray()) + { + if (npc.isVillager() && !npcs.ContainsKey(npc.Name)) + { + try + { + npc.reloadSprite(); // this won't crash for special villagers like Bouncer + } + catch + { + SaveGamePatcher.Monitor.Log($"Removed invalid villager '{npc.Name}' in {location.Name} ({npc.getTileLocation()}) to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom NPC mod?)", LogLevel.Warn); + location.characters.Remove(npc); + removedAny = true; + } + } + } + + // check objects + foreach (var pair in location.objects.Pairs.ToArray()) + { + // SpaceCore can leave null values when removing its custom content + if (pair.Value == null) + { + location.Objects.Remove(pair.Key); + SaveGamePatcher.Monitor.Log($"Removed invalid null object in {location.Name} ({pair.Key}) to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom item mod?)", LogLevel.Warn); + removedAny = true; + } + } + + return removedAny; + } + } +} diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/ScheduleErrorPatch.cs b/src/SMAPI.Mods.ErrorHandler/Patches/ScheduleErrorPatch.cs deleted file mode 100644 index d2a5e988..00000000 --- a/src/SMAPI.Mods.ErrorHandler/Patches/ScheduleErrorPatch.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using StardewModdingAPI.Framework.Patching; -using StardewValley; -#if HARMONY_2 -using System; -using HarmonyLib; -using StardewModdingAPI.Framework; -#else -using System.Reflection; -using Harmony; -#endif - -namespace StardewModdingAPI.Mods.ErrorHandler.Patches -{ - /// <summary>A Harmony patch for <see cref="NPC.parseMasterSchedule"/> which intercepts crashes due to invalid schedule data.</summary> - /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks> - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - internal class ScheduleErrorPatch : IHarmonyPatch - { - /********* - ** Fields - *********/ - /// <summary>Writes messages to the console and log file on behalf of the game.</summary> - private static IMonitor MonitorForGame; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="monitorForGame">Writes messages to the console and log file on behalf of the game.</param> - public ScheduleErrorPatch(IMonitor monitorForGame) - { - ScheduleErrorPatch.MonitorForGame = monitorForGame; - } - - /// <inheritdoc /> -#if HARMONY_2 - public void Apply(Harmony harmony) -#else - public void Apply(HarmonyInstance harmony) -#endif - { - harmony.Patch( - original: AccessTools.Method(typeof(NPC), nameof(NPC.parseMasterSchedule)), -#if HARMONY_2 - finalizer: new HarmonyMethod(this.GetType(), nameof(ScheduleErrorPatch.Finalize_NPC_parseMasterSchedule)) -#else - prefix: new HarmonyMethod(this.GetType(), nameof(ScheduleErrorPatch.Before_NPC_parseMasterSchedule)) -#endif - ); - } - - - /********* - ** Private methods - *********/ -#if HARMONY_2 - /// <summary>The method to call instead of <see cref="NPC.parseMasterSchedule"/>.</summary> - /// <param name="rawData">The raw schedule data to parse.</param> - /// <param name="__instance">The instance being patched.</param> - /// <param name="__result">The patched method's return value.</param> - /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> - /// <returns>Returns the exception to throw, if any.</returns> - private static Exception Finalize_NPC_parseMasterSchedule(string rawData, NPC __instance, ref Dictionary<int, SchedulePathDescription> __result, Exception __exception) - { - if (__exception != null) - { - ScheduleErrorPatch.MonitorForGame.Log($"Failed parsing schedule for NPC {__instance.Name}:\n{rawData}\n{__exception.GetLogSummary()}", LogLevel.Error); - __result = new Dictionary<int, SchedulePathDescription>(); - } - - return null; - } -#else - /// <summary>The method to call instead of <see cref="NPC.parseMasterSchedule"/>.</summary> - /// <param name="rawData">The raw schedule data to parse.</param> - /// <param name="__instance">The instance being patched.</param> - /// <param name="__result">The patched method's return value.</param> - /// <param name="__originalMethod">The method being wrapped.</param> - /// <returns>Returns whether to execute the original method.</returns> - private static bool Before_NPC_parseMasterSchedule(string rawData, NPC __instance, ref Dictionary<int, SchedulePathDescription> __result, MethodInfo __originalMethod) - { - const string key = nameof(ScheduleErrorPatch.Before_NPC_parseMasterSchedule); - if (!PatchHelper.StartIntercept(key)) - return true; - - try - { - __result = (Dictionary<int, SchedulePathDescription>)__originalMethod.Invoke(__instance, new object[] { rawData }); - return false; - } - catch (TargetInvocationException ex) - { - ScheduleErrorPatch.MonitorForGame.Log($"Failed parsing schedule for NPC {__instance.Name}:\n{rawData}\n{ex.InnerException ?? ex}", LogLevel.Error); - __result = new Dictionary<int, SchedulePathDescription>(); - return false; - } - finally - { - PatchHelper.StopIntercept(key); - } - } -#endif - } -} diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/SpriteBatchValidationPatches.cs b/src/SMAPI.Mods.ErrorHandler/Patches/SpriteBatchPatcher.cs index 8056fd71..6860a4ec 100644 --- a/src/SMAPI.Mods.ErrorHandler/Patches/SpriteBatchValidationPatches.cs +++ b/src/SMAPI.Mods.ErrorHandler/Patches/SpriteBatchPatcher.cs @@ -1,36 +1,28 @@ -#if HARMONY_2 -using HarmonyLib; -#else -using Harmony; -#endif using System; using System.Diagnostics.CodeAnalysis; +using HarmonyLib; using Microsoft.Xna.Framework.Graphics; -using StardewModdingAPI.Framework.Patching; +using StardewModdingAPI.Internal.Patching; namespace StardewModdingAPI.Mods.ErrorHandler.Patches { - /// <summary>Harmony patch for <see cref="SpriteBatch"/> to validate textures earlier.</summary> + /// <summary>Harmony patches for <see cref="SpriteBatch"/> which validate textures earlier.</summary> /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks> [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - internal class SpriteBatchValidationPatches : IHarmonyPatch + internal class SpriteBatchPatcher : BasePatcher { /********* ** Public methods *********/ /// <inheritdoc /> -#if HARMONY_2 - public void Apply(Harmony harmony) -#else - public void Apply(HarmonyInstance harmony) -#endif + public override void Apply(Harmony harmony, IMonitor monitor) { harmony.Patch( original: Constants.GameFramework == GameFramework.Xna - ? AccessTools.Method(typeof(SpriteBatch), "InternalDraw") - : AccessTools.Method(typeof(SpriteBatch), "CheckValid", new[] { typeof(Texture2D) }), - postfix: new HarmonyMethod(this.GetType(), nameof(SpriteBatchValidationPatches.After_SpriteBatch_CheckValid)) + ? this.RequireMethod<SpriteBatch>("InternalDraw") + : this.RequireMethod<SpriteBatch>("CheckValid", new[] { typeof(Texture2D) }), + postfix: this.GetHarmonyMethod(nameof(SpriteBatchPatcher.After_CheckValid)) ); } @@ -39,13 +31,13 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches ** Private methods *********/ #if SMAPI_FOR_XNA - /// <summary>The method to call instead of <see cref="SpriteBatch.InternalDraw"/>.</summary> + /// <summary>The method to call after <see cref="SpriteBatch.InternalDraw"/>.</summary> /// <param name="texture">The texture to validate.</param> #else - /// <summary>The method to call instead of <see cref="SpriteBatch.CheckValid"/>.</summary> + /// <summary>The method to call after <see cref="SpriteBatch.CheckValid"/>.</summary> /// <param name="texture">The texture to validate.</param> #endif - private static void After_SpriteBatch_CheckValid(Texture2D texture) + private static void After_CheckValid(Texture2D texture) { if (texture?.IsDisposed == true) throw new ObjectDisposedException("Cannot draw this texture because it's disposed."); diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/UtilityErrorPatches.cs b/src/SMAPI.Mods.ErrorHandler/Patches/UtilityErrorPatches.cs deleted file mode 100644 index 1ddd407c..00000000 --- a/src/SMAPI.Mods.ErrorHandler/Patches/UtilityErrorPatches.cs +++ /dev/null @@ -1,89 +0,0 @@ -#if HARMONY_2 -using System; -using HarmonyLib; -#else -using Harmony; -#endif -using System; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using StardewModdingAPI.Framework.Patching; -using StardewValley; - -namespace StardewModdingAPI.Mods.ErrorHandler.Patches -{ - /// <summary>A Harmony patch for <see cref="Utility"/> methods to log more detailed errors.</summary> - /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks> - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - internal class UtilityErrorPatches : IHarmonyPatch - { - /********* - ** Public methods - *********/ - /// <inheritdoc /> -#if HARMONY_2 - public void Apply(Harmony harmony) - { - harmony.Patch( - original: AccessTools.Method(typeof(Utility), nameof(Utility.getItemFromStandardTextDescription)), - finalizer: new HarmonyMethod(this.GetType(), nameof(UtilityErrorPatches.Finalize_Utility_GetItemFromStandardTextDescription)) - ); - } -#else - public void Apply(HarmonyInstance harmony) - { - harmony.Patch( - original: AccessTools.Method(typeof(Utility), nameof(Utility.getItemFromStandardTextDescription)), - prefix: new HarmonyMethod(this.GetType(), nameof(UtilityErrorPatches.Before_Utility_GetItemFromStandardTextDescription)) - ); - } -#endif - - - /********* - ** Private methods - *********/ -#if HARMONY_2 - /// <summary>The method to call instead of <see cref="Utility.getItemFromStandardTextDescription"/>.</summary> - /// <param name="description">The item text description to parse.</param> - /// <param name="delimiter">The delimiter by which to split the text description.</param> - /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> - /// <returns>Returns the exception to throw, if any.</returns> - private static Exception Finalize_Utility_GetItemFromStandardTextDescription(string description, char delimiter, ref Exception __exception) - { - return __exception != null - ? new FormatException($"Failed to parse item text description \"{description}\" with delimiter \"{delimiter}\".", __exception) - : null; - } -#else - /// <summary>The method to call instead of <see cref="Utility.getItemFromStandardTextDescription"/>.</summary> - /// <param name="__result">The return value of the original method.</param> - /// <param name="description">The item text description to parse.</param> - /// <param name="who">The player for which the item is being parsed.</param> - /// <param name="delimiter">The delimiter by which to split the text description.</param> - /// <param name="__originalMethod">The method being wrapped.</param> - /// <returns>Returns whether to execute the original method.</returns> - private static bool Before_Utility_GetItemFromStandardTextDescription(ref Item __result, string description, Farmer who, char delimiter, MethodInfo __originalMethod) - { - const string key = nameof(UtilityErrorPatches.Before_Utility_GetItemFromStandardTextDescription); - if (!PatchHelper.StartIntercept(key)) - return true; - - try - { - __result = (Item)__originalMethod.Invoke(null, new object[] { description, who, delimiter }); - return false; - } - catch (TargetInvocationException ex) - { - throw new FormatException($"Failed to parse item text description \"{description}\" with delimiter \"{delimiter}\".", ex.InnerException); - } - finally - { - PatchHelper.StopIntercept(key); - } - } -#endif - } -} diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/UtilityPatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/UtilityPatcher.cs new file mode 100644 index 00000000..ce85d0c2 --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/Patches/UtilityPatcher.cs @@ -0,0 +1,43 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using HarmonyLib; +using StardewModdingAPI.Internal.Patching; +using StardewValley; + +namespace StardewModdingAPI.Mods.ErrorHandler.Patches +{ + /// <summary>A Harmony patch for <see cref="Utility"/> methods to log more detailed errors.</summary> + /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks> + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] + internal class UtilityPatcher : BasePatcher + { + /********* + ** Public methods + *********/ + /// <inheritdoc /> + public override void Apply(Harmony harmony, IMonitor monitor) + { + harmony.Patch( + original: this.RequireMethod<Utility>(nameof(Utility.getItemFromStandardTextDescription)), + finalizer: this.GetHarmonyMethod(nameof(UtilityPatcher.Finalize_GetItemFromStandardTextDescription)) + ); + } + + + /********* + ** Private methods + *********/ + /// <summary>The method to call when <see cref="Utility.getItemFromStandardTextDescription"/> throws an exception.</summary> + /// <param name="description">The item text description to parse.</param> + /// <param name="delimiter">The delimiter by which to split the text description.</param> + /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> + /// <returns>Returns the exception to throw, if any.</returns> + private static Exception Finalize_GetItemFromStandardTextDescription(string description, char delimiter, ref Exception __exception) + { + return __exception != null + ? new FormatException($"Failed to parse item text description \"{description}\" with delimiter \"{delimiter}\".", __exception) + : null; + } + } +} diff --git a/src/SMAPI.Mods.ErrorHandler/SMAPI.Mods.ErrorHandler.csproj b/src/SMAPI.Mods.ErrorHandler/SMAPI.Mods.ErrorHandler.csproj index 006a09ca..ffda5f89 100644 --- a/src/SMAPI.Mods.ErrorHandler/SMAPI.Mods.ErrorHandler.csproj +++ b/src/SMAPI.Mods.ErrorHandler/SMAPI.Mods.ErrorHandler.csproj @@ -15,6 +15,7 @@ <ItemGroup> <Reference Include="$(GameExecutableName)" HintPath="$(GamePath)\$(GameExecutableName).exe" Private="False" /> + <Reference Include="StardewValley.GameData" HintPath="$(GamePath)\StardewValley.GameData.dll" Private="False" /> <Reference Include="xTile" HintPath="$(GamePath)\xTile.dll" Private="False" /> </ItemGroup> @@ -46,4 +47,5 @@ </ItemGroup> <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> + <Import Project="..\SMAPI.Internal.Patching\SMAPI.Internal.Patching.projitems" Label="Shared" /> </Project> diff --git a/src/SMAPI.Mods.ErrorHandler/manifest.json b/src/SMAPI.Mods.ErrorHandler/manifest.json index 82e6152d..c83ca3d0 100644 --- a/src/SMAPI.Mods.ErrorHandler/manifest.json +++ b/src/SMAPI.Mods.ErrorHandler/manifest.json @@ -1,9 +1,9 @@ { "Name": "Error Handler", "Author": "SMAPI", - "Version": "3.11.0", + "Version": "3.12.0", "Description": "Handles some common vanilla errors to log more useful info or avoid breaking the game.", "UniqueID": "SMAPI.ErrorHandler", "EntryDll": "ErrorHandler.dll", - "MinimumApiVersion": "3.11.0" + "MinimumApiVersion": "3.12.0" } |