summaryrefslogtreecommitdiff
path: root/src/SMAPI.Mods.ErrorHandler
diff options
context:
space:
mode:
Diffstat (limited to 'src/SMAPI.Mods.ErrorHandler')
-rw-r--r--src/SMAPI.Mods.ErrorHandler/ModEntry.cs73
-rw-r--r--src/SMAPI.Mods.ErrorHandler/Patches/DialogueErrorPatch.cs191
-rw-r--r--src/SMAPI.Mods.ErrorHandler/Patches/EventErrorPatch.cs114
-rw-r--r--src/SMAPI.Mods.ErrorHandler/Patches/LoadErrorPatch.cs157
-rw-r--r--src/SMAPI.Mods.ErrorHandler/Patches/ObjectErrorPatch.cs143
-rw-r--r--src/SMAPI.Mods.ErrorHandler/Patches/ScheduleErrorPatch.cs115
-rw-r--r--src/SMAPI.Mods.ErrorHandler/SMAPI.Mods.ErrorHandler.csproj46
-rw-r--r--src/SMAPI.Mods.ErrorHandler/i18n/de.json4
-rw-r--r--src/SMAPI.Mods.ErrorHandler/i18n/default.json4
-rw-r--r--src/SMAPI.Mods.ErrorHandler/i18n/es.json4
-rw-r--r--src/SMAPI.Mods.ErrorHandler/i18n/fr.json4
-rw-r--r--src/SMAPI.Mods.ErrorHandler/i18n/hu.json4
-rw-r--r--src/SMAPI.Mods.ErrorHandler/i18n/it.json4
-rw-r--r--src/SMAPI.Mods.ErrorHandler/i18n/ja.json4
-rw-r--r--src/SMAPI.Mods.ErrorHandler/i18n/ko.json4
-rw-r--r--src/SMAPI.Mods.ErrorHandler/i18n/pt.json4
-rw-r--r--src/SMAPI.Mods.ErrorHandler/i18n/ru.json4
-rw-r--r--src/SMAPI.Mods.ErrorHandler/i18n/tr.json4
-rw-r--r--src/SMAPI.Mods.ErrorHandler/i18n/zh.json4
-rw-r--r--src/SMAPI.Mods.ErrorHandler/manifest.json9
20 files changed, 896 insertions, 0 deletions
diff --git a/src/SMAPI.Mods.ErrorHandler/ModEntry.cs b/src/SMAPI.Mods.ErrorHandler/ModEntry.cs
new file mode 100644
index 00000000..fa9338f4
--- /dev/null
+++ b/src/SMAPI.Mods.ErrorHandler/ModEntry.cs
@@ -0,0 +1,73 @@
+using System.Reflection;
+using StardewModdingAPI.Events;
+using StardewModdingAPI.Framework;
+using StardewModdingAPI.Framework.Logging;
+using StardewModdingAPI.Framework.Patching;
+using StardewModdingAPI.Mods.ErrorHandler.Patches;
+using StardewValley;
+
+namespace StardewModdingAPI.Mods.ErrorHandler
+{
+ /// <summary>The main entry point for the mod.</summary>
+ public class ModEntry : Mod
+ {
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Whether custom content was removed from the save data to avoid a crash.</summary>
+ private bool IsSaveContentRemoved;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>The mod entry point, called after the mod is first loaded.</summary>
+ /// <param name="helper">Provides simplified APIs for writing mods.</param>
+ public override void Entry(IModHelper helper)
+ {
+ // get SMAPI core types
+ 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. Error-handling patches won't be applied.", LogLevel.Error);
+ return;
+ }
+
+ // apply patches
+ new GamePatcher(this.Monitor).Apply(
+ new EventErrorPatch(logManager.MonitorForGame),
+ new DialogueErrorPatch(logManager.MonitorForGame, this.Helper.Reflection),
+ new ObjectErrorPatch(),
+ new LoadErrorPatch(this.Monitor, this.OnSaveContentRemoved),
+ new ScheduleErrorPatch(logManager.MonitorForGame)
+ );
+
+ // hook events
+ this.Helper.Events.GameLoop.SaveLoaded += this.OnSaveLoaded;
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Raised after custom content is removed from the save data to avoid a crash.</summary>
+ internal void OnSaveContentRemoved()
+ {
+ this.IsSaveContentRemoved = true;
+ }
+
+ /// <summary>The method invoked when a save is loaded.</summary>
+ /// <param name="sender">The event sender.</param>
+ /// <param name="e">The event arguments.</param>
+ public void OnSaveLoaded(object sender, SaveLoadedEventArgs e)
+ {
+ // show in-game warning for removed save content
+ if (this.IsSaveContentRemoved)
+ {
+ this.IsSaveContentRemoved = false;
+ Game1.addHUDMessage(new HUDMessage(this.Helper.Translation.Get("warn.invalid-content-removed"), HUDMessage.error_type));
+ }
+ }
+ }
+}
diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/DialogueErrorPatch.cs b/src/SMAPI.Mods.ErrorHandler/Patches/DialogueErrorPatch.cs
new file mode 100644
index 00000000..ba0ca582
--- /dev/null
+++ b/src/SMAPI.Mods.ErrorHandler/Patches/DialogueErrorPatch.cs
@@ -0,0 +1,191 @@
+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;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <inheritdoc />
+ public string Name => nameof(DialogueErrorPatch);
+
+
+ /*********
+ ** 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/EventErrorPatch.cs b/src/SMAPI.Mods.ErrorHandler/Patches/EventErrorPatch.cs
new file mode 100644
index 00000000..fabc6cad
--- /dev/null
+++ b/src/SMAPI.Mods.ErrorHandler/Patches/EventErrorPatch.cs
@@ -0,0 +1,114 @@
+using System.Diagnostics.CodeAnalysis;
+#if HARMONY_2
+using System;
+using HarmonyLib;
+#else
+using System.Reflection;
+using Harmony;
+#endif
+using StardewModdingAPI.Framework.Patching;
+using StardewValley;
+
+namespace StardewModdingAPI.Mods.ErrorHandler.Patches
+{
+ /// <summary>A Harmony patch for <see cref="GameLocation.checkEventPrecondition"/> which intercepts invalid preconditions 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 EventErrorPatch : IHarmonyPatch
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>Writes messages to the console and log file on behalf of the game.</summary>
+ private static IMonitor MonitorForGame;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <inheritdoc />
+ public string Name => nameof(EventErrorPatch);
+
+
+ /*********
+ ** 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 EventErrorPatch(IMonitor monitorForGame)
+ {
+ EventErrorPatch.MonitorForGame = monitorForGame;
+ }
+
+ /// <inheritdoc />
+#if HARMONY_2
+ public void Apply(Harmony harmony)
+ {
+ harmony.Patch(
+ original: AccessTools.Method(typeof(GameLocation), "checkEventPrecondition"),
+ finalizer: new HarmonyMethod(this.GetType(), nameof(EventErrorPatch.Finalize_GameLocation_CheckEventPrecondition))
+ );
+ }
+#else
+ public void Apply(HarmonyInstance harmony)
+ {
+ harmony.Patch(
+ original: AccessTools.Method(typeof(GameLocation), "checkEventPrecondition"),
+ prefix: new HarmonyMethod(this.GetType(), nameof(EventErrorPatch.Before_GameLocation_CheckEventPrecondition))
+ );
+ }
+#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 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(EventErrorPatch.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;
+ EventErrorPatch.MonitorForGame.Log($"Failed parsing event precondition ({precondition}):\n{ex.InnerException}", LogLevel.Error);
+ return false;
+ }
+ finally
+ {
+ PatchHelper.StopIntercept(key);
+ }
+ }
+#endif
+ }
+}
diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/LoadErrorPatch.cs b/src/SMAPI.Mods.ErrorHandler/Patches/LoadErrorPatch.cs
new file mode 100644
index 00000000..2227ea07
--- /dev/null
+++ b/src/SMAPI.Mods.ErrorHandler/Patches/LoadErrorPatch.cs
@@ -0,0 +1,157 @@
+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;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <inheritdoc />
+ public string Name => nameof(LoadErrorPatch);
+
+
+ /*********
+ ** 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/ObjectErrorPatch.cs b/src/SMAPI.Mods.ErrorHandler/Patches/ObjectErrorPatch.cs
new file mode 100644
index 00000000..70f054cd
--- /dev/null
+++ b/src/SMAPI.Mods.ErrorHandler/Patches/ObjectErrorPatch.cs
@@ -0,0 +1,143 @@
+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
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <inheritdoc />
+ public string Name => nameof(ObjectErrorPatch);
+
+
+ /*********
+ ** 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/ScheduleErrorPatch.cs b/src/SMAPI.Mods.ErrorHandler/Patches/ScheduleErrorPatch.cs
new file mode 100644
index 00000000..abbd1a8f
--- /dev/null
+++ b/src/SMAPI.Mods.ErrorHandler/Patches/ScheduleErrorPatch.cs
@@ -0,0 +1,115 @@
+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;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <inheritdoc />
+ public string Name => nameof(ScheduleErrorPatch);
+
+
+ /*********
+ ** 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/SMAPI.Mods.ErrorHandler.csproj b/src/SMAPI.Mods.ErrorHandler/SMAPI.Mods.ErrorHandler.csproj
new file mode 100644
index 00000000..5c0cf952
--- /dev/null
+++ b/src/SMAPI.Mods.ErrorHandler/SMAPI.Mods.ErrorHandler.csproj
@@ -0,0 +1,46 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <AssemblyName>ErrorHandler</AssemblyName>
+ <RootNamespace>StardewModdingAPI.Mods.ErrorHandler</RootNamespace>
+ <TargetFramework>net45</TargetFramework>
+ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
+ <PlatformTarget>x86</PlatformTarget>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\SMAPI\SMAPI.csproj" Private="False" />
+ <Reference Include="..\..\build\0Harmony.dll" Private="False" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <Reference Include="$(GameExecutableName)" HintPath="$(GamePath)\$(GameExecutableName).exe" Private="False" />
+ </ItemGroup>
+
+ <Choose>
+ <!-- Windows -->
+ <When Condition="$(OS) == 'Windows_NT'">
+ <ItemGroup>
+ <Reference Include="Netcode" HintPath="$(GamePath)\Netcode.dll" Private="False" />
+ <Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="False" />
+ <Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="False" />
+ <Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="False" />
+ <Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="False" />
+ </ItemGroup>
+ </When>
+
+ <!-- Linux/Mac -->
+ <Otherwise>
+ <ItemGroup>
+ <Reference Include="MonoGame.Framework" HintPath="$(GamePath)\MonoGame.Framework.dll" Private="False" />
+ </ItemGroup>
+ </Otherwise>
+ </Choose>
+
+ <ItemGroup>
+ <None Update="i18n\*.json" CopyToOutputDirectory="PreserveNewest" />
+ <None Update="manifest.json" CopyToOutputDirectory="PreserveNewest" />
+ </ItemGroup>
+
+ <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" />
+ <Import Project="..\..\build\common.targets" />
+</Project>
diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/de.json b/src/SMAPI.Mods.ErrorHandler/i18n/de.json
new file mode 100644
index 00000000..1de6301c
--- /dev/null
+++ b/src/SMAPI.Mods.ErrorHandler/i18n/de.json
@@ -0,0 +1,4 @@
+{
+ // warning messages
+ "warn.invalid-content-removed": "Ungültiger Inhalt wurde entfernt, um einen Absturz zu verhindern (siehe SMAPI Konsole für weitere Informationen)."
+}
diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/default.json b/src/SMAPI.Mods.ErrorHandler/i18n/default.json
new file mode 100644
index 00000000..b74dcea0
--- /dev/null
+++ b/src/SMAPI.Mods.ErrorHandler/i18n/default.json
@@ -0,0 +1,4 @@
+{
+ // warning messages
+ "warn.invalid-content-removed": "Invalid content was removed to prevent a crash (see the SMAPI console for info)."
+}
diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/es.json b/src/SMAPI.Mods.ErrorHandler/i18n/es.json
new file mode 100644
index 00000000..8ba10b70
--- /dev/null
+++ b/src/SMAPI.Mods.ErrorHandler/i18n/es.json
@@ -0,0 +1,4 @@
+{
+ // warning messages
+ "warn.invalid-content-removed": "Se ha quitado contenido inválido para evitar un cierre forzoso (revisa la consola de SMAPI para más información)."
+}
diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/fr.json b/src/SMAPI.Mods.ErrorHandler/i18n/fr.json
new file mode 100644
index 00000000..76978526
--- /dev/null
+++ b/src/SMAPI.Mods.ErrorHandler/i18n/fr.json
@@ -0,0 +1,4 @@
+{
+ // warning messages
+ "warn.invalid-content-removed": "Le contenu non valide a été supprimé afin d'éviter un plantage (voir la console de SMAPI pour plus d'informations)."
+}
diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/hu.json b/src/SMAPI.Mods.ErrorHandler/i18n/hu.json
new file mode 100644
index 00000000..92aca7d0
--- /dev/null
+++ b/src/SMAPI.Mods.ErrorHandler/i18n/hu.json
@@ -0,0 +1,4 @@
+{
+ // warning messages
+ "warn.invalid-content-removed": "Érvénytelen elemek kerültek eltávolításra, hogy a játék ne omoljon össze (további információk a SMAPI konzolon)."
+}
diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/it.json b/src/SMAPI.Mods.ErrorHandler/i18n/it.json
new file mode 100644
index 00000000..5182972e
--- /dev/null
+++ b/src/SMAPI.Mods.ErrorHandler/i18n/it.json
@@ -0,0 +1,4 @@
+{
+ // warning messages
+ "warn.invalid-content-removed": "Contenuto non valido rimosso per prevenire un crash (Guarda la console di SMAPI per maggiori informazioni)."
+}
diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/ja.json b/src/SMAPI.Mods.ErrorHandler/i18n/ja.json
new file mode 100644
index 00000000..559c7fbe
--- /dev/null
+++ b/src/SMAPI.Mods.ErrorHandler/i18n/ja.json
@@ -0,0 +1,4 @@
+{
+ // warning messages
+ "warn.invalid-content-removed": "クラッシュを防ぐために無効なコンテンツを取り除きました (詳細はSMAPIコンソールを参照)"
+}
diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/ko.json b/src/SMAPI.Mods.ErrorHandler/i18n/ko.json
new file mode 100644
index 00000000..48f05c26
--- /dev/null
+++ b/src/SMAPI.Mods.ErrorHandler/i18n/ko.json
@@ -0,0 +1,4 @@
+{
+ // warning messages
+ "warn.invalid-content-removed": "충돌을 방지하기 위해 잘못된 컨텐츠가 제거되었습니다 (자세한 내용은 SMAPI 콘솔 참조)."
+}
diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/pt.json b/src/SMAPI.Mods.ErrorHandler/i18n/pt.json
new file mode 100644
index 00000000..8ea8cec9
--- /dev/null
+++ b/src/SMAPI.Mods.ErrorHandler/i18n/pt.json
@@ -0,0 +1,4 @@
+{
+ // warning messages
+ "warn.invalid-content-removed": "Conteúdo inválido foi removido para prevenir uma falha (veja o console do SMAPI para mais informações)."
+}
diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/ru.json b/src/SMAPI.Mods.ErrorHandler/i18n/ru.json
new file mode 100644
index 00000000..e9c3b313
--- /dev/null
+++ b/src/SMAPI.Mods.ErrorHandler/i18n/ru.json
@@ -0,0 +1,4 @@
+{
+ // warning messages
+ "warn.invalid-content-removed": "Недопустимое содержимое было удалено, чтобы предотвратить сбой (см. информацию в консоли SMAPI)"
+}
diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/tr.json b/src/SMAPI.Mods.ErrorHandler/i18n/tr.json
new file mode 100644
index 00000000..a05ab152
--- /dev/null
+++ b/src/SMAPI.Mods.ErrorHandler/i18n/tr.json
@@ -0,0 +1,4 @@
+{
+ // warning messages
+ "warn.invalid-content-removed": "Yanlış paketlenmiş bir içerik, oyunun çökmemesi için yüklenmedi (SMAPI konsol penceresinde detaylı bilgi mevcut)."
+}
diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/zh.json b/src/SMAPI.Mods.ErrorHandler/i18n/zh.json
new file mode 100644
index 00000000..e959aa40
--- /dev/null
+++ b/src/SMAPI.Mods.ErrorHandler/i18n/zh.json
@@ -0,0 +1,4 @@
+{
+ // warning messages
+ "warn.invalid-content-removed": "非法内容已移除以防游戏闪退(查看SMAPI控制台获得更多信息)"
+}
diff --git a/src/SMAPI.Mods.ErrorHandler/manifest.json b/src/SMAPI.Mods.ErrorHandler/manifest.json
new file mode 100644
index 00000000..f83e2f96
--- /dev/null
+++ b/src/SMAPI.Mods.ErrorHandler/manifest.json
@@ -0,0 +1,9 @@
+{
+ "Name": "Error Handler",
+ "Author": "SMAPI",
+ "Version": "3.8.3",
+ "Description": "Handles some common vanilla errors to log more useful info or avoid breaking the game.",
+ "UniqueID": "SMAPI.ErrorHandler",
+ "EntryDll": "ErrorHandler.dll",
+ "MinimumApiVersion": "3.8.3"
+}