diff options
Diffstat (limited to 'src')
61 files changed, 1415 insertions, 1191 deletions
diff --git a/src/SMAPI.Installer/InteractiveInstaller.cs b/src/SMAPI.Installer/InteractiveInstaller.cs index 55e9c064..ab07c864 100644 --- a/src/SMAPI.Installer/InteractiveInstaller.cs +++ b/src/SMAPI.Installer/InteractiveInstaller.cs @@ -9,6 +9,7 @@ using StardewModdingApi.Installer.Enums; using StardewModdingAPI.Installer.Framework; using StardewModdingAPI.Internal.ConsoleWriting; using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework; using StardewModdingAPI.Toolkit.Framework.ModScanning; using StardewModdingAPI.Toolkit.Utilities; @@ -571,7 +572,7 @@ namespace StardewModdingApi.Installer /// <param name="executablePath">The absolute path to the executable file.</param> private bool Is64Bit(string executablePath) { - return AssemblyName.GetAssemblyName(executablePath).ProcessorArchitecture != ProcessorArchitecture.X86; + return LowLevelEnvironmentUtility.Is64BitAssembly(executablePath); } /// <summary>Get the display text for a color scheme.</summary> diff --git a/src/SMAPI.Internal.Patching/BasePatcher.cs b/src/SMAPI.Internal.Patching/BasePatcher.cs new file mode 100644 index 00000000..87155d7f --- /dev/null +++ b/src/SMAPI.Internal.Patching/BasePatcher.cs @@ -0,0 +1,54 @@ +using System; +using System.Reflection; +using HarmonyLib; + +namespace StardewModdingAPI.Internal.Patching +{ + /// <summary>Provides base implementation logic for <see cref="IPatcher"/> instances.</summary> + internal abstract class BasePatcher : IPatcher + { + /********* + ** Public methods + *********/ + /// <inheritdoc /> + public abstract void Apply(Harmony harmony, IMonitor monitor); + + + /********* + ** Protected methods + *********/ + /// <summary>Get a method and assert that it was found.</summary> + /// <typeparam name="TTarget">The type containing the method.</typeparam> + /// <param name="parameters">The method parameter types, or <c>null</c> if it's not overloaded.</param> + protected ConstructorInfo RequireConstructor<TTarget>(params Type[] parameters) + { + return PatchHelper.RequireConstructor<TTarget>(parameters); + } + + /// <summary>Get a method and assert that it was found.</summary> + /// <typeparam name="TTarget">The type containing the method.</typeparam> + /// <param name="name">The method name.</param> + /// <param name="parameters">The method parameter types, or <c>null</c> if it's not overloaded.</param> + /// <param name="generics">The method generic types, or <c>null</c> if it's not generic.</param> + protected MethodInfo RequireMethod<TTarget>(string name, Type[] parameters = null, Type[] generics = null) + { + return PatchHelper.RequireMethod<TTarget>(name, parameters, generics); + } + + /// <summary>Get a Harmony patch method on the current patcher instance.</summary> + /// <param name="name">The method name.</param> + /// <param name="priority">The patch priority to apply, usually specified using Harmony's <see cref="Priority"/> enum, or <c>null</c> to keep the default value.</param> + protected HarmonyMethod GetHarmonyMethod(string name, int? priority = null) + { + var method = new HarmonyMethod( + AccessTools.Method(this.GetType(), name) + ?? throw new InvalidOperationException($"Can't find patcher method {PatchHelper.GetMethodString(this.GetType(), name)}.") + ); + + if (priority.HasValue) + method.priority = priority.Value; + + return method; + } + } +} diff --git a/src/SMAPI.Internal.Patching/HarmonyPatcher.cs b/src/SMAPI.Internal.Patching/HarmonyPatcher.cs new file mode 100644 index 00000000..c07e3b41 --- /dev/null +++ b/src/SMAPI.Internal.Patching/HarmonyPatcher.cs @@ -0,0 +1,36 @@ +using System; +using HarmonyLib; + +namespace StardewModdingAPI.Internal.Patching +{ + /// <summary>Simplifies applying <see cref="IPatcher"/> instances to the game.</summary> + internal static class HarmonyPatcher + { + /********* + ** Public methods + *********/ + /// <summary>Apply the given Harmony patchers.</summary> + /// <param name="id">The mod ID applying the patchers.</param> + /// <param name="monitor">The monitor with which to log any errors.</param> + /// <param name="patchers">The patchers to apply.</param> + public static Harmony Apply(string id, IMonitor monitor, params IPatcher[] patchers) + { + Harmony harmony = new Harmony(id); + + foreach (IPatcher patcher in patchers) + { + try + { + patcher.Apply(harmony, monitor); + } + catch (Exception ex) + { + monitor.Log($"Couldn't apply runtime patch '{patcher.GetType().Name}' to the game. Some SMAPI features may not work correctly. See log file for details.", LogLevel.Error); + monitor.Log($"Technical details:\n{ex.GetLogSummary()}"); + } + } + + return harmony; + } + } +} diff --git a/src/SMAPI.Internal.Patching/IPatcher.cs b/src/SMAPI.Internal.Patching/IPatcher.cs new file mode 100644 index 00000000..a732d64f --- /dev/null +++ b/src/SMAPI.Internal.Patching/IPatcher.cs @@ -0,0 +1,16 @@ +using HarmonyLib; + +namespace StardewModdingAPI.Internal.Patching +{ + /// <summary>A set of Harmony patches to apply.</summary> + internal interface IPatcher + { + /********* + ** Public methods + *********/ + /// <summary>Apply the Harmony patches for this instance.</summary> + /// <param name="harmony">The Harmony instance.</param> + /// <param name="monitor">The monitor with which to log any errors.</param> + public void Apply(Harmony harmony, IMonitor monitor); + } +} diff --git a/src/SMAPI.Internal.Patching/PatchHelper.cs b/src/SMAPI.Internal.Patching/PatchHelper.cs new file mode 100644 index 00000000..fc79ddf2 --- /dev/null +++ b/src/SMAPI.Internal.Patching/PatchHelper.cs @@ -0,0 +1,77 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Text; +using HarmonyLib; + +namespace StardewModdingAPI.Internal.Patching +{ + /// <summary>Provides utility methods for patching game code with Harmony.</summary> + internal static class PatchHelper + { + /********* + ** Public methods + *********/ + /// <summary>Get a constructor and assert that it was found.</summary> + /// <typeparam name="TTarget">The type containing the method.</typeparam> + /// <param name="parameters">The method parameter types, or <c>null</c> if it's not overloaded.</param> + /// <exception cref="InvalidOperationException">The type has no matching constructor.</exception> + public static ConstructorInfo RequireConstructor<TTarget>(Type[] parameters = null) + { + return + AccessTools.Constructor(typeof(TTarget), parameters) + ?? throw new InvalidOperationException($"Can't find constructor {PatchHelper.GetMethodString(typeof(TTarget), null, parameters)} to patch."); + } + + /// <summary>Get a method and assert that it was found.</summary> + /// <typeparam name="TTarget">The type containing the method.</typeparam> + /// <param name="name">The method name.</param> + /// <param name="parameters">The method parameter types, or <c>null</c> if it's not overloaded.</param> + /// <param name="generics">The method generic types, or <c>null</c> if it's not generic.</param> + /// <exception cref="InvalidOperationException">The type has no matching method.</exception> + public static MethodInfo RequireMethod<TTarget>(string name, Type[] parameters = null, Type[] generics = null) + { + return + AccessTools.Method(typeof(TTarget), name, parameters, generics) + ?? throw new InvalidOperationException($"Can't find method {PatchHelper.GetMethodString(typeof(TTarget), name, parameters, generics)} to patch."); + } + + /// <summary>Get a human-readable representation of a method target.</summary> + /// <param name="type">The type containing the method.</param> + /// <param name="name">The method name, or <c>null</c> for a constructor.</param> + /// <param name="parameters">The method parameter types, or <c>null</c> if it's not overloaded.</param> + /// <param name="generics">The method generic types, or <c>null</c> if it's not generic.</param> + public static string GetMethodString(Type type, string name, Type[] parameters = null, Type[] generics = null) + { + StringBuilder str = new StringBuilder(); + + // type + str.Append(type.FullName); + + // method name (if not constructor) + if (name != null) + { + str.Append('.'); + str.Append(name); + } + + // generics + if (generics?.Any() == true) + { + str.Append('<'); + str.Append(string.Join(", ", generics.Select(p => p.FullName))); + str.Append('>'); + } + + // parameters + if (parameters?.Any() == true) + { + str.Append('('); + str.Append(string.Join(", ", parameters.Select(p => p.FullName))); + str.Append(')'); + } + + return str.ToString(); + } + } +} diff --git a/src/SMAPI.Internal.Patching/SMAPI.Internal.Patching.projitems b/src/SMAPI.Internal.Patching/SMAPI.Internal.Patching.projitems new file mode 100644 index 00000000..4fa2a062 --- /dev/null +++ b/src/SMAPI.Internal.Patching/SMAPI.Internal.Patching.projitems @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <PropertyGroup> + <MSBuildAllProjects Condition="'$(MSBuildVersion)' == '' Or '$(MSBuildVersion)' < '16.0'">$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects> + <HasSharedItems>true</HasSharedItems> + <SharedGUID>6c16e948-3e5c-47a7-bf4b-07a7469a87a5</SharedGUID> + </PropertyGroup> + <PropertyGroup Label="Configuration"> + <Import_RootNamespace>SMAPI.Internal.Patching</Import_RootNamespace> + </PropertyGroup> + <ItemGroup> + <Compile Include="$(MSBuildThisFileDirectory)BasePatcher.cs" /> + <Compile Include="$(MSBuildThisFileDirectory)HarmonyPatcher.cs" /> + <Compile Include="$(MSBuildThisFileDirectory)IPatcher.cs" /> + <Compile Include="$(MSBuildThisFileDirectory)PatchHelper.cs" /> + </ItemGroup> +</Project>
\ No newline at end of file diff --git a/src/SMAPI.Internal.Patching/SMAPI.Internal.Patching.shproj b/src/SMAPI.Internal.Patching/SMAPI.Internal.Patching.shproj new file mode 100644 index 00000000..1a102c82 --- /dev/null +++ b/src/SMAPI.Internal.Patching/SMAPI.Internal.Patching.shproj @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <PropertyGroup Label="Globals"> + <ProjectGuid>6c16e948-3e5c-47a7-bf4b-07a7469a87a5</ProjectGuid> + <MinimumVisualStudioVersion>14.0</MinimumVisualStudioVersion> + </PropertyGroup> + <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> + <Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\CodeSharing\Microsoft.CodeSharing.Common.Default.props" /> + <Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\CodeSharing\Microsoft.CodeSharing.Common.props" /> + <PropertyGroup /> + <Import Project="SMAPI.Internal.Patching.projitems" Label="Shared" /> + <Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\CodeSharing\Microsoft.CodeSharing.CSharp.targets" /> +</Project> diff --git a/src/SMAPI.Internal/ExceptionExtensions.cs b/src/SMAPI.Internal/ExceptionExtensions.cs new file mode 100644 index 00000000..d7a2252b --- /dev/null +++ b/src/SMAPI.Internal/ExceptionExtensions.cs @@ -0,0 +1,41 @@ +using System; +using System.Reflection; + +namespace StardewModdingAPI.Internal +{ + /// <summary>Provides extension methods for handling exceptions.</summary> + internal static class ExceptionExtensions + { + /********* + ** Public methods + *********/ + /// <summary>Get a string representation of an exception suitable for writing to the error log.</summary> + /// <param name="exception">The error to summarize.</param> + public static string GetLogSummary(this Exception exception) + { + switch (exception) + { + case TypeLoadException ex: + return $"Failed loading type '{ex.TypeName}': {exception}"; + + case ReflectionTypeLoadException ex: + string summary = exception.ToString(); + foreach (Exception childEx in ex.LoaderExceptions) + summary += $"\n\n{childEx.GetLogSummary()}"; + return summary; + + default: + return exception.ToString(); + } + } + + /// <summary>Get the lowest exception in an exception stack.</summary> + /// <param name="exception">The exception from which to search.</param> + public static Exception GetInnermostException(this Exception exception) + { + while (exception.InnerException != null) + exception = exception.InnerException; + return exception; + } + } +} diff --git a/src/SMAPI.Internal/SMAPI.Internal.projitems b/src/SMAPI.Internal/SMAPI.Internal.projitems index 0d583a6d..0ee94a5b 100644 --- a/src/SMAPI.Internal/SMAPI.Internal.projitems +++ b/src/SMAPI.Internal/SMAPI.Internal.projitems @@ -14,5 +14,6 @@ <Compile Include="$(MSBuildThisFileDirectory)ConsoleWriting\ConsoleLogLevel.cs" /> <Compile Include="$(MSBuildThisFileDirectory)ConsoleWriting\IConsoleWriter.cs" /> <Compile Include="$(MSBuildThisFileDirectory)ConsoleWriting\MonitorColorScheme.cs" /> + <Compile Include="$(MSBuildThisFileDirectory)ExceptionExtensions.cs" /> </ItemGroup> </Project>
\ No newline at end of file diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs index 4cfaf242..ceaeb278 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs @@ -15,7 +15,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World ** Fields *********/ /// <summary>The valid types that can be cleared.</summary> - private readonly string[] ValidTypes = { "crops", "debris", "fruit-trees", "furniture", "grass", "trees", "everything" }; + private readonly string[] ValidTypes = { "crops", "debris", "fruit-trees", "furniture", "grass", "trees", "removable", "everything" }; /// <summary>The resource clump IDs to consider debris.</summary> private readonly int[] DebrisClumps = { ResourceClump.stumpIndex, ResourceClump.hollowLogIndex, ResourceClump.meteoriteIndex, ResourceClump.boulderIndex }; @@ -30,8 +30,8 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World name: "world_clear", description: "Clears in-game entities in a given location.\n\n" + "Usage: world_clear <location> <object type>\n" - + "- location: the location name for which to clear objects (like Farm), or 'current' for the current location.\n" - + " - object type: the type of object clear. You can specify 'crops', 'debris' (stones/twigs/weeds and dead crops), 'furniture', 'grass', and 'trees' / 'fruit-trees'. You can also specify 'everything', which includes things not removed by the other types (like resource clumps)." + + " - location: the location name for which to clear objects (like Farm), or 'current' for the current location.\n" + + " - object type: the type of object clear. You can specify 'crops', 'debris' (stones/twigs/weeds and dead crops), 'furniture', 'grass', and 'trees' / 'fruit-trees'. You can also specify 'removable' (remove everything that can be removed or destroyed during normal gameplay) or 'everything' (remove everything including permanent bushes)." ) { } @@ -133,13 +133,15 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World break; } + case "removable": case "everything": { + bool everything = type == "everything"; int removed = this.RemoveFurniture(location, p => true) + this.RemoveObjects(location, p => true) + this.RemoveTerrainFeatures(location, p => true) - + this.RemoveLargeTerrainFeatures(location, p => true) + + this.RemoveLargeTerrainFeatures(location, p => everything || !(p is Bush bush) || bush.isDestroyable(location, p.currentTileLocation)) + this.RemoveResourceClumps(location, p => true); monitor.Log($"Done! Removed {removed} entities from {location.Name}.", LogLevel.Info); break; diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json index 1781c40d..09684f32 100644 --- a/src/SMAPI.Mods.ConsoleCommands/manifest.json +++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json @@ -1,9 +1,9 @@ { "Name": "Console Commands", "Author": "SMAPI", - "Version": "3.11.0", + "Version": "3.12.0", "Description": "Adds SMAPI console commands that let you manipulate the game.", "UniqueID": "SMAPI.ConsoleCommands", "EntryDll": "ConsoleCommands.dll", - "MinimumApiVersion": "3.11.0" + "MinimumApiVersion": "3.12.0" } 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" } diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json index 6042dee4..42f1af59 100644 --- a/src/SMAPI.Mods.SaveBackup/manifest.json +++ b/src/SMAPI.Mods.SaveBackup/manifest.json @@ -1,9 +1,9 @@ { "Name": "Save Backup", "Author": "SMAPI", - "Version": "3.11.0", + "Version": "3.12.0", "Description": "Automatically backs up all your saves once per day into its folder.", "UniqueID": "SMAPI.SaveBackup", "EntryDll": "SaveBackup.dll", - "MinimumApiVersion": "3.11.0" + "MinimumApiVersion": "3.12.0" } diff --git a/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs b/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs index 8cbd8e51..be0c18ce 100644 --- a/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs +++ b/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs @@ -2,6 +2,7 @@ using System; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Reflection; #if SMAPI_FOR_WINDOWS using System.Management; #endif @@ -48,7 +49,6 @@ namespace StardewModdingAPI.Toolkit.Framework } } - /// <summary>Get the human-readable OS name and version.</summary> /// <param name="platform">The current platform.</param> [SuppressMessage("ReSharper", "EmptyGeneralCatchClause", Justification = "Error suppressed deliberately to fallback to default behaviour.")] @@ -89,6 +89,13 @@ namespace StardewModdingAPI.Toolkit.Framework : "StardewValley.exe"; } + /// <summary>Get whether an executable is 64-bit.</summary> + /// <param name="executablePath">The absolute path to the executable file.</param> + public static bool Is64BitAssembly(string executablePath) + { + return AssemblyName.GetAssemblyName(executablePath).ProcessorArchitecture != ProcessorArchitecture.X86; + } + /********* ** Private methods diff --git a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj index 8dc4d559..0e051c84 100644 --- a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj +++ b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj @@ -19,6 +19,4 @@ <ItemGroup> <ProjectReference Include="..\SMAPI.Toolkit.CoreInterfaces\SMAPI.Toolkit.CoreInterfaces.csproj" /> </ItemGroup> - - <Import Project="..\..\build\common.targets" /> </Project> diff --git a/src/SMAPI.sln b/src/SMAPI.sln index 58228ce9..92c6cb24 100644 --- a/src/SMAPI.sln +++ b/src/SMAPI.sln @@ -52,6 +52,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Mods", "Mods", "{AE9A4D46-E EndProject Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "SMAPI.Internal", "SMAPI.Internal\SMAPI.Internal.shproj", "{85208F8D-6FD1-4531-BE05-7142490F59FE}" EndProject +Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "SMAPI.Internal.Patching", "SMAPI.Internal.Patching\SMAPI.Internal.Patching.shproj", "{6C16E948-3E5C-47A7-BF4B-07A7469A87A5}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.ModBuildConfig.Analyzer.Tests", "SMAPI.ModBuildConfig.Analyzer.Tests\SMAPI.ModBuildConfig.Analyzer.Tests.csproj", "{680B2641-81EA-467C-86A5-0E81CDC57ED0}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Tests", "SMAPI.Tests\SMAPI.Tests.csproj", "{AA95884B-7097-476E-92C8-D0500DE9D6D1}" @@ -86,10 +88,13 @@ Global GlobalSection(SharedMSBuildProjectFiles) = preSolution SMAPI.Internal\SMAPI.Internal.projitems*{0634ea4c-3b8f-42db-aea6-ca9e4ef6e92f}*SharedItemsImports = 5 SMAPI.Internal\SMAPI.Internal.projitems*{0a9bb24f-15ff-4c26-b1a2-81f7ae316518}*SharedItemsImports = 5 + SMAPI.Internal.Patching\SMAPI.Internal.Patching.projitems*{491e775b-ead0-44d4-b6ca-f1fc3e316d33}*SharedItemsImports = 5 SMAPI.Internal\SMAPI.Internal.projitems*{491e775b-ead0-44d4-b6ca-f1fc3e316d33}*SharedItemsImports = 5 + SMAPI.Internal.Patching\SMAPI.Internal.Patching.projitems*{6c16e948-3e5c-47a7-bf4b-07a7469a87a5}*SharedItemsImports = 13 SMAPI.Internal\SMAPI.Internal.projitems*{80efd92f-728f-41e0-8a5b-9f6f49a91899}*SharedItemsImports = 5 SMAPI.Internal\SMAPI.Internal.projitems*{85208f8d-6fd1-4531-be05-7142490f59fe}*SharedItemsImports = 13 SMAPI.Internal\SMAPI.Internal.projitems*{cd53ad6f-97f4-4872-a212-50c2a0fd3601}*SharedItemsImports = 5 + SMAPI.Internal.Patching\SMAPI.Internal.Patching.projitems*{e6da2198-7686-4f1d-b312-4a4dc70884c0}*SharedItemsImports = 5 SMAPI.Internal\SMAPI.Internal.projitems*{e6da2198-7686-4f1d-b312-4a4dc70884c0}*SharedItemsImports = 5 EndGlobalSection GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -156,6 +161,7 @@ Global {EB35A917-67B9-4EFA-8DFC-4FB49B3949BB} = {86C452BE-D2D8-45B4-B63F-E329EB06CEDA} {5947303D-3512-413A-9009-7AC43F5D3513} = {EB35A917-67B9-4EFA-8DFC-4FB49B3949BB} {85208F8D-6FD1-4531-BE05-7142490F59FE} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11} + {6C16E948-3E5C-47A7-BF4B-07A7469A87A5} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11} {680B2641-81EA-467C-86A5-0E81CDC57ED0} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11} {AA95884B-7097-476E-92C8-D0500DE9D6D1} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11} {0634EA4C-3B8F-42DB-AEA6-CA9E4EF6E92F} = {AE9A4D46-E910-4293-8BC4-673F85FFF6A5} diff --git a/src/SMAPI.sln.DotSettings b/src/SMAPI.sln.DotSettings index 29d4ade5..9a6cad37 100644 --- a/src/SMAPI.sln.DotSettings +++ b/src/SMAPI.sln.DotSettings @@ -36,6 +36,7 @@ <s:Boolean x:Key="/Default/UserDictionary/Words/=fallbacks/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=filenames/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=gamepad/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=gameplay/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Hangfire/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=initializers/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Junimo/@EntryIndexedValue">True</s:Boolean> @@ -48,6 +49,7 @@ <s:Boolean x:Key="/Default/UserDictionary/Words/=Netcode/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=overworld/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Pastebin/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=pathfinding/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Pathoschild/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=premultiplied/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=premultiply/@EntryIndexedValue">True</s:Boolean> diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index 9e93551c..3877e17a 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -61,7 +61,7 @@ namespace StardewModdingAPI internal static int? LogScreenId { get; set; } /// <summary>SMAPI's current raw semantic version.</summary> - internal static string RawApiVersion = "3.11.0"; + internal static string RawApiVersion = "3.12.0"; } /// <summary>Contains SMAPI's constants and assumptions.</summary> @@ -351,9 +351,16 @@ namespace StardewModdingAPI DirectoryInfo folder = null; foreach (string saveName in new[] { rawSaveName, new string(rawSaveName.Where(char.IsLetterOrDigit).ToArray()) }) { - folder = new DirectoryInfo(Path.Combine(Constants.SavesPath, $"{saveName}_{saveID}")); - if (folder.Exists) - return folder; + try + { + folder = new DirectoryInfo(Path.Combine(Constants.SavesPath, $"{saveName}_{saveID}")); + if (folder.Exists) + return folder; + } + catch (ArgumentException) + { + // ignore invalid path + } } // if save doesn't exist yet, return the default one we expect to be created diff --git a/src/SMAPI/Framework/Commands/HarmonySummaryCommand.cs b/src/SMAPI/Framework/Commands/HarmonySummaryCommand.cs index f3731d16..45b34556 100644 --- a/src/SMAPI/Framework/Commands/HarmonySummaryCommand.cs +++ b/src/SMAPI/Framework/Commands/HarmonySummaryCommand.cs @@ -3,25 +3,13 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Text; -#if HARMONY_2 using HarmonyLib; -#else -using Harmony; -#endif namespace StardewModdingAPI.Framework.Commands { /// <summary>The 'harmony_summary' SMAPI console command.</summary> internal class HarmonySummaryCommand : IInternalCommand { -#if !HARMONY_2 - /********* - ** Fields - *********/ - /// <summary>The Harmony instance through which to fetch patch info.</summary> - private readonly HarmonyInstance HarmonyInstance = HarmonyInstance.Create($"SMAPI.{nameof(HarmonySummaryCommand)}"); -#endif - /********* ** Accessors *********/ @@ -60,9 +48,7 @@ namespace StardewModdingAPI.Framework.Commands { PatchType.Prefix => 0, PatchType.Postfix => 1, -#if HARMONY_2 PatchType.Finalizer => 2, -#endif PatchType.Transpiler => 3, _ => 4 }); @@ -111,26 +97,16 @@ namespace StardewModdingAPI.Framework.Commands /// <summary>Get all current Harmony patches.</summary> private IEnumerable<SearchResult> GetAllPatches() { -#if HARMONY_2 foreach (MethodBase method in Harmony.GetAllPatchedMethods().ToArray()) -#else - foreach (MethodBase method in this.HarmonyInstance.GetPatchedMethods().ToArray()) -#endif { // get metadata for method -#if HARMONY_2 HarmonyLib.Patches patchInfo = Harmony.GetPatchInfo(method); -#else - Harmony.Patches patchInfo = this.HarmonyInstance.GetPatchInfo(method); -#endif IDictionary<PatchType, IReadOnlyCollection<Patch>> patchGroups = new Dictionary<PatchType, IReadOnlyCollection<Patch>> { [PatchType.Prefix] = patchInfo.Prefixes, [PatchType.Postfix] = patchInfo.Postfixes, -#if HARMONY_2 [PatchType.Finalizer] = patchInfo.Finalizers, -#endif [PatchType.Transpiler] = patchInfo.Transpilers }; @@ -160,10 +136,8 @@ namespace StardewModdingAPI.Framework.Commands /// <summary>A postfix patch.</summary> Postfix, -#if HARMONY_2 /// <summary>A finalizer patch.</summary> Finalizer, -#endif /// <summary>A transpiler patch.</summary> Transpiler diff --git a/src/SMAPI/Framework/Content/AssetInterceptorChange.cs b/src/SMAPI/Framework/Content/AssetInterceptorChange.cs index 037d9f89..10488b84 100644 --- a/src/SMAPI/Framework/Content/AssetInterceptorChange.cs +++ b/src/SMAPI/Framework/Content/AssetInterceptorChange.cs @@ -1,5 +1,6 @@ using System; using System.Reflection; +using StardewModdingAPI.Internal; namespace StardewModdingAPI.Framework.Content { diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs index 80a9937a..63cd1759 100644 --- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs @@ -9,6 +9,7 @@ using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Utilities; +using StardewModdingAPI.Internal; using StardewValley; using xTile; diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index 4f6aa775..d24ffb81 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -7,6 +7,7 @@ using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Internal; using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; @@ -77,6 +78,8 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <inheritdoc /> public override T Load<T>(string assetName, LanguageCode language, bool useCache) { + // normalize key + bool isXnbFile = Path.GetExtension(assetName).ToLower() == ".xnb"; assetName = this.AssertAndNormalizeAssetName(assetName); // disable caching @@ -108,7 +111,7 @@ namespace StardewModdingAPI.Framework.ContentManagers try { // get file - FileInfo file = this.GetModFile(assetName); + FileInfo file = this.GetModFile(isXnbFile ? $"{assetName}.xnb" : assetName); // .xnb extension is stripped from asset names passed to the content manager if (!file.Exists) throw GetContentError("the specified path doesn't exist."); diff --git a/src/SMAPI/Framework/Events/ManagedEvent.cs b/src/SMAPI/Framework/Events/ManagedEvent.cs index 2204966c..fa20a079 100644 --- a/src/SMAPI/Framework/Events/ManagedEvent.cs +++ b/src/SMAPI/Framework/Events/ManagedEvent.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; using StardewModdingAPI.Events; +using StardewModdingAPI.Internal; namespace StardewModdingAPI.Framework.Events { diff --git a/src/SMAPI/Framework/InternalExtensions.cs b/src/SMAPI/Framework/InternalExtensions.cs index ab7f1e6c..6c9a5f3b 100644 --- a/src/SMAPI/Framework/InternalExtensions.cs +++ b/src/SMAPI/Framework/InternalExtensions.cs @@ -14,6 +14,9 @@ namespace StardewModdingAPI.Framework /// <summary>Provides extension methods for SMAPI's internal use.</summary> internal static class InternalExtensions { + /********* + ** Public methods + *********/ /**** ** IMonitor ****/ @@ -55,38 +58,6 @@ namespace StardewModdingAPI.Framework } /**** - ** Exceptions - ****/ - /// <summary>Get a string representation of an exception suitable for writing to the error log.</summary> - /// <param name="exception">The error to summarize.</param> - public static string GetLogSummary(this Exception exception) - { - switch (exception) - { - case TypeLoadException ex: - return $"Failed loading type '{ex.TypeName}': {exception}"; - - case ReflectionTypeLoadException ex: - string summary = exception.ToString(); - foreach (Exception childEx in ex.LoaderExceptions) - summary += $"\n\n{childEx.GetLogSummary()}"; - return summary; - - default: - return exception.ToString(); - } - } - - /// <summary>Get the lowest exception in an exception stack.</summary> - /// <param name="exception">The exception from which to search.</param> - public static Exception GetInnermostException(this Exception exception) - { - while (exception.InnerException != null) - exception = exception.InnerException; - return exception; - } - - /**** ** ReaderWriterLockSlim ****/ /// <summary>Run code within a read lock.</summary> diff --git a/src/SMAPI/Framework/Logging/LogManager.cs b/src/SMAPI/Framework/Logging/LogManager.cs index a4df3c18..a3d4f23d 100644 --- a/src/SMAPI/Framework/Logging/LogManager.cs +++ b/src/SMAPI/Framework/Logging/LogManager.cs @@ -3,11 +3,13 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; +using System.Text; using System.Text.RegularExpressions; using System.Threading; using StardewModdingAPI.Framework.Commands; using StardewModdingAPI.Framework.Models; using StardewModdingAPI.Framework.ModLoading; +using StardewModdingAPI.Internal; using StardewModdingAPI.Internal.ConsoleWriting; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Utilities; @@ -106,6 +108,10 @@ namespace StardewModdingAPI.Framework.Logging if (writeToConsole) output.OnMessageIntercepted += message => this.HandleConsoleMessage(this.MonitorForGame, message); Console.SetOut(output); + + // enable Unicode handling + Console.InputEncoding = Encoding.Unicode; + Console.OutputEncoding = Encoding.Unicode; } /// <summary>Get a monitor instance derived from SMAPI's current settings.</summary> @@ -258,7 +264,7 @@ namespace StardewModdingAPI.Framework.Logging break; // path too long exception - case PathTooLongException: + case PathTooLongException _: { string[] affectedPaths = PathUtilities.GetTooLongPaths(Constants.ModsPath).ToArray(); string message = affectedPaths.Any() diff --git a/src/SMAPI/Framework/ModLoading/RewriteFacades/AccessToolsFacade.cs b/src/SMAPI/Framework/ModLoading/RewriteFacades/AccessToolsFacade.cs index 102f3364..8e4320b3 100644 --- a/src/SMAPI/Framework/ModLoading/RewriteFacades/AccessToolsFacade.cs +++ b/src/SMAPI/Framework/ModLoading/RewriteFacades/AccessToolsFacade.cs @@ -1,4 +1,3 @@ -#if HARMONY_2 using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; @@ -41,4 +40,3 @@ namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades } } } -#endif diff --git a/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyInstanceFacade.cs b/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyInstanceFacade.cs index ad6d5e4f..54b91679 100644 --- a/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyInstanceFacade.cs +++ b/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyInstanceFacade.cs @@ -1,4 +1,3 @@ -#if HARMONY_2 using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; @@ -81,4 +80,3 @@ namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades } } } -#endif diff --git a/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyMethodFacade.cs b/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyMethodFacade.cs index f3975558..44c97401 100644 --- a/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyMethodFacade.cs +++ b/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyMethodFacade.cs @@ -1,4 +1,3 @@ -#if HARMONY_2 using System; using System.Diagnostics.CodeAnalysis; using System.Reflection; @@ -44,4 +43,3 @@ namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades } } } -#endif diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/Harmony1AssemblyRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/Harmony1AssemblyRewriter.cs index 4b3675bc..7a3b428d 100644 --- a/src/SMAPI/Framework/ModLoading/Rewriters/Harmony1AssemblyRewriter.cs +++ b/src/SMAPI/Framework/ModLoading/Rewriters/Harmony1AssemblyRewriter.cs @@ -1,4 +1,3 @@ -#if HARMONY_2 using System; using HarmonyLib; using Mono.Cecil; @@ -29,11 +28,11 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters public override bool Handle(ModuleDefinition module, TypeReference type, Action<TypeReference> replaceWith) { // rewrite Harmony 1.x type to Harmony 2.0 type - if (type.Scope is AssemblyNameReference scope && scope.Name == "0Harmony" && scope.Version.Major == 1) + if (type.Scope is AssemblyNameReference { Name: "0Harmony" } scope && scope.Version.Major == 1) { Type targetType = this.GetMappedType(type); replaceWith(module.ImportReference(targetType)); - this.MarkRewritten(); + this.OnChanged(); this.ReplacedTypes = true; return true; } @@ -42,19 +41,25 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters } /// <inheritdoc /> - public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) + public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction) { // rewrite Harmony 1.x methods to Harmony 2.0 MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); if (this.TryRewriteMethodsToFacade(module, methodRef)) + { + this.OnChanged(); return true; + } // rewrite renamed fields FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); if (fieldRef != null) { if (fieldRef.DeclaringType.FullName == "HarmonyLib.HarmonyMethod" && fieldRef.Name == "prioritiy") + { fieldRef.Name = nameof(HarmonyMethod.priority); + this.OnChanged(); + } } return false; @@ -64,6 +69,13 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters /********* ** Private methods *********/ + /// <summary>Update the mod metadata when any Harmony 1.x code is migrated.</summary> + private void OnChanged() + { + this.MarkRewritten(); + this.MarkFlag(InstructionHandleResult.DetectedGamePatch); + } + /// <summary>Rewrite methods to use Harmony facades if needed.</summary> /// <param name="module">The assembly module containing the method reference.</param> /// <param name="methodRef">The method reference to map.</param> @@ -73,24 +85,15 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters return false; // not Harmony (or already using Harmony 2.0) // get facade type - Type toType; - switch (methodRef?.DeclaringType.FullName) + Type toType = methodRef?.DeclaringType.FullName switch { - case "HarmonyLib.Harmony": - toType = typeof(HarmonyInstanceFacade); - break; - - case "HarmonyLib.AccessTools": - toType = typeof(AccessToolsFacade); - break; - - case "HarmonyLib.HarmonyMethod": - toType = typeof(HarmonyMethodFacade); - break; - - default: - return false; - } + "HarmonyLib.Harmony" => typeof(HarmonyInstanceFacade), + "HarmonyLib.AccessTools" => typeof(AccessToolsFacade), + "HarmonyLib.HarmonyMethod" => typeof(HarmonyMethodFacade), + _ => null + }; + if (toType == null) + return false; // map if there's a matching method if (RewriteHelper.HasMatchingSignature(toType, methodRef)) @@ -103,18 +106,24 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters } /// <summary>Get an equivalent Harmony 2.x type.</summary> - /// <param name="type">The Harmony 1.x method.</param> + /// <param name="type">The Harmony 1.x type.</param> private Type GetMappedType(TypeReference type) { - // main Harmony object - if (type.FullName == "Harmony.HarmonyInstance") - return typeof(Harmony); + return type.FullName switch + { + "Harmony.HarmonyInstance" => typeof(Harmony), + "Harmony.ILCopying.ExceptionBlock" => typeof(ExceptionBlock), + _ => this.GetMappedTypeByConvention(type) + }; + } - // other objects + /// <summary>Get an equivalent Harmony 2.x type using the convention expected for most types.</summary> + /// <param name="type">The Harmony 1.x type.</param> + private Type GetMappedTypeByConvention(TypeReference type) + { string fullName = type.FullName.Replace("Harmony.", "HarmonyLib."); - string targetName = typeof(Harmony).AssemblyQualifiedName.Replace(typeof(Harmony).FullName, fullName); + string targetName = typeof(Harmony).AssemblyQualifiedName!.Replace(typeof(Harmony).FullName!, fullName); return Type.GetType(targetName, throwOnError: true); } } } -#endif diff --git a/src/SMAPI/Framework/Patching/GamePatcher.cs b/src/SMAPI/Framework/Patching/GamePatcher.cs deleted file mode 100644 index ddecda08..00000000 --- a/src/SMAPI/Framework/Patching/GamePatcher.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -#if HARMONY_2 -using HarmonyLib; -#else -using Harmony; -#endif - -namespace StardewModdingAPI.Framework.Patching -{ - /// <summary>Encapsulates applying Harmony patches to the game.</summary> - internal class GamePatcher - { - /********* - ** Fields - *********/ - /// <summary>Encapsulates monitoring and logging.</summary> - private readonly IMonitor Monitor; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="monitor">Encapsulates monitoring and logging.</param> - public GamePatcher(IMonitor monitor) - { - this.Monitor = monitor; - } - - /// <summary>Apply all loaded patches to the game.</summary> - /// <param name="patches">The patches to apply.</param> - public void Apply(params IHarmonyPatch[] patches) - { -#if HARMONY_2 - Harmony harmony = new Harmony("SMAPI"); -#else - HarmonyInstance harmony = HarmonyInstance.Create("SMAPI"); -#endif - foreach (IHarmonyPatch patch in patches) - { - try - { - patch.Apply(harmony); - } - catch (Exception ex) - { - this.Monitor.Log($"Couldn't apply runtime patch '{patch.GetType().Name}' to the game. Some SMAPI features may not work correctly. See log file for details.", LogLevel.Error); - this.Monitor.Log(ex.GetLogSummary(), LogLevel.Trace); - } - } - } - } -} diff --git a/src/SMAPI/Framework/Patching/IHarmonyPatch.cs b/src/SMAPI/Framework/Patching/IHarmonyPatch.cs deleted file mode 100644 index 38d30ab2..00000000 --- a/src/SMAPI/Framework/Patching/IHarmonyPatch.cs +++ /dev/null @@ -1,23 +0,0 @@ -#if HARMONY_2 -using HarmonyLib; -#else -using Harmony; -#endif - -namespace StardewModdingAPI.Framework.Patching -{ - /// <summary>A Harmony patch to apply.</summary> - internal interface IHarmonyPatch - { - /********* - ** Methods - *********/ - /// <summary>Apply the Harmony patch.</summary> - /// <param name="harmony">The Harmony instance.</param> -#if HARMONY_2 - void Apply(Harmony harmony); -#else - void Apply(HarmonyInstance harmony); -#endif - } -} diff --git a/src/SMAPI/Framework/Patching/PatchHelper.cs b/src/SMAPI/Framework/Patching/PatchHelper.cs deleted file mode 100644 index d1aa0185..00000000 --- a/src/SMAPI/Framework/Patching/PatchHelper.cs +++ /dev/null @@ -1,36 +0,0 @@ -#if !HARMONY_2 -using System; -using System.Collections.Generic; - -namespace StardewModdingAPI.Framework.Patching -{ - /// <summary>Provides generic methods for implementing Harmony patches.</summary> - internal class PatchHelper - { - /********* - ** Fields - *********/ - /// <summary>The interception keys currently being intercepted.</summary> - private static readonly HashSet<string> InterceptingKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase); - - - /********* - ** Public methods - *********/ - /// <summary>Track a method that will be intercepted.</summary> - /// <param name="key">The intercept key.</param> - /// <returns>Returns false if the method was already marked for interception, else true.</returns> - public static bool StartIntercept(string key) - { - return PatchHelper.InterceptingKeys.Add(key); - } - - /// <summary>Track a method as no longer being intercepted.</summary> - /// <param name="key">The intercept key.</param> - public static void StopIntercept(string key) - { - PatchHelper.InterceptingKeys.Remove(key); - } - } -} -#endif diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs index 464367b6..8d1b6034 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs @@ -36,23 +36,26 @@ namespace StardewModdingAPI.Framework.Reflection public TInterface CreateProxy<TInterface>(object instance, string sourceModID, string targetModID) where TInterface : class { - // validate - if (instance == null) - throw new InvalidOperationException("Can't proxy access to a null API."); - if (!typeof(TInterface).IsInterface) - throw new InvalidOperationException("The proxy type must be an interface, not a class."); - - // get proxy type - Type targetType = instance.GetType(); - string proxyTypeName = $"StardewModdingAPI.Proxies.From<{sourceModID}_{typeof(TInterface).FullName}>_To<{targetModID}_{targetType.FullName}>"; - if (!this.Builders.TryGetValue(proxyTypeName, out InterfaceProxyBuilder builder)) + lock (this.Builders) { - builder = new InterfaceProxyBuilder(proxyTypeName, this.ModuleBuilder, typeof(TInterface), targetType); - this.Builders[proxyTypeName] = builder; - } + // validate + if (instance == null) + throw new InvalidOperationException("Can't proxy access to a null API."); + if (!typeof(TInterface).IsInterface) + throw new InvalidOperationException("The proxy type must be an interface, not a class."); - // create instance - return (TInterface)builder.CreateInstance(instance); + // get proxy type + Type targetType = instance.GetType(); + string proxyTypeName = $"StardewModdingAPI.Proxies.From<{sourceModID}_{typeof(TInterface).FullName}>_To<{targetModID}_{targetType.FullName}>"; + if (!this.Builders.TryGetValue(proxyTypeName, out InterfaceProxyBuilder builder)) + { + builder = new InterfaceProxyBuilder(proxyTypeName, this.ModuleBuilder, typeof(TInterface), targetType); + this.Builders[proxyTypeName] = builder; + } + + // create instance + return (TInterface)builder.CreateInstance(instance); + } } } } diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index c3285979..a34b3eff 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Net; @@ -30,13 +31,14 @@ using StardewModdingAPI.Framework.Models; using StardewModdingAPI.Framework.ModHelpers; using StardewModdingAPI.Framework.ModLoading; using StardewModdingAPI.Framework.Networking; -using StardewModdingAPI.Framework.Patching; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Rendering; using StardewModdingAPI.Framework.Serialization; using StardewModdingAPI.Framework.StateTracking.Comparers; using StardewModdingAPI.Framework.StateTracking.Snapshots; using StardewModdingAPI.Framework.Utilities; +using StardewModdingAPI.Internal; +using StardewModdingAPI.Internal.Patching; using StardewModdingAPI.Patches; using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; @@ -46,6 +48,7 @@ using StardewModdingAPI.Toolkit.Utilities; using StardewModdingAPI.Utilities; using StardewValley; using xTile.Display; +using MiniMonoModHotfix = MonoMod.Utils.MiniMonoModHotfix; using SObject = StardewValley.Object; namespace StardewModdingAPI.Framework @@ -251,8 +254,10 @@ namespace StardewModdingAPI.Framework StardewValley.GameRunner.instance = this.Game; // apply game patches - new GamePatcher(this.Monitor).Apply( - new LoadContextPatch(this.Reflection, this.OnLoadStageChanged) + MiniMonoModHotfix.Apply(); + HarmonyPatcher.Apply("SMAPI", this.Monitor, + new Game1Patcher(this.Reflection, this.OnLoadStageChanged), + new TitleMenuPatcher(this.OnLoadStageChanged) ); // add exit handler @@ -308,6 +313,14 @@ namespace StardewModdingAPI.Framework } } + /// <summary>Get the core logger and monitor on behalf of the game.</summary> + /// <remarks>This method is called using reflection by the ErrorHandler mod to log game errors.</remarks> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Used via reflection")] + public IMonitor GetMonitorForGame() + { + return this.LogManager.MonitorForGame; + } + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> public void Dispose() { diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index af7fa387..55ab8377 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -11,6 +11,7 @@ using StardewModdingAPI.Framework.Input; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.StateTracking.Snapshots; using StardewModdingAPI.Framework.Utilities; +using StardewModdingAPI.Internal; using StardewModdingAPI.Utilities; using StardewValley; using StardewValley.BellsAndWhistles; diff --git a/src/SMAPI/Framework/TemporaryHacks/MiniMonoModHotfix.cs b/src/SMAPI/Framework/TemporaryHacks/MiniMonoModHotfix.cs new file mode 100644 index 00000000..9d63ab2c --- /dev/null +++ b/src/SMAPI/Framework/TemporaryHacks/MiniMonoModHotfix.cs @@ -0,0 +1,239 @@ +// This temporary utility fixes an esoteric issue in XNA Framework where deserialization depends on +// the order of fields returned by Type.GetFields, but that order changes after Harmony/MonoMod use +// reflection to access the fields due to an issue in .NET Framework. +// https://twitter.com/0x0ade/status/1414992316964687873 +// +// This will be removed when Harmony/MonoMod are updated to incorporate the fix. +// +// Special thanks to 0x0ade for submitting this worokaround! Copy/pasted and adapted from MonoMod. + +using System; +using System.Reflection; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using HarmonyLib; +using System.Reflection.Emit; + +// ReSharper disable once CheckNamespace -- Temporary hotfix submitted by the MonoMod author. +namespace MonoMod.Utils +{ + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Temporary hotfix submitted by the MonoMod author.")] + [SuppressMessage("ReSharper", "PossibleNullReferenceException", Justification = "Temporary hotfix submitted by the MonoMod author.")] + static class MiniMonoModHotfix + { + // .NET Framework can break member ordering if using Module.Resolve* on certain members. + + private static object[] _NoArgs = new object[0]; + private static object[] _CacheGetterArgs = { /* MemberListType.All */ 0, /* name apparently always null? */ null }; + + private static Type t_RuntimeModule = + typeof(Module).Assembly + .GetType("System.Reflection.RuntimeModule"); + + private static PropertyInfo p_RuntimeModule_RuntimeType = + typeof(Module).Assembly + .GetType("System.Reflection.RuntimeModule") + ?.GetProperty("RuntimeType", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + + private static Type t_RuntimeType = + typeof(Type).Assembly + .GetType("System.RuntimeType"); + + private static PropertyInfo p_RuntimeType_Cache = + typeof(Type).Assembly + .GetType("System.RuntimeType") + ?.GetProperty("Cache", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + + private static MethodInfo m_RuntimeTypeCache_GetFieldList = + typeof(Type).Assembly + .GetType("System.RuntimeType+RuntimeTypeCache") + ?.GetMethod("GetFieldList", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + + private static MethodInfo m_RuntimeTypeCache_GetPropertyList = + typeof(Type).Assembly + .GetType("System.RuntimeType+RuntimeTypeCache") + ?.GetMethod("GetPropertyList", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + + private static readonly ConditionalWeakTable<Type, CacheFixEntry> _CacheFixed = new ConditionalWeakTable<Type, CacheFixEntry>(); + + public static void Apply() + { + var harmony = new Harmony("MiniMonoModHotfix"); + + harmony.Patch( + original: typeof(Harmony).Assembly + .GetType("HarmonyLib.MethodBodyReader") + .GetMethod("ReadOperand", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance), + transpiler: new HarmonyMethod(typeof(MiniMonoModHotfix), nameof(ResolveTokenFix)) + ); + + harmony.Patch( + original: typeof(MonoMod.Utils.ReflectionHelper).Assembly + .GetType("MonoMod.Utils.DynamicMethodDefinition+<>c__DisplayClass3_0") + .GetMethod("<_CopyMethodToDefinition>g__ResolveTokenAs|1", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance), + transpiler: new HarmonyMethod(typeof(MiniMonoModHotfix), nameof(ResolveTokenFix)) + ); + + } + + private static IEnumerable<CodeInstruction> ResolveTokenFix(IEnumerable<CodeInstruction> instrs) + { + MethodInfo getdecl = typeof(MiniMonoModHotfix).GetMethod(nameof(GetRealDeclaringType)); + MethodInfo fixup = typeof(MiniMonoModHotfix).GetMethod(nameof(FixReflectionCache)); + + foreach (CodeInstruction instr in instrs) + { + yield return instr; + + if (instr.operand is MethodInfo called) + { + switch (called.Name) + { + case "ResolveType": + // type.FixReflectionCache(); + yield return new CodeInstruction(OpCodes.Dup); + yield return new CodeInstruction(OpCodes.Call, fixup); + break; + + case "ResolveMember": + case "ResolveMethod": + case "ResolveField": + // member.GetRealDeclaringType().FixReflectionCache(); + yield return new CodeInstruction(OpCodes.Dup); + yield return new CodeInstruction(OpCodes.Call, getdecl); + yield return new CodeInstruction(OpCodes.Call, fixup); + break; + } + } + } + } + + public static Type GetModuleType(this Module module) + { + // Sadly we can't blindly resolve type 0x02000001 as the runtime throws ArgumentException. + + if (module == null || t_RuntimeModule == null || !t_RuntimeModule.IsInstanceOfType(module)) + return null; + + // .NET + if (p_RuntimeModule_RuntimeType != null) + return (Type)p_RuntimeModule_RuntimeType.GetValue(module, _NoArgs); + + // The hotfix doesn't apply to Mono anyway, thus that's not copied over. + + return null; + } + + public static Type GetRealDeclaringType(this MemberInfo member) + => member.DeclaringType ?? member.Module?.GetModuleType(); + + public static void FixReflectionCache(this Type type) + { + if (t_RuntimeType == null || + p_RuntimeType_Cache == null || + m_RuntimeTypeCache_GetFieldList == null || + m_RuntimeTypeCache_GetPropertyList == null) + return; + + for (; type != null; type = type.DeclaringType) + { + // All types SHOULD inherit RuntimeType, including those built at runtime. + // One might never know what awaits us in the depths of reflection hell though. + if (!t_RuntimeType.IsInstanceOfType(type)) + continue; + + CacheFixEntry entry = _CacheFixed.GetValue(type, rt => { + CacheFixEntry entryNew = new CacheFixEntry(); + object cache; + Array properties, fields; + + // All RuntimeTypes MUST have a cache, the getter is non-virtual, it creates on demand and asserts non-null. + entryNew.Cache = cache = p_RuntimeType_Cache.GetValue(rt, _NoArgs); + entryNew.Properties = properties = _GetArray(cache, m_RuntimeTypeCache_GetPropertyList); + entryNew.Fields = fields = _GetArray(cache, m_RuntimeTypeCache_GetFieldList); + + _FixReflectionCacheOrder<PropertyInfo>(properties); + _FixReflectionCacheOrder<FieldInfo>(fields); + + entryNew.NeedsVerify = false; + return entryNew; + }); + + if (entry.NeedsVerify && !_Verify(entry, type)) + { + lock (entry) + { + _FixReflectionCacheOrder<PropertyInfo>(entry.Properties); + _FixReflectionCacheOrder<FieldInfo>(entry.Fields); + } + } + + entry.NeedsVerify = true; + } + } + + private static bool _Verify(CacheFixEntry entry, Type type) + { + object cache; + Array properties, fields; + + // The cache can sometimes be invalidated. + // TODO: Figure out if only the arrays get replaced or if the entire cache object gets replaced! + if (entry.Cache != (cache = p_RuntimeType_Cache.GetValue(type, _NoArgs))) + { + entry.Cache = cache; + entry.Properties = _GetArray(cache, m_RuntimeTypeCache_GetPropertyList); + entry.Fields = _GetArray(cache, m_RuntimeTypeCache_GetFieldList); + return false; + + } + else if (entry.Properties != (properties = _GetArray(cache, m_RuntimeTypeCache_GetPropertyList))) + { + entry.Properties = properties; + entry.Fields = _GetArray(cache, m_RuntimeTypeCache_GetFieldList); + return false; + + } + else if (entry.Fields != (fields = _GetArray(cache, m_RuntimeTypeCache_GetFieldList))) + { + entry.Fields = fields; + return false; + + } + else + { + // Cache should still be the same, no re-fix necessary. + return true; + } + } + + private static Array _GetArray(object cache, MethodInfo getter) + { + // Get and discard once, otherwise we might not be getting the actual backing array. + getter.Invoke(cache, _CacheGetterArgs); + return (Array)getter.Invoke(cache, _CacheGetterArgs); + } + + private static void _FixReflectionCacheOrder<T>(Array orig) where T : MemberInfo + { + // Sort using a short-lived list. + List<T> list = new List<T>(orig.Length); + for (int i = 0; i < orig.Length; i++) + list.Add((T)orig.GetValue(i)); + + list.Sort((a, b) => a.MetadataToken - b.MetadataToken); + + for (int i = orig.Length - 1; i >= 0; --i) + orig.SetValue(list[i], i); + } + + private class CacheFixEntry + { + public object Cache; + public Array Properties; + public Array Fields; + public bool NeedsVerify; + } + } +} diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs index 5641f90f..a8686ca4 100644 --- a/src/SMAPI/Metadata/CoreAssetPropagator.cs +++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs @@ -5,9 +5,9 @@ using System.IO; using System.Linq; using Microsoft.Xna.Framework.Graphics; using Netcode; -using StardewModdingAPI.Framework; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Internal; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; using StardewValley.BellsAndWhistles; @@ -938,16 +938,17 @@ namespace StardewModdingAPI.Metadata // reload map location.interiorDoors.Clear(); // prevent errors when doors try to update tiles which no longer exist location.reloadMap(); - location.updateWarps(); - location.MakeMapModifications(force: true); - // update interior doors + // reload interior doors location.interiorDoors.Clear(); - foreach (var entry in new InteriorDoorDictionary(location)) - location.interiorDoors.Add(entry); + location.interiorDoors.ResetSharedState(); // load doors from map properties + location.interiorDoors.ResetLocalState(); // reapply door tiles - // update doors - location.doors.Clear(); + // reapply map changes (after reloading doors so they apply theirs too) + location.MakeMapModifications(force: true); + + // update for changes + location.updateWarps(); location.updateDoors(); } diff --git a/src/SMAPI/Metadata/InstructionMetadata.cs b/src/SMAPI/Metadata/InstructionMetadata.cs index d1699636..a787993a 100644 --- a/src/SMAPI/Metadata/InstructionMetadata.cs +++ b/src/SMAPI/Metadata/InstructionMetadata.cs @@ -48,10 +48,8 @@ namespace StardewModdingAPI.Metadata yield return new HeuristicFieldRewriter(this.ValidateReferencesToAssemblies); yield return new HeuristicMethodRewriter(this.ValidateReferencesToAssemblies); -#if HARMONY_2 - // rewrite for SMAPI 3.x (Harmony 1.x => 2.0 update) + // rewrite for SMAPI 3.12 (Harmony 1.x => 2.0 update) yield return new Harmony1AssemblyRewriter(); -#endif } /**** @@ -64,11 +62,7 @@ namespace StardewModdingAPI.Metadata /**** ** detect code which may impact game stability ****/ -#if HARMONY_2 yield return new TypeFinder(typeof(HarmonyLib.Harmony).FullName, InstructionHandleResult.DetectedGamePatch); -#else - yield return new TypeFinder(typeof(Harmony.HarmonyInstance).FullName, InstructionHandleResult.DetectedGamePatch); -#endif yield return new TypeFinder("System.Runtime.CompilerServices.CallSite", InstructionHandleResult.DetectedDynamic); yield return new FieldFinder(typeof(SaveGame).FullName, nameof(SaveGame.serializer), InstructionHandleResult.DetectedSaveSerializer); yield return new FieldFinder(typeof(SaveGame).FullName, nameof(SaveGame.farmerSerializer), InstructionHandleResult.DetectedSaveSerializer); diff --git a/src/SMAPI/Patches/LoadContextPatch.cs b/src/SMAPI/Patches/Game1Patcher.cs index c43d7071..173a2055 100644 --- a/src/SMAPI/Patches/LoadContextPatch.cs +++ b/src/SMAPI/Patches/Game1Patcher.cs @@ -1,24 +1,20 @@ using System; using System.Diagnostics.CodeAnalysis; -#if HARMONY_2 using HarmonyLib; -#else -using Harmony; -#endif using StardewModdingAPI.Enums; -using StardewModdingAPI.Framework.Patching; using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Internal.Patching; using StardewValley; using StardewValley.Menus; using StardewValley.Minigames; namespace StardewModdingAPI.Patches { - /// <summary>Harmony patches which notify SMAPI for save creation load stages.</summary> + /// <summary>Harmony patches for <see cref="Game1"/> which notify SMAPI for save load stages.</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 LoadContextPatch : IHarmonyPatch + internal class Game1Patcher : BasePatcher { /********* ** Fields @@ -39,42 +35,32 @@ namespace StardewModdingAPI.Patches /// <summary>Construct an instance.</summary> /// <param name="reflection">Simplifies access to private code.</param> /// <param name="onStageChanged">A callback to invoke when the load stage changes.</param> - public LoadContextPatch(Reflector reflection, Action<LoadStage> onStageChanged) + public Game1Patcher(Reflector reflection, Action<LoadStage> onStageChanged) { - LoadContextPatch.Reflection = reflection; - LoadContextPatch.OnStageChanged = onStageChanged; + Game1Patcher.Reflection = reflection; + Game1Patcher.OnStageChanged = onStageChanged; } /// <inheritdoc /> -#if HARMONY_2 - public void Apply(Harmony harmony) -#else - public void Apply(HarmonyInstance harmony) -#endif + public override void Apply(Harmony harmony, IMonitor monitor) { - // detect CreatedBasicInfo - harmony.Patch( - original: AccessTools.Method(typeof(TitleMenu), nameof(TitleMenu.createdNewCharacter)), - prefix: new HarmonyMethod(this.GetType(), nameof(LoadContextPatch.Before_TitleMenu_CreatedNewCharacter)) - ); - // detect CreatedInitialLocations and SaveAddedLocations harmony.Patch( - original: AccessTools.Method(typeof(Game1), nameof(Game1.AddModNPCs)), - prefix: new HarmonyMethod(this.GetType(), nameof(LoadContextPatch.Before_Game1_AddModNPCs)) + original: this.RequireMethod<Game1>(nameof(Game1.AddModNPCs)), + prefix: this.GetHarmonyMethod(nameof(Game1Patcher.Before_AddModNpcs)) ); // detect CreatedLocations, and track IsInLoadForNewGame harmony.Patch( - original: AccessTools.Method(typeof(Game1), nameof(Game1.loadForNewGame)), - prefix: new HarmonyMethod(this.GetType(), nameof(LoadContextPatch.Before_Game1_LoadForNewGame)), - postfix: new HarmonyMethod(this.GetType(), nameof(LoadContextPatch.After_Game1_LoadForNewGame)) + original: this.RequireMethod<Game1>(nameof(Game1.loadForNewGame)), + prefix: this.GetHarmonyMethod(nameof(Game1Patcher.Before_LoadForNewGame)), + postfix: this.GetHarmonyMethod(nameof(Game1Patcher.After_LoadForNewGame)) ); // detect ReturningToTitle harmony.Patch( - original: AccessTools.Method(typeof(Game1), nameof(Game1.CleanupReturningToTitle)), - prefix: new HarmonyMethod(this.GetType(), nameof(LoadContextPatch.Before_Game1_CleanupReturningToTitle)) + original: this.RequireMethod<Game1>(nameof(Game1.CleanupReturningToTitle)), + prefix: this.GetHarmonyMethod(nameof(Game1Patcher.Before_CleanupReturningToTitle)) ); } @@ -82,25 +68,16 @@ namespace StardewModdingAPI.Patches /********* ** Private methods *********/ - /// <summary>Called before <see cref="TitleMenu.createdNewCharacter"/>.</summary> - /// <returns>Returns whether to execute the original method.</returns> - /// <remarks>This method must be static for Harmony to work correctly. See the Harmony documentation before renaming arguments.</remarks> - private static bool Before_TitleMenu_CreatedNewCharacter() - { - LoadContextPatch.OnStageChanged(LoadStage.CreatedBasicInfo); - return true; - } - - /// <summary>Called before <see cref="Game1.AddModNPCs"/>.</summary> + /// <summary>The method to call before <see cref="Game1.AddModNPCs"/>.</summary> /// <returns>Returns whether to execute the original method.</returns> /// <remarks>This method must be static for Harmony to work correctly. See the Harmony documentation before renaming arguments.</remarks> - private static bool Before_Game1_AddModNPCs() + private static bool Before_AddModNpcs() { // When this method is called from Game1.loadForNewGame, it happens right after adding the vanilla // locations but before initializing them. - if (LoadContextPatch.IsInLoadForNewGame) + if (Game1Patcher.IsInLoadForNewGame) { - LoadContextPatch.OnStageChanged(LoadContextPatch.IsCreating() + Game1Patcher.OnStageChanged(Game1Patcher.IsCreating() ? LoadStage.CreatedInitialLocations : LoadStage.SaveAddedLocations ); @@ -109,32 +86,32 @@ namespace StardewModdingAPI.Patches return true; } - /// <summary>Called before <see cref="Game1.CleanupReturningToTitle"/>.</summary> + /// <summary>The method to call before <see cref="Game1.CleanupReturningToTitle"/>.</summary> /// <returns>Returns whether to execute the original method.</returns> /// <remarks>This method must be static for Harmony to work correctly. See the Harmony documentation before renaming arguments.</remarks> - private static bool Before_Game1_CleanupReturningToTitle() + private static bool Before_CleanupReturningToTitle() { - LoadContextPatch.OnStageChanged(LoadStage.ReturningToTitle); + Game1Patcher.OnStageChanged(LoadStage.ReturningToTitle); return true; } - /// <summary>Called before <see cref="Game1.loadForNewGame"/>.</summary> + /// <summary>The method to call before <see cref="Game1.loadForNewGame"/>.</summary> /// <returns>Returns whether to execute the original method.</returns> /// <remarks>This method must be static for Harmony to work correctly. See the Harmony documentation before renaming arguments.</remarks> - private static bool Before_Game1_LoadForNewGame() + private static bool Before_LoadForNewGame() { - LoadContextPatch.IsInLoadForNewGame = true; + Game1Patcher.IsInLoadForNewGame = true; return true; } - /// <summary>Called after <see cref="Game1.loadForNewGame"/>.</summary> + /// <summary>The method to call after <see cref="Game1.loadForNewGame"/>.</summary> /// <remarks>This method must be static for Harmony to work correctly. See the Harmony documentation before renaming arguments.</remarks> - private static void After_Game1_LoadForNewGame() + private static void After_LoadForNewGame() { - LoadContextPatch.IsInLoadForNewGame = false; + Game1Patcher.IsInLoadForNewGame = false; - if (LoadContextPatch.IsCreating()) - LoadContextPatch.OnStageChanged(LoadStage.CreatedLocations); + if (Game1Patcher.IsCreating()) + Game1Patcher.OnStageChanged(LoadStage.CreatedLocations); } /// <summary>Get whether the save file is currently being created.</summary> @@ -142,7 +119,7 @@ namespace StardewModdingAPI.Patches { return (Game1.currentMinigame is Intro) // creating save with intro - || (Game1.activeClickableMenu is TitleMenu menu && LoadContextPatch.Reflection.GetField<bool>(menu, "transitioningCharacterCreationMenu").GetValue()); // creating save, skipped intro + || (Game1.activeClickableMenu is TitleMenu menu && Game1Patcher.Reflection.GetField<bool>(menu, "transitioningCharacterCreationMenu").GetValue()); // creating save, skipped intro } } } diff --git a/src/SMAPI/Patches/TitleMenuPatcher.cs b/src/SMAPI/Patches/TitleMenuPatcher.cs new file mode 100644 index 00000000..b4320ce0 --- /dev/null +++ b/src/SMAPI/Patches/TitleMenuPatcher.cs @@ -0,0 +1,55 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using HarmonyLib; +using StardewModdingAPI.Enums; +using StardewModdingAPI.Internal.Patching; +using StardewValley.Menus; + +namespace StardewModdingAPI.Patches +{ + /// <summary>Harmony patches for <see cref="TitleMenu"/> which notify SMAPI when a new character was created.</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 TitleMenuPatcher : BasePatcher + { + /********* + ** Fields + *********/ + /// <summary>A callback to invoke when the load stage changes.</summary> + private static Action<LoadStage> OnStageChanged; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="onStageChanged">A callback to invoke when the load stage changes.</param> + public TitleMenuPatcher(Action<LoadStage> onStageChanged) + { + TitleMenuPatcher.OnStageChanged = onStageChanged; + } + + /// <inheritdoc /> + public override void Apply(Harmony harmony, IMonitor monitor) + { + harmony.Patch( + original: this.RequireMethod<TitleMenu>(nameof(TitleMenu.createdNewCharacter)), + prefix: this.GetHarmonyMethod(nameof(TitleMenuPatcher.Before_CreatedNewCharacter)) + ); + } + + + /********* + ** Private methods + *********/ + /// <summary>The method to call before <see cref="TitleMenu.createdNewCharacter"/>.</summary> + /// <returns>Returns whether to execute the original method.</returns> + /// <remarks>This method must be static for Harmony to work correctly. See the Harmony documentation before renaming arguments.</remarks> + private static bool Before_CreatedNewCharacter() + { + TitleMenuPatcher.OnStageChanged(LoadStage.CreatedBasicInfo); + return true; + } + } +} diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs index e830f799..3249e02f 100644 --- a/src/SMAPI/Program.cs +++ b/src/SMAPI/Program.cs @@ -4,6 +4,8 @@ using System.Linq; using System.Reflection; using System.Threading; using StardewModdingAPI.Framework; +using StardewModdingAPI.Toolkit.Framework; +using StardewModdingAPI.Toolkit.Serialization.Models; namespace StardewModdingAPI { @@ -31,6 +33,7 @@ namespace StardewModdingAPI AppDomain.CurrentDomain.AssemblyResolve += Program.CurrentDomain_AssemblyResolve; Program.AssertGamePresent(); Program.AssertGameVersion(); + Program.AssertSmapiVersions(); Program.Start(args); } catch (BadImageFormatException ex) when (ex.FileName == "StardewValley" || ex.FileName == "Stardew Valley") // don't use EarlyConstants.GameAssemblyName, since we want to check both possible names @@ -107,8 +110,35 @@ namespace StardewModdingAPI } // max version - else if (Constants.MaximumGameVersion != null && Constants.GameVersion.IsNewerThan(Constants.MaximumGameVersion)) + if (Constants.MaximumGameVersion != null && Constants.GameVersion.IsNewerThan(Constants.MaximumGameVersion)) Program.PrintErrorAndExit($"Oops! You're running Stardew Valley {Constants.GameVersion}, but this version of SMAPI is only compatible up to Stardew Valley {Constants.MaximumGameVersion}. Please check for a newer version of SMAPI: https://smapi.io."); + + // bitness + bool is64BitGame = LowLevelEnvironmentUtility.Is64BitAssembly(Path.Combine(EarlyConstants.ExecutionPath, $"{EarlyConstants.GameAssemblyName}.exe")); +#if SMAPI_FOR_WINDOWS_64BIT_HACK + if (!is64BitGame) + Program.PrintErrorAndExit("Oops! This is the 64-bit version of SMAPI, but you have the 32-bit version of Stardew Valley. You can reinstall SMAPI using its installer to automatically install the correct version of SMAPI."); +#elif SMAPI_FOR_WINDOWS + if (is64BitGame) + Program.PrintErrorAndExit("Oops! This is the 32-bit version of SMAPI, but you have the 64-bit version of Stardew Valley. You can reinstall SMAPI using its installer to automatically install the correct version of SMAPI."); +#endif + } + + /// <summary>Assert that the versions of all SMAPI components are correct.</summary> + /// <remarks>Players sometimes have mismatched versions (particularly when installed through Vortex), which can cause some very confusing bugs without this check.</remarks> + private static void AssertSmapiVersions() + { + // get SMAPI version without prerelease suffix (since we can't get that from the assembly versions) + ISemanticVersion smapiVersion = new SemanticVersion(Constants.ApiVersion.MajorVersion, Constants.ApiVersion.MinorVersion, Constants.ApiVersion.PatchVersion); + + // compare with assembly versions + foreach (var type in new[] { typeof(IManifest), typeof(Manifest) }) + { + AssemblyName assemblyName = type.Assembly.GetName(); + ISemanticVersion assemblyVersion = new SemanticVersion(assemblyName.Version); + if (!assemblyVersion.Equals(smapiVersion)) + Program.PrintErrorAndExit($"Oops! The 'smapi-internal/{assemblyName.Name}.dll' file is version {assemblyVersion} instead of the required {Constants.ApiVersion}. SMAPI doesn't seem to be installed correctly."); + } } /// <summary>Initialize SMAPI and launch the game.</summary> diff --git a/src/SMAPI/Properties/AssemblyInfo.cs b/src/SMAPI/Properties/AssemblyInfo.cs index ae758e9b..ee8a1674 100644 --- a/src/SMAPI/Properties/AssemblyInfo.cs +++ b/src/SMAPI/Properties/AssemblyInfo.cs @@ -1,5 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("SMAPI.Tests")] -[assembly: InternalsVisibleTo("ErrorHandler")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // Moq for unit testing diff --git a/src/SMAPI/SMAPI.csproj b/src/SMAPI/SMAPI.csproj index 413d9f33..7d5e7ef9 100644 --- a/src/SMAPI/SMAPI.csproj +++ b/src/SMAPI/SMAPI.csproj @@ -20,7 +20,8 @@ <ItemGroup> <PackageReference Include="LargeAddressAware" Version="1.0.5" /> - <PackageReference Include="Mono.Cecil" Version="0.11.3" /> + <PackageReference Include="Mono.Cecil" Version="0.11.4" /> + <PackageReference Include="MonoMod.Common" Version="21.6.21.1" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> <PackageReference Include="Platonymous.TMXTile" Version="1.5.8" /> </ItemGroup> @@ -75,4 +76,5 @@ </ItemGroup> <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> + <Import Project="..\SMAPI.Internal.Patching\SMAPI.Internal.Patching.projitems" Label="Shared" /> </Project> |