diff options
Diffstat (limited to 'src/StardewModdingAPI')
149 files changed, 15259 insertions, 0 deletions
diff --git a/src/StardewModdingAPI/App.config b/src/StardewModdingAPI/App.config new file mode 100644 index 00000000..27cdf0f7 --- /dev/null +++ b/src/StardewModdingAPI/App.config @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<configuration> + <startup> + <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5"/> + </startup> + <runtime> + <loadFromRemoteSources enabled="true"/> + </runtime> +</configuration> diff --git a/src/StardewModdingAPI/Constants.cs b/src/StardewModdingAPI/Constants.cs new file mode 100644 index 00000000..4d0a9ca9 --- /dev/null +++ b/src/StardewModdingAPI/Constants.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using StardewModdingAPI.Framework; +using StardewModdingAPI.Framework.ModLoading; +using StardewValley; + +namespace StardewModdingAPI +{ + /// <summary>Contains SMAPI's constants and assumptions.</summary> + public static class Constants + { + /********* + ** Properties + *********/ + /// <summary>The directory path containing the current save's data (if a save is loaded).</summary> + private static string RawSavePath => Context.IsSaveLoaded ? Path.Combine(Constants.SavesPath, Constants.GetSaveFolderName()) : null; + + /// <summary>Whether the directory containing the current save's data exists on disk.</summary> + private static bool SavePathReady => Context.IsSaveLoaded && Directory.Exists(Constants.RawSavePath); + + + /********* + ** Accessors + *********/ + /**** + ** Public + ****/ + /// <summary>SMAPI's current semantic version.</summary> + public static ISemanticVersion ApiVersion { get; } = new SemanticVersion(2, 0, 0, "beta.1"); + + /// <summary>The minimum supported version of Stardew Valley.</summary> + public static ISemanticVersion MinimumGameVersion { get; } = new SemanticVersion("1.2.30"); + + /// <summary>The maximum supported version of Stardew Valley.</summary> + public static ISemanticVersion MaximumGameVersion { get; } = null; + + /// <summary>The path to the game folder.</summary> + public static string ExecutionPath { get; } = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + + /// <summary>The directory path containing Stardew Valley's app data.</summary> + public static string DataPath { get; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley"); + + /// <summary>The directory path in which error logs should be stored.</summary> + public static string LogDir { get; } = Path.Combine(Constants.DataPath, "ErrorLogs"); + + /// <summary>The directory path where all saves are stored.</summary> + public static string SavesPath { get; } = Path.Combine(Constants.DataPath, "Saves"); + + /// <summary>The directory name containing the current save's data (if a save is loaded and the directory exists).</summary> + public static string SaveFolderName => Context.IsSaveLoaded ? Constants.GetSaveFolderName() : ""; + + /// <summary>The directory path containing the current save's data (if a save is loaded and the directory exists).</summary> + public static string CurrentSavePath => Constants.SavePathReady ? Path.Combine(Constants.SavesPath, Constants.GetSaveFolderName()) : ""; + + /**** + ** Internal + ****/ + /// <summary>The GitHub repository to check for updates.</summary> + internal const string GitHubRepository = "Pathoschild/SMAPI"; + + /// <summary>The file path for the SMAPI configuration file.</summary> + internal static string ApiConfigPath => Path.Combine(Constants.ExecutionPath, $"{typeof(Program).Assembly.GetName().Name}.config.json"); + + /// <summary>The file path to the log where the latest output should be saved.</summary> + internal static string DefaultLogPath => Path.Combine(Constants.LogDir, "SMAPI-latest.txt"); + + /// <summary>A copy of the log leading up to the previous fatal crash, if any.</summary> + internal static string FatalCrashLog => Path.Combine(Constants.LogDir, "SMAPI-crash.txt"); + + /// <summary>The file path which stores a fatal crash message for the next run.</summary> + internal static string FatalCrashMarker => Path.Combine(Constants.ExecutionPath, "StardewModdingAPI.crash.marker"); + + /// <summary>The full path to the folder containing mods.</summary> + internal static string ModPath { get; } = Path.Combine(Constants.ExecutionPath, "Mods"); + + /// <summary>The game's current semantic version.</summary> + internal static ISemanticVersion GameVersion { get; } = new GameVersion(Constants.GetGameVersion()); + + /// <summary>The target game platform.</summary> + internal static Platform TargetPlatform { get; } = +#if SMAPI_FOR_WINDOWS + Platform.Windows; +#else + Platform.Mono; +#endif + + /// <summary>Maps vendor keys (like <c>Nexus</c>) to their mod URL template (where <c>{0}</c> is the mod ID) during mod compatibility checks. This doesn't affect update checks, which defer to the remote web API.</summary> + internal static readonly IDictionary<string, string> VendorModUrls = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase) + { + ["Chucklefish"] = "https://community.playstarbound.com/resources/{0}", + ["Nexus"] = "http://nexusmods.com/stardewvalley/mods/{0}", + ["GitHub"] = "https://github.com/{0}/releases" + }; + + + /********* + ** Internal methods + *********/ + /// <summary>Get metadata for mapping assemblies to the current platform.</summary> + /// <param name="targetPlatform">The target game platform.</param> + internal static PlatformAssemblyMap GetAssemblyMap(Platform targetPlatform) + { + // get assembly changes needed for platform + string[] removeAssemblyReferences; + Assembly[] targetAssemblies; + switch (targetPlatform) + { + case Platform.Mono: + removeAssemblyReferences = new[] + { + "Stardew Valley", + "Microsoft.Xna.Framework", + "Microsoft.Xna.Framework.Game", + "Microsoft.Xna.Framework.Graphics" + }; + targetAssemblies = new[] + { + typeof(StardewValley.Game1).Assembly, + typeof(Microsoft.Xna.Framework.Vector2).Assembly + }; + break; + + case Platform.Windows: + removeAssemblyReferences = new[] + { + "StardewValley", + "MonoGame.Framework" + }; + targetAssemblies = new[] + { + typeof(StardewValley.Game1).Assembly, + typeof(Microsoft.Xna.Framework.Vector2).Assembly, + typeof(Microsoft.Xna.Framework.Game).Assembly, + typeof(Microsoft.Xna.Framework.Graphics.SpriteBatch).Assembly + }; + break; + + default: + throw new InvalidOperationException($"Unknown target platform '{targetPlatform}'."); + } + + return new PlatformAssemblyMap(targetPlatform, removeAssemblyReferences, targetAssemblies); + } + + + /********* + ** Private methods + *********/ + /// <summary>Get the name of a save directory for the current player.</summary> + private static string GetSaveFolderName() + { + string prefix = new string(Game1.player.name.Where(char.IsLetterOrDigit).ToArray()); + return $"{prefix}_{Game1.uniqueIDForThisGame}"; + } + + /// <summary>Get the game's current version string.</summary> + private static string GetGameVersion() + { + // we need reflection because it's a constant, so SMAPI's references to it are inlined at compile-time + FieldInfo field = typeof(Game1).GetField(nameof(Game1.version), BindingFlags.Public | BindingFlags.Static); + if (field == null) + throw new InvalidOperationException($"The {nameof(Game1)}.{nameof(Game1.version)} field could not be found."); + return (string)field.GetValue(null); + } + } +} diff --git a/src/StardewModdingAPI/ContentSource.cs b/src/StardewModdingAPI/ContentSource.cs new file mode 100644 index 00000000..35c8bc21 --- /dev/null +++ b/src/StardewModdingAPI/ContentSource.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI +{ + /// <summary>Specifies a source containing content that can be loaded.</summary> + public enum ContentSource + { + /// <summary>Assets in the game's content manager (i.e. XNBs in the game's content folder).</summary> + GameContent, + + /// <summary>XNB files in the current mod's folder.</summary> + ModFolder + } +} diff --git a/src/StardewModdingAPI/Context.cs b/src/StardewModdingAPI/Context.cs new file mode 100644 index 00000000..119e14c8 --- /dev/null +++ b/src/StardewModdingAPI/Context.cs @@ -0,0 +1,37 @@ +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; + +namespace StardewModdingAPI +{ + /// <summary>Provides information about the current game state.</summary> + public static class Context + { + /********* + ** Accessors + *********/ + /**** + ** Public + ****/ + /// <summary>Whether the player has loaded a save and the world has finished initialising.</summary> + public static bool IsWorldReady { get; internal set; } + + /// <summary>Whether <see cref="IsWorldReady"/> is true and the player is free to act in the world (no menu is displayed, no cutscene is in progress, etc).</summary> + public static bool IsPlayerFree => Context.IsWorldReady && Game1.activeClickableMenu == null && !Game1.dialogueUp && !Game1.eventUp; + + /// <summary>Whether <see cref="IsPlayerFree"/> is true and the player is free to move (e.g. not using a tool).</summary> + public static bool CanPlayerMove => Context.IsPlayerFree && Game1.player.CanMove; + + /// <summary>Whether the game is currently running the draw loop. This isn't relevant to most mods, since you should use <see cref="GraphicsEvents.OnPostRenderEvent"/> to draw to the screen.</summary> + public static bool IsInDrawLoop { get; internal set; } + + /**** + ** Internal + ****/ + /// <summary>Whether a player save has been loaded.</summary> + internal static bool IsSaveLoaded => Game1.hasLoadedGame && !string.IsNullOrEmpty(Game1.player.name); + + /// <summary>Whether the game is currently writing to the save file.</summary> + internal static bool IsSaving => Game1.activeClickableMenu is SaveGameMenu || Game1.activeClickableMenu is ShippingMenu; // saving is performed by SaveGameMenu, but it's wrapped by ShippingMenu on days when the player shipping something + } +} diff --git a/src/StardewModdingAPI/Events/ChangeType.cs b/src/StardewModdingAPI/Events/ChangeType.cs new file mode 100644 index 00000000..4b207f08 --- /dev/null +++ b/src/StardewModdingAPI/Events/ChangeType.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Events +{ + /// <summary>Indicates how an inventory item changed.</summary> + public enum ChangeType + { + /// <summary>The entire stack was removed.</summary> + Removed, + + /// <summary>The entire stack was added.</summary> + Added, + + /// <summary>The stack size changed.</summary> + StackChange + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Events/ContentEvents.cs b/src/StardewModdingAPI/Events/ContentEvents.cs new file mode 100644 index 00000000..4b4e2ad0 --- /dev/null +++ b/src/StardewModdingAPI/Events/ContentEvents.cs @@ -0,0 +1,29 @@ +using System; +using StardewModdingAPI.Framework; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events raised when the game loads content.</summary> + public static class ContentEvents + { + + /********* + ** Events + *********/ + /// <summary>Raised after the content language changes.</summary> + public static event EventHandler<EventArgsValueChanged<string>> AfterLocaleChanged; + + + /********* + ** Internal methods + *********/ + /// <summary>Raise an <see cref="AfterLocaleChanged"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="oldLocale">The previous locale.</param> + /// <param name="newLocale">The current locale.</param> + internal static void InvokeAfterLocaleChanged(IMonitor monitor, string oldLocale, string newLocale) + { + monitor.SafelyRaiseGenericEvent($"{nameof(ContentEvents)}.{nameof(ContentEvents.AfterLocaleChanged)}", ContentEvents.AfterLocaleChanged?.GetInvocationList(), null, new EventArgsValueChanged<string>(oldLocale, newLocale)); + } + } +} diff --git a/src/StardewModdingAPI/Events/ControlEvents.cs b/src/StardewModdingAPI/Events/ControlEvents.cs new file mode 100644 index 00000000..80d0f547 --- /dev/null +++ b/src/StardewModdingAPI/Events/ControlEvents.cs @@ -0,0 +1,112 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using StardewModdingAPI.Framework; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events raised when the player uses a controller, keyboard, or mouse.</summary> + public static class ControlEvents + { + /********* + ** Events + *********/ + /// <summary>Raised when the <see cref="KeyboardState"/> changes. That happens when the player presses or releases a key.</summary> + public static event EventHandler<EventArgsKeyboardStateChanged> KeyboardChanged; + + /// <summary>Raised when the player presses a keyboard key.</summary> + public static event EventHandler<EventArgsKeyPressed> KeyPressed; + + /// <summary>Raised when the player releases a keyboard key.</summary> + public static event EventHandler<EventArgsKeyPressed> KeyReleased; + + /// <summary>Raised when the <see cref="MouseState"/> changes. That happens when the player moves the mouse, scrolls the mouse wheel, or presses/releases a button.</summary> + public static event EventHandler<EventArgsMouseStateChanged> MouseChanged; + + /// <summary>The player pressed a controller button. This event isn't raised for trigger buttons.</summary> + public static event EventHandler<EventArgsControllerButtonPressed> ControllerButtonPressed; + + /// <summary>The player released a controller button. This event isn't raised for trigger buttons.</summary> + public static event EventHandler<EventArgsControllerButtonReleased> ControllerButtonReleased; + + /// <summary>The player pressed a controller trigger button.</summary> + public static event EventHandler<EventArgsControllerTriggerPressed> ControllerTriggerPressed; + + /// <summary>The player released a controller trigger button.</summary> + public static event EventHandler<EventArgsControllerTriggerReleased> ControllerTriggerReleased; + + + /********* + ** Internal methods + *********/ + /// <summary>Raise a <see cref="KeyboardChanged"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="priorState">The previous keyboard state.</param> + /// <param name="newState">The current keyboard state.</param> + internal static void InvokeKeyboardChanged(IMonitor monitor, KeyboardState priorState, KeyboardState newState) + { + monitor.SafelyRaiseGenericEvent($"{nameof(ControlEvents)}.{nameof(ControlEvents.KeyboardChanged)}", ControlEvents.KeyboardChanged?.GetInvocationList(), null, new EventArgsKeyboardStateChanged(priorState, newState)); + } + + /// <summary>Raise a <see cref="MouseChanged"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="priorState">The previous mouse state.</param> + /// <param name="newState">The current mouse state.</param> + /// <param name="priorPosition">The previous mouse position on the screen adjusted for the zoom level.</param> + /// <param name="newPosition">The current mouse position on the screen adjusted for the zoom level.</param> + internal static void InvokeMouseChanged(IMonitor monitor, MouseState priorState, MouseState newState, Point priorPosition, Point newPosition) + { + monitor.SafelyRaiseGenericEvent($"{nameof(ControlEvents)}.{nameof(ControlEvents.MouseChanged)}", ControlEvents.MouseChanged?.GetInvocationList(), null, new EventArgsMouseStateChanged(priorState, newState, priorPosition, newPosition)); + } + + /// <summary>Raise a <see cref="KeyPressed"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="key">The keyboard button that was pressed.</param> + internal static void InvokeKeyPressed(IMonitor monitor, Keys key) + { + monitor.SafelyRaiseGenericEvent($"{nameof(ControlEvents)}.{nameof(ControlEvents.KeyPressed)}", ControlEvents.KeyPressed?.GetInvocationList(), null, new EventArgsKeyPressed(key)); + } + + /// <summary>Raise a <see cref="KeyReleased"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="key">The keyboard button that was released.</param> + internal static void InvokeKeyReleased(IMonitor monitor, Keys key) + { + monitor.SafelyRaiseGenericEvent($"{nameof(ControlEvents)}.{nameof(ControlEvents.KeyReleased)}", ControlEvents.KeyReleased?.GetInvocationList(), null, new EventArgsKeyPressed(key)); + } + + /// <summary>Raise a <see cref="ControllerButtonPressed"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="button">The controller button that was pressed.</param> + internal static void InvokeButtonPressed(IMonitor monitor, Buttons button) + { + monitor.SafelyRaiseGenericEvent($"{nameof(ControlEvents)}.{nameof(ControlEvents.ControllerButtonPressed)}", ControlEvents.ControllerButtonPressed?.GetInvocationList(), null, new EventArgsControllerButtonPressed(PlayerIndex.One, button)); + } + + /// <summary>Raise a <see cref="ControllerButtonReleased"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="button">The controller button that was released.</param> + internal static void InvokeButtonReleased(IMonitor monitor, Buttons button) + { + monitor.SafelyRaiseGenericEvent($"{nameof(ControlEvents)}.{nameof(ControlEvents.ControllerButtonReleased)}", ControlEvents.ControllerButtonReleased?.GetInvocationList(), null, new EventArgsControllerButtonReleased(PlayerIndex.One, button)); + } + + /// <summary>Raise a <see cref="ControllerTriggerPressed"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="button">The trigger button that was pressed.</param> + /// <param name="value">The current trigger value.</param> + internal static void InvokeTriggerPressed(IMonitor monitor, Buttons button, float value) + { + monitor.SafelyRaiseGenericEvent($"{nameof(ControlEvents)}.{nameof(ControlEvents.ControllerTriggerPressed)}", ControlEvents.ControllerTriggerPressed?.GetInvocationList(), null, new EventArgsControllerTriggerPressed(PlayerIndex.One, button, value)); + } + + /// <summary>Raise a <see cref="ControllerTriggerReleased"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="button">The trigger button that was pressed.</param> + /// <param name="value">The current trigger value.</param> + internal static void InvokeTriggerReleased(IMonitor monitor, Buttons button, float value) + { + monitor.SafelyRaiseGenericEvent($"{nameof(ControlEvents)}.{nameof(ControlEvents.ControllerTriggerReleased)}", ControlEvents.ControllerTriggerReleased?.GetInvocationList(), null, new EventArgsControllerTriggerReleased(PlayerIndex.One, button, value)); + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsClickableMenuChanged.cs b/src/StardewModdingAPI/Events/EventArgsClickableMenuChanged.cs new file mode 100644 index 00000000..2a2aa163 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsClickableMenuChanged.cs @@ -0,0 +1,31 @@ +using System; +using StardewValley.Menus; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="MenuEvents.MenuChanged"/> event.</summary> + public class EventArgsClickableMenuChanged : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The previous menu.</summary> + public IClickableMenu NewMenu { get; } + + /// <summary>The current menu.</summary> + public IClickableMenu PriorMenu { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="priorMenu">The previous menu.</param> + /// <param name="newMenu">The current menu.</param> + public EventArgsClickableMenuChanged(IClickableMenu priorMenu, IClickableMenu newMenu) + { + this.NewMenu = newMenu; + this.PriorMenu = priorMenu; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsClickableMenuClosed.cs b/src/StardewModdingAPI/Events/EventArgsClickableMenuClosed.cs new file mode 100644 index 00000000..5e6585f0 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsClickableMenuClosed.cs @@ -0,0 +1,26 @@ +using System; +using StardewValley.Menus; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="MenuEvents.MenuClosed"/> event.</summary> + public class EventArgsClickableMenuClosed : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The menu that was closed.</summary> + public IClickableMenu PriorMenu { get; } + + + /********* + ** Accessors + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="priorMenu">The menu that was closed.</param> + public EventArgsClickableMenuClosed(IClickableMenu priorMenu) + { + this.PriorMenu = priorMenu; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsControllerButtonPressed.cs b/src/StardewModdingAPI/Events/EventArgsControllerButtonPressed.cs new file mode 100644 index 00000000..3243b80b --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsControllerButtonPressed.cs @@ -0,0 +1,32 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="ControlEvents.ControllerButtonPressed"/> event.</summary> + public class EventArgsControllerButtonPressed : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The player who pressed the button.</summary> + public PlayerIndex PlayerIndex { get; } + + /// <summary>The controller button that was pressed.</summary> + public Buttons ButtonPressed { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="playerIndex">The player who pressed the button.</param> + /// <param name="button">The controller button that was pressed.</param> + public EventArgsControllerButtonPressed(PlayerIndex playerIndex, Buttons button) + { + this.PlayerIndex = playerIndex; + this.ButtonPressed = button; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsControllerButtonReleased.cs b/src/StardewModdingAPI/Events/EventArgsControllerButtonReleased.cs new file mode 100644 index 00000000..e05a080b --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsControllerButtonReleased.cs @@ -0,0 +1,32 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="ControlEvents.ControllerButtonReleased"/> event.</summary> + public class EventArgsControllerButtonReleased : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The player who pressed the button.</summary> + public PlayerIndex PlayerIndex { get; } + + /// <summary>The controller button that was pressed.</summary> + public Buttons ButtonReleased { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="playerIndex">The player who pressed the button.</param> + /// <param name="button">The controller button that was released.</param> + public EventArgsControllerButtonReleased(PlayerIndex playerIndex, Buttons button) + { + this.PlayerIndex = playerIndex; + this.ButtonReleased = button; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsControllerTriggerPressed.cs b/src/StardewModdingAPI/Events/EventArgsControllerTriggerPressed.cs new file mode 100644 index 00000000..a2087733 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsControllerTriggerPressed.cs @@ -0,0 +1,37 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="ControlEvents.ControllerTriggerPressed"/> event.</summary> + public class EventArgsControllerTriggerPressed : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The player who pressed the button.</summary> + public PlayerIndex PlayerIndex { get; } + + /// <summary>The controller button that was pressed.</summary> + public Buttons ButtonPressed { get; } + + /// <summary>The current trigger value.</summary> + public float Value { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="playerIndex">The player who pressed the trigger button.</param> + /// <param name="button">The trigger button that was pressed.</param> + /// <param name="value">The current trigger value.</param> + public EventArgsControllerTriggerPressed(PlayerIndex playerIndex, Buttons button, float value) + { + this.PlayerIndex = playerIndex; + this.ButtonPressed = button; + this.Value = value; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsControllerTriggerReleased.cs b/src/StardewModdingAPI/Events/EventArgsControllerTriggerReleased.cs new file mode 100644 index 00000000..d2eecbec --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsControllerTriggerReleased.cs @@ -0,0 +1,37 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="ControlEvents.ControllerTriggerReleased"/> event.</summary> + public class EventArgsControllerTriggerReleased : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The player who pressed the button.</summary> + public PlayerIndex PlayerIndex { get; } + + /// <summary>The controller button that was released.</summary> + public Buttons ButtonReleased { get; } + + /// <summary>The current trigger value.</summary> + public float Value { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="playerIndex">The player who pressed the trigger button.</param> + /// <param name="button">The trigger button that was released.</param> + /// <param name="value">The current trigger value.</param> + public EventArgsControllerTriggerReleased(PlayerIndex playerIndex, Buttons button, float value) + { + this.PlayerIndex = playerIndex; + this.ButtonReleased = button; + this.Value = value; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsCurrentLocationChanged.cs b/src/StardewModdingAPI/Events/EventArgsCurrentLocationChanged.cs new file mode 100644 index 00000000..25d3ebf3 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsCurrentLocationChanged.cs @@ -0,0 +1,31 @@ +using System; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="LocationEvents.CurrentLocationChanged"/> event.</summary> + public class EventArgsCurrentLocationChanged : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The player's current location.</summary> + public GameLocation NewLocation { get; } + + /// <summary>The player's previous location.</summary> + public GameLocation PriorLocation { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="priorLocation">The player's previous location.</param> + /// <param name="newLocation">The player's current location.</param> + public EventArgsCurrentLocationChanged(GameLocation priorLocation, GameLocation newLocation) + { + this.NewLocation = newLocation; + this.PriorLocation = priorLocation; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsGameLocationsChanged.cs b/src/StardewModdingAPI/Events/EventArgsGameLocationsChanged.cs new file mode 100644 index 00000000..fb8c821e --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsGameLocationsChanged.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="LocationEvents.LocationsChanged"/> event.</summary> + public class EventArgsGameLocationsChanged : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The current list of game locations.</summary> + public List<GameLocation> NewLocations { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="newLocations">The current list of game locations.</param> + public EventArgsGameLocationsChanged(List<GameLocation> newLocations) + { + this.NewLocations = newLocations; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsInput.cs b/src/StardewModdingAPI/Events/EventArgsInput.cs new file mode 100644 index 00000000..66cb19f2 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsInput.cs @@ -0,0 +1,124 @@ +using System; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using StardewModdingAPI.Utilities; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments when a button is pressed or released.</summary> + public class EventArgsInput : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The button on the controller, keyboard, or mouse.</summary> + public SButton Button { get; } + + /// <summary>The current cursor position.</summary> + public ICursorPosition Cursor { get; set; } + + /// <summary>Whether the input is considered a 'click' by the game for enabling action.</summary> + public bool IsClick { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="button">The button on the controller, keyboard, or mouse.</param> + /// <param name="cursor">The cursor position.</param> + /// <param name="isClick">Whether the input is considered a 'click' by the game for enabling action.</param> + public EventArgsInput(SButton button, ICursorPosition cursor, bool isClick) + { + this.Button = button; + this.Cursor = cursor; + this.IsClick = isClick; + } + + /// <summary>Prevent the game from handling the vurrent button press. This doesn't prevent other mods from receiving the event.</summary> + public void SuppressButton() + { + this.SuppressButton(this.Button); + } + + /// <summary>Prevent the game from handling a button press. This doesn't prevent other mods from receiving the event.</summary> + /// <param name="button">The button to suppress.</param> + public void SuppressButton(SButton button) + { + // keyboard + if (this.Button.TryGetKeyboard(out Keys key)) + Game1.oldKBState = new KeyboardState(Game1.oldKBState.GetPressedKeys().Except(new[] { key }).ToArray()); + + // controller + else if (this.Button.TryGetController(out Buttons controllerButton)) + { + var newState = GamePad.GetState(PlayerIndex.One); + var thumbsticks = Game1.oldPadState.ThumbSticks; + var triggers = Game1.oldPadState.Triggers; + var buttons = Game1.oldPadState.Buttons; + var dpad = Game1.oldPadState.DPad; + + switch (controllerButton) + { + // d-pad + case Buttons.DPadDown: + dpad = new GamePadDPad(dpad.Up, newState.DPad.Down, dpad.Left, dpad.Right); + break; + case Buttons.DPadLeft: + dpad = new GamePadDPad(dpad.Up, dpad.Down, newState.DPad.Left, dpad.Right); + break; + case Buttons.DPadRight: + dpad = new GamePadDPad(dpad.Up, dpad.Down, dpad.Left, newState.DPad.Right); + break; + case Buttons.DPadUp: + dpad = new GamePadDPad(newState.DPad.Up, dpad.Down, dpad.Left, dpad.Right); + break; + + // trigger + case Buttons.LeftTrigger: + triggers = new GamePadTriggers(newState.Triggers.Left, triggers.Right); + break; + case Buttons.RightTrigger: + triggers = new GamePadTriggers(triggers.Left, newState.Triggers.Right); + break; + + // thumbstick + case Buttons.LeftThumbstickDown: + case Buttons.LeftThumbstickLeft: + case Buttons.LeftThumbstickRight: + case Buttons.LeftThumbstickUp: + thumbsticks = new GamePadThumbSticks(newState.ThumbSticks.Left, thumbsticks.Right); + break; + case Buttons.RightThumbstickDown: + case Buttons.RightThumbstickLeft: + case Buttons.RightThumbstickRight: + case Buttons.RightThumbstickUp: + thumbsticks = new GamePadThumbSticks(newState.ThumbSticks.Right, thumbsticks.Left); + break; + + // buttons + default: + var mask = + (buttons.A == ButtonState.Pressed ? Buttons.A : 0) + | (buttons.B == ButtonState.Pressed ? Buttons.B : 0) + | (buttons.Back == ButtonState.Pressed ? Buttons.Back : 0) + | (buttons.BigButton == ButtonState.Pressed ? Buttons.BigButton : 0) + | (buttons.LeftShoulder == ButtonState.Pressed ? Buttons.LeftShoulder : 0) + | (buttons.LeftStick == ButtonState.Pressed ? Buttons.LeftStick : 0) + | (buttons.RightShoulder == ButtonState.Pressed ? Buttons.RightShoulder : 0) + | (buttons.RightStick == ButtonState.Pressed ? Buttons.RightStick : 0) + | (buttons.Start == ButtonState.Pressed ? Buttons.Start : 0) + | (buttons.X == ButtonState.Pressed ? Buttons.X : 0) + | (buttons.Y == ButtonState.Pressed ? Buttons.Y : 0); + mask = mask ^ controllerButton; + buttons = new GamePadButtons(mask); + break; + } + + Game1.oldPadState = new GamePadState(thumbsticks, triggers, buttons, dpad); + } + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsIntChanged.cs b/src/StardewModdingAPI/Events/EventArgsIntChanged.cs new file mode 100644 index 00000000..0c742d12 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsIntChanged.cs @@ -0,0 +1,29 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an integer field that changed value.</summary> + public class EventArgsIntChanged : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The previous value.</summary> + public int PriorInt { get; } + + /// <summary>The current value.</summary> + public int NewInt { get; } + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="priorInt">The previous value.</param> + /// <param name="newInt">The current value.</param> + public EventArgsIntChanged(int priorInt, int newInt) + { + this.PriorInt = priorInt; + this.NewInt = newInt; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsInventoryChanged.cs b/src/StardewModdingAPI/Events/EventArgsInventoryChanged.cs new file mode 100644 index 00000000..1ee02842 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsInventoryChanged.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="PlayerEvents.InventoryChanged"/> event.</summary> + public class EventArgsInventoryChanged : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The player's inventory.</summary> + public List<Item> Inventory { get; } + + /// <summary>The added items.</summary> + public List<ItemStackChange> Added { get; } + + /// <summary>The removed items.</summary> + public List<ItemStackChange> Removed { get; } + + /// <summary>The items whose stack sizes changed.</summary> + public List<ItemStackChange> QuantityChanged { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="inventory">The player's inventory.</param> + /// <param name="changedItems">The inventory changes.</param> + public EventArgsInventoryChanged(List<Item> inventory, List<ItemStackChange> changedItems) + { + this.Inventory = inventory; + this.Added = changedItems.Where(n => n.ChangeType == ChangeType.Added).ToList(); + this.Removed = changedItems.Where(n => n.ChangeType == ChangeType.Removed).ToList(); + this.QuantityChanged = changedItems.Where(n => n.ChangeType == ChangeType.StackChange).ToList(); + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsKeyPressed.cs b/src/StardewModdingAPI/Events/EventArgsKeyPressed.cs new file mode 100644 index 00000000..d9d81e10 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsKeyPressed.cs @@ -0,0 +1,26 @@ +using System; +using Microsoft.Xna.Framework.Input; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="ControlEvents.KeyboardChanged"/> event.</summary> + public class EventArgsKeyPressed : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The keyboard button that was pressed.</summary> + public Keys KeyPressed { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="key">The keyboard button that was pressed.</param> + public EventArgsKeyPressed(Keys key) + { + this.KeyPressed = key; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsKeyboardStateChanged.cs b/src/StardewModdingAPI/Events/EventArgsKeyboardStateChanged.cs new file mode 100644 index 00000000..14e397ce --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsKeyboardStateChanged.cs @@ -0,0 +1,31 @@ +using System; +using Microsoft.Xna.Framework.Input; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="ControlEvents.KeyboardChanged"/> event.</summary> + public class EventArgsKeyboardStateChanged : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The previous keyboard state.</summary> + public KeyboardState NewState { get; } + + /// <summary>The current keyboard state.</summary> + public KeyboardState PriorState { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="priorState">The previous keyboard state.</param> + /// <param name="newState">The current keyboard state.</param> + public EventArgsKeyboardStateChanged(KeyboardState priorState, KeyboardState newState) + { + this.PriorState = priorState; + this.NewState = newState; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsLevelUp.cs b/src/StardewModdingAPI/Events/EventArgsLevelUp.cs new file mode 100644 index 00000000..fe6696d4 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsLevelUp.cs @@ -0,0 +1,52 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="PlayerEvents.LeveledUp"/> event.</summary> + public class EventArgsLevelUp : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The player skill that leveled up.</summary> + public LevelType Type { get; } + + /// <summary>The new skill level.</summary> + public int NewLevel { get; } + + /// <summary>The player skill types.</summary> + public enum LevelType + { + /// <summary>The combat skill.</summary> + Combat, + + /// <summary>The farming skill.</summary> + Farming, + + /// <summary>The fishing skill.</summary> + Fishing, + + /// <summary>The foraging skill.</summary> + Foraging, + + /// <summary>The mining skill.</summary> + Mining, + + /// <summary>The luck skill.</summary> + Luck + } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="type">The player skill that leveled up.</param> + /// <param name="newLevel">The new skill level.</param> + public EventArgsLevelUp(LevelType type, int newLevel) + { + this.Type = type; + this.NewLevel = newLevel; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsLocationObjectsChanged.cs b/src/StardewModdingAPI/Events/EventArgsLocationObjectsChanged.cs new file mode 100644 index 00000000..058999e9 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsLocationObjectsChanged.cs @@ -0,0 +1,28 @@ +using System; +using Microsoft.Xna.Framework; +using StardewValley; +using Object = StardewValley.Object; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="LocationEvents.LocationObjectsChanged"/> event.</summary> + public class EventArgsLocationObjectsChanged : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The current list of objects in the current location.</summary> + public SerializableDictionary<Vector2, Object> NewObjects { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="newObjects">The current list of objects in the current location.</param> + public EventArgsLocationObjectsChanged(SerializableDictionary<Vector2, Object> newObjects) + { + this.NewObjects = newObjects; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsMineLevelChanged.cs b/src/StardewModdingAPI/Events/EventArgsMineLevelChanged.cs new file mode 100644 index 00000000..c82fed35 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsMineLevelChanged.cs @@ -0,0 +1,30 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="MineEvents.MineLevelChanged"/> event.</summary> + public class EventArgsMineLevelChanged : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The previous mine level.</summary> + public int PreviousMineLevel { get; } + + /// <summary>The current mine level.</summary> + public int CurrentMineLevel { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="previousMineLevel">The previous mine level.</param> + /// <param name="currentMineLevel">The current mine level.</param> + public EventArgsMineLevelChanged(int previousMineLevel, int currentMineLevel) + { + this.PreviousMineLevel = previousMineLevel; + this.CurrentMineLevel = currentMineLevel; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsMouseStateChanged.cs b/src/StardewModdingAPI/Events/EventArgsMouseStateChanged.cs new file mode 100644 index 00000000..57298164 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsMouseStateChanged.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="ControlEvents.MouseChanged"/> event.</summary> + public class EventArgsMouseStateChanged : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The previous mouse state.</summary> + public MouseState PriorState { get; } + + /// <summary>The current mouse state.</summary> + public MouseState NewState { get; } + + /// <summary>The previous mouse position on the screen adjusted for the zoom level.</summary> + public Point PriorPosition { get; } + + /// <summary>The current mouse position on the screen adjusted for the zoom level.</summary> + public Point NewPosition { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="priorState">The previous mouse state.</param> + /// <param name="newState">The current mouse state.</param> + /// <param name="priorPosition">The previous mouse position on the screen adjusted for the zoom level.</param> + /// <param name="newPosition">The current mouse position on the screen adjusted for the zoom level.</param> + public EventArgsMouseStateChanged(MouseState priorState, MouseState newState, Point priorPosition, Point newPosition) + { + this.PriorState = priorState; + this.NewState = newState; + this.PriorPosition = priorPosition; + this.NewPosition = newPosition; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsValueChanged.cs b/src/StardewModdingAPI/Events/EventArgsValueChanged.cs new file mode 100644 index 00000000..1d25af49 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsValueChanged.cs @@ -0,0 +1,31 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a field that changed value.</summary> + /// <typeparam name="T">The value type.</typeparam> + public class EventArgsValueChanged<T> : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The previous value.</summary> + public T PriorValue { get; } + + /// <summary>The current value.</summary> + public T NewValue { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="priorValue">The previous value.</param> + /// <param name="newValue">The current value.</param> + public EventArgsValueChanged(T priorValue, T newValue) + { + this.PriorValue = priorValue; + this.NewValue = newValue; + } + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Events/GameEvents.cs b/src/StardewModdingAPI/Events/GameEvents.cs new file mode 100644 index 00000000..b477376e --- /dev/null +++ b/src/StardewModdingAPI/Events/GameEvents.cs @@ -0,0 +1,96 @@ +using System; +using StardewModdingAPI.Framework; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events raised when the game changes state.</summary> + public static class GameEvents + { + /********* + ** Events + *********/ + /// <summary>Raised during launch after configuring XNA or MonoGame. The game window hasn't been opened by this point. Called after <see cref="Microsoft.Xna.Framework.Game.Initialize"/>.</summary> + internal static event EventHandler InitializeInternal; + + /// <summary>Raised when the game updates its state (≈60 times per second).</summary> + public static event EventHandler UpdateTick; + + /// <summary>Raised every other tick (≈30 times per second).</summary> + public static event EventHandler SecondUpdateTick; + + /// <summary>Raised every fourth tick (≈15 times per second).</summary> + public static event EventHandler FourthUpdateTick; + + /// <summary>Raised every eighth tick (≈8 times per second).</summary> + public static event EventHandler EighthUpdateTick; + + /// <summary>Raised every 15th tick (≈4 times per second).</summary> + public static event EventHandler QuarterSecondTick; + + /// <summary>Raised every 30th tick (≈twice per second).</summary> + public static event EventHandler HalfSecondTick; + + /// <summary>Raised every 60th tick (≈once per second).</summary> + public static event EventHandler OneSecondTick; + + + /********* + ** Internal methods + *********/ + /// <summary>Raise an <see cref="InitializeInternal"/> event.</summary> + /// <param name="monitor">Encapsulates logging and monitoring.</param> + internal static void InvokeInitialize(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.InitializeInternal)}", GameEvents.InitializeInternal?.GetInvocationList()); + } + + /// <summary>Raise an <see cref="UpdateTick"/> event.</summary> + /// <param name="monitor">Encapsulates logging and monitoring.</param> + internal static void InvokeUpdateTick(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.UpdateTick)}", GameEvents.UpdateTick?.GetInvocationList()); + } + + /// <summary>Raise a <see cref="SecondUpdateTick"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeSecondUpdateTick(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.SecondUpdateTick)}", GameEvents.SecondUpdateTick?.GetInvocationList()); + } + + /// <summary>Raise a <see cref="FourthUpdateTick"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeFourthUpdateTick(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.FourthUpdateTick)}", GameEvents.FourthUpdateTick?.GetInvocationList()); + } + + /// <summary>Raise a <see cref="EighthUpdateTick"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeEighthUpdateTick(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.EighthUpdateTick)}", GameEvents.EighthUpdateTick?.GetInvocationList()); + } + + /// <summary>Raise a <see cref="QuarterSecondTick"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeQuarterSecondTick(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.QuarterSecondTick)}", GameEvents.QuarterSecondTick?.GetInvocationList()); + } + + /// <summary>Raise a <see cref="HalfSecondTick"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeHalfSecondTick(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.HalfSecondTick)}", GameEvents.HalfSecondTick?.GetInvocationList()); + } + + /// <summary>Raise a <see cref="OneSecondTick"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeOneSecondTick(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.OneSecondTick)}", GameEvents.OneSecondTick?.GetInvocationList()); + } + } +} diff --git a/src/StardewModdingAPI/Events/GraphicsEvents.cs b/src/StardewModdingAPI/Events/GraphicsEvents.cs new file mode 100644 index 00000000..fff51bed --- /dev/null +++ b/src/StardewModdingAPI/Events/GraphicsEvents.cs @@ -0,0 +1,116 @@ +using System; +using StardewModdingAPI.Framework; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events raised during the game's draw loop, when the game is rendering content to the window.</summary> + public static class GraphicsEvents + { + /********* + ** Events + *********/ + /**** + ** Generic events + ****/ + /// <summary>Raised after the game window is resized.</summary> + public static event EventHandler Resize; + + /**** + ** Main render events + ****/ + /// <summary>Raised before drawing the world to the screen.</summary> + public static event EventHandler OnPreRenderEvent; + + /// <summary>Raised after drawing the world to the screen.</summary> + public static event EventHandler OnPostRenderEvent; + + /**** + ** HUD events + ****/ + /// <summary>Raised before drawing the HUD (item toolbar, clock, etc) to the screen. The HUD is available at this point, but not necessarily visible. (For example, the event is raised even if a menu is open.)</summary> + public static event EventHandler OnPreRenderHudEvent; + + /// <summary>Raised after drawing the HUD (item toolbar, clock, etc) to the screen. The HUD is available at this point, but not necessarily visible. (For example, the event is raised even if a menu is open.)</summary> + public static event EventHandler OnPostRenderHudEvent; + + /**** + ** GUI events + ****/ + /// <summary>Raised before drawing a menu to the screen during a draw loop. This includes the game's internal menus like the title screen.</summary> + public static event EventHandler OnPreRenderGuiEvent; + + /// <summary>Raised after drawing a menu to the screen during a draw loop. This includes the game's internal menus like the title screen.</summary> + public static event EventHandler OnPostRenderGuiEvent; + + + /********* + ** Internal methods + *********/ + /**** + ** Generic events + ****/ + /// <summary>Raise a <see cref="Resize"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeResize(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.Resize)}", GraphicsEvents.Resize?.GetInvocationList()); + } + + /**** + ** Main render events + ****/ + /// <summary>Raise an <see cref="OnPreRenderEvent"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeOnPreRenderEvent(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.OnPreRenderEvent)}", GraphicsEvents.OnPreRenderEvent?.GetInvocationList()); + } + + /// <summary>Raise an <see cref="OnPostRenderEvent"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeOnPostRenderEvent(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.OnPostRenderEvent)}", GraphicsEvents.OnPostRenderEvent?.GetInvocationList()); + } + + /// <summary>Get whether there are any post-render event listeners.</summary> + internal static bool HasPostRenderListeners() + { + return GraphicsEvents.OnPostRenderEvent != null; + } + + /**** + ** GUI events + ****/ + /// <summary>Raise an <see cref="OnPreRenderGuiEvent"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeOnPreRenderGuiEvent(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.OnPreRenderGuiEvent)}", GraphicsEvents.OnPreRenderGuiEvent?.GetInvocationList()); + } + + /// <summary>Raise an <see cref="OnPostRenderGuiEvent"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeOnPostRenderGuiEvent(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.OnPostRenderGuiEvent)}", GraphicsEvents.OnPostRenderGuiEvent?.GetInvocationList()); + } + + /**** + ** HUD events + ****/ + /// <summary>Raise an <see cref="OnPreRenderHudEvent"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeOnPreRenderHudEvent(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.OnPreRenderHudEvent)}", GraphicsEvents.OnPreRenderHudEvent?.GetInvocationList()); + } + + /// <summary>Raise an <see cref="OnPostRenderHudEvent"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeOnPostRenderHudEvent(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.OnPostRenderHudEvent)}", GraphicsEvents.OnPostRenderHudEvent?.GetInvocationList()); + } + } +} diff --git a/src/StardewModdingAPI/Events/InputEvents.cs b/src/StardewModdingAPI/Events/InputEvents.cs new file mode 100644 index 00000000..c31eb698 --- /dev/null +++ b/src/StardewModdingAPI/Events/InputEvents.cs @@ -0,0 +1,43 @@ +using System; +using StardewModdingAPI.Framework; +using StardewModdingAPI.Utilities; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events raised when the player uses a controller, keyboard, or mouse button.</summary> + public static class InputEvents + { + /********* + ** Events + *********/ + /// <summary>Raised when the player presses a button on the keyboard, controller, or mouse.</summary> + public static event EventHandler<EventArgsInput> ButtonPressed; + + /// <summary>Raised when the player releases a keyboard key on the keyboard, controller, or mouse.</summary> + public static event EventHandler<EventArgsInput> ButtonReleased; + + + /********* + ** Internal methods + *********/ + /// <summary>Raise a <see cref="ButtonPressed"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="button">The button on the controller, keyboard, or mouse.</param> + /// <param name="cursor">The cursor position.</param> + /// <param name="isClick">Whether the input is considered a 'click' by the game for enabling action.</param> + internal static void InvokeButtonPressed(IMonitor monitor, SButton button, ICursorPosition cursor, bool isClick) + { + monitor.SafelyRaiseGenericEvent($"{nameof(InputEvents)}.{nameof(InputEvents.ButtonPressed)}", InputEvents.ButtonPressed?.GetInvocationList(), null, new EventArgsInput(button, cursor, isClick)); + } + + /// <summary>Raise a <see cref="ButtonReleased"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="button">The button on the controller, keyboard, or mouse.</param> + /// <param name="cursor">The cursor position.</param> + /// <param name="isClick">Whether the input is considered a 'click' by the game for enabling action.</param> + internal static void InvokeButtonReleased(IMonitor monitor, SButton button, ICursorPosition cursor, bool isClick) + { + monitor.SafelyRaiseGenericEvent($"{nameof(InputEvents)}.{nameof(InputEvents.ButtonReleased)}", InputEvents.ButtonReleased?.GetInvocationList(), null, new EventArgsInput(button, cursor, isClick)); + } + } +} diff --git a/src/StardewModdingAPI/Events/ItemStackChange.cs b/src/StardewModdingAPI/Events/ItemStackChange.cs new file mode 100644 index 00000000..f9ae6df6 --- /dev/null +++ b/src/StardewModdingAPI/Events/ItemStackChange.cs @@ -0,0 +1,20 @@ +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Represents an inventory slot that changed.</summary> + public class ItemStackChange + { + /********* + ** Accessors + *********/ + /// <summary>The item in the slot.</summary> + public Item Item { get; set; } + + /// <summary>The amount by which the item's stack size changed.</summary> + public int StackChange { get; set; } + + /// <summary>How the inventory slot changed.</summary> + public ChangeType ChangeType { get; set; } + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Events/LocationEvents.cs b/src/StardewModdingAPI/Events/LocationEvents.cs new file mode 100644 index 00000000..b834bc1c --- /dev/null +++ b/src/StardewModdingAPI/Events/LocationEvents.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework; +using StardewValley; +using Object = StardewValley.Object; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events raised when the player transitions between game locations, a location is added or removed, or the objects in the current location change.</summary> + public static class LocationEvents + { + /********* + ** Events + *********/ + /// <summary>Raised after the player warps to a new location.</summary> + public static event EventHandler<EventArgsCurrentLocationChanged> CurrentLocationChanged; + + /// <summary>Raised after a game location is added or removed.</summary> + public static event EventHandler<EventArgsGameLocationsChanged> LocationsChanged; + + /// <summary>Raised after the list of objects in the current location changes (e.g. an object is added or removed).</summary> + public static event EventHandler<EventArgsLocationObjectsChanged> LocationObjectsChanged; + + + /********* + ** Internal methods + *********/ + /// <summary>Raise a <see cref="CurrentLocationChanged"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="priorLocation">The player's previous location.</param> + /// <param name="newLocation">The player's current location.</param> + internal static void InvokeCurrentLocationChanged(IMonitor monitor, GameLocation priorLocation, GameLocation newLocation) + { + monitor.SafelyRaiseGenericEvent($"{nameof(LocationEvents)}.{nameof(LocationEvents.CurrentLocationChanged)}", LocationEvents.CurrentLocationChanged?.GetInvocationList(), null, new EventArgsCurrentLocationChanged(priorLocation, newLocation)); + } + + /// <summary>Raise a <see cref="LocationsChanged"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="newLocations">The current list of game locations.</param> + internal static void InvokeLocationsChanged(IMonitor monitor, List<GameLocation> newLocations) + { + monitor.SafelyRaiseGenericEvent($"{nameof(LocationEvents)}.{nameof(LocationEvents.LocationsChanged)}", LocationEvents.LocationsChanged?.GetInvocationList(), null, new EventArgsGameLocationsChanged(newLocations)); + } + + /// <summary>Raise a <see cref="LocationObjectsChanged"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="newObjects">The current list of objects in the current location.</param> + internal static void InvokeOnNewLocationObject(IMonitor monitor, SerializableDictionary<Vector2, Object> newObjects) + { + monitor.SafelyRaiseGenericEvent($"{nameof(LocationEvents)}.{nameof(LocationEvents.LocationObjectsChanged)}", LocationEvents.LocationObjectsChanged?.GetInvocationList(), null, new EventArgsLocationObjectsChanged(newObjects)); + } + } +} diff --git a/src/StardewModdingAPI/Events/MenuEvents.cs b/src/StardewModdingAPI/Events/MenuEvents.cs new file mode 100644 index 00000000..bd8d897e --- /dev/null +++ b/src/StardewModdingAPI/Events/MenuEvents.cs @@ -0,0 +1,40 @@ +using System; +using StardewModdingAPI.Framework; +using StardewValley.Menus; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events raised when a game menu is opened or closed (including internal menus like the title screen).</summary> + public static class MenuEvents + { + /********* + ** Events + *********/ + /// <summary>Raised after a game menu is opened or replaced with another menu. This event is not invoked when a menu is closed.</summary> + public static event EventHandler<EventArgsClickableMenuChanged> MenuChanged; + + /// <summary>Raised after a game menu is closed.</summary> + public static event EventHandler<EventArgsClickableMenuClosed> MenuClosed; + + + /********* + ** Internal methods + *********/ + /// <summary>Raise a <see cref="MenuChanged"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="priorMenu">The previous menu.</param> + /// <param name="newMenu">The current menu.</param> + internal static void InvokeMenuChanged(IMonitor monitor, IClickableMenu priorMenu, IClickableMenu newMenu) + { + monitor.SafelyRaiseGenericEvent($"{nameof(MenuEvents)}.{nameof(MenuEvents.MenuChanged)}", MenuEvents.MenuChanged?.GetInvocationList(), null, new EventArgsClickableMenuChanged(priorMenu, newMenu)); + } + + /// <summary>Raise a <see cref="MenuClosed"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="priorMenu">The menu that was closed.</param> + internal static void InvokeMenuClosed(IMonitor monitor, IClickableMenu priorMenu) + { + monitor.SafelyRaiseGenericEvent($"{nameof(MenuEvents)}.{nameof(MenuEvents.MenuClosed)}", MenuEvents.MenuClosed?.GetInvocationList(), null, new EventArgsClickableMenuClosed(priorMenu)); + } + } +} diff --git a/src/StardewModdingAPI/Events/MineEvents.cs b/src/StardewModdingAPI/Events/MineEvents.cs new file mode 100644 index 00000000..9cf7edac --- /dev/null +++ b/src/StardewModdingAPI/Events/MineEvents.cs @@ -0,0 +1,28 @@ +using System; +using StardewModdingAPI.Framework; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events raised when something happens in the mines.</summary> + public static class MineEvents + { + /********* + ** Events + *********/ + /// <summary>Raised after the player warps to a new level of the mine.</summary> + public static event EventHandler<EventArgsMineLevelChanged> MineLevelChanged; + + + /********* + ** Internal methods + *********/ + /// <summary>Raise a <see cref="MineLevelChanged"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="previousMineLevel">The previous mine level.</param> + /// <param name="currentMineLevel">The current mine level.</param> + internal static void InvokeMineLevelChanged(IMonitor monitor, int previousMineLevel, int currentMineLevel) + { + monitor.SafelyRaiseGenericEvent($"{nameof(MineEvents)}.{nameof(MineEvents.MineLevelChanged)}", MineEvents.MineLevelChanged?.GetInvocationList(), null, new EventArgsMineLevelChanged(previousMineLevel, currentMineLevel)); + } + } +} diff --git a/src/StardewModdingAPI/Events/PlayerEvents.cs b/src/StardewModdingAPI/Events/PlayerEvents.cs new file mode 100644 index 00000000..5a9a9d5f --- /dev/null +++ b/src/StardewModdingAPI/Events/PlayerEvents.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Framework; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events raised when the player data changes.</summary> + public static class PlayerEvents + { + /********* + ** Events + *********/ + /// <summary>Raised after the player's inventory changes in any way (added or removed item, sorted, etc).</summary> + public static event EventHandler<EventArgsInventoryChanged> InventoryChanged; + + /// <summary> Raised after the player levels up a skill. This happens as soon as they level up, not when the game notifies the player after their character goes to bed.</summary> + public static event EventHandler<EventArgsLevelUp> LeveledUp; + + + /********* + ** Internal methods + *********/ + /// <summary>Raise an <see cref="InventoryChanged"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="inventory">The player's inventory.</param> + /// <param name="changedItems">The inventory changes.</param> + internal static void InvokeInventoryChanged(IMonitor monitor, List<Item> inventory, IEnumerable<ItemStackChange> changedItems) + { + monitor.SafelyRaiseGenericEvent($"{nameof(PlayerEvents)}.{nameof(PlayerEvents.InventoryChanged)}", PlayerEvents.InventoryChanged?.GetInvocationList(), null, new EventArgsInventoryChanged(inventory, changedItems.ToList())); + } + + /// <summary>Rase a <see cref="LeveledUp"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="type">The player skill that leveled up.</param> + /// <param name="newLevel">The new skill level.</param> + internal static void InvokeLeveledUp(IMonitor monitor, EventArgsLevelUp.LevelType type, int newLevel) + { + monitor.SafelyRaiseGenericEvent($"{nameof(PlayerEvents)}.{nameof(PlayerEvents.LeveledUp)}", PlayerEvents.LeveledUp?.GetInvocationList(), null, new EventArgsLevelUp(type, newLevel)); + } + } +} diff --git a/src/StardewModdingAPI/Events/SaveEvents.cs b/src/StardewModdingAPI/Events/SaveEvents.cs new file mode 100644 index 00000000..50e6d729 --- /dev/null +++ b/src/StardewModdingAPI/Events/SaveEvents.cs @@ -0,0 +1,56 @@ +using System; +using StardewModdingAPI.Framework; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events raised before and after the player saves/loads the game.</summary> + public static class SaveEvents + { + /********* + ** Events + *********/ + /// <summary>Raised before the game begins writes data to the save file.</summary> + public static event EventHandler BeforeSave; + + /// <summary>Raised after the game finishes writing data to the save file.</summary> + public static event EventHandler AfterSave; + + /// <summary>Raised after the player loads a save slot.</summary> + public static event EventHandler AfterLoad; + + /// <summary>Raised after the game returns to the title screen.</summary> + public static event EventHandler AfterReturnToTitle; + + + /********* + ** Internal methods + *********/ + /// <summary>Raise a <see cref="BeforeSave"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeBeforeSave(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(SaveEvents)}.{nameof(SaveEvents.BeforeSave)}", SaveEvents.BeforeSave?.GetInvocationList(), null, EventArgs.Empty); + } + + /// <summary>Raise a <see cref="AfterSave"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeAfterSave(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(SaveEvents)}.{nameof(SaveEvents.AfterSave)}", SaveEvents.AfterSave?.GetInvocationList(), null, EventArgs.Empty); + } + + /// <summary>Raise a <see cref="AfterLoad"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeAfterLoad(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(SaveEvents)}.{nameof(SaveEvents.AfterLoad)}", SaveEvents.AfterLoad?.GetInvocationList(), null, EventArgs.Empty); + } + + /// <summary>Raise a <see cref="AfterReturnToTitle"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeAfterReturnToTitle(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(SaveEvents)}.{nameof(SaveEvents.AfterReturnToTitle)}", SaveEvents.AfterReturnToTitle?.GetInvocationList(), null, EventArgs.Empty); + } + } +} diff --git a/src/StardewModdingAPI/Events/TimeEvents.cs b/src/StardewModdingAPI/Events/TimeEvents.cs new file mode 100644 index 00000000..9aea5e04 --- /dev/null +++ b/src/StardewModdingAPI/Events/TimeEvents.cs @@ -0,0 +1,37 @@ +using System; +using StardewModdingAPI.Framework; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events raised when the in-game date or time changes.</summary> + public static class TimeEvents + { + /********* + ** Events + *********/ + /// <summary>Raised after the game begins a new day, including when loading a save.</summary> + public static event EventHandler AfterDayStarted; + + /// <summary>Raised after the in-game clock changes.</summary> + public static event EventHandler<EventArgsIntChanged> TimeOfDayChanged; + + /********* + ** Internal methods + *********/ + /// <summary>Raise an <see cref="AfterDayStarted"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeAfterDayStarted(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(TimeEvents)}.{nameof(TimeEvents.AfterDayStarted)}", TimeEvents.AfterDayStarted?.GetInvocationList(), null, EventArgs.Empty); + } + + /// <summary>Raise a <see cref="TimeOfDayChanged"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="priorTime">The previous time in military time format (e.g. 6:00pm is 1800).</param> + /// <param name="newTime">The current time in military time format (e.g. 6:10pm is 1810).</param> + internal static void InvokeTimeOfDayChanged(IMonitor monitor, int priorTime, int newTime) + { + monitor.SafelyRaiseGenericEvent($"{nameof(TimeEvents)}.{nameof(TimeEvents.TimeOfDayChanged)}", TimeEvents.TimeOfDayChanged?.GetInvocationList(), null, new EventArgsIntChanged(priorTime, newTime)); + } + } +} diff --git a/src/StardewModdingAPI/Framework/Command.cs b/src/StardewModdingAPI/Framework/Command.cs new file mode 100644 index 00000000..943e018d --- /dev/null +++ b/src/StardewModdingAPI/Framework/Command.cs @@ -0,0 +1,40 @@ +using System; + +namespace StardewModdingAPI.Framework +{ + /// <summary>A command that can be submitted through the SMAPI console to interact with SMAPI.</summary> + internal class Command + { + /********* + ** Accessor + *********/ + /// <summary>The friendly name for the mod that registered the command.</summary> + public string ModName { get; } + + /// <summary>The command name, which the user must type to trigger it.</summary> + public string Name { get; } + + /// <summary>The human-readable documentation shown when the player runs the built-in 'help' command.</summary> + public string Documentation { get; } + + /// <summary>The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user.</summary> + public Action<string, string[]> Callback { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="modName">The friendly name for the mod that registered the command.</param> + /// <param name="name">The command name, which the user must type to trigger it.</param> + /// <param name="documentation">The human-readable documentation shown when the player runs the built-in 'help' command.</param> + /// <param name="callback">The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user.</param> + public Command(string modName, string name, string documentation, Action<string, string[]> callback) + { + this.ModName = modName; + this.Name = name; + this.Documentation = documentation; + this.Callback = callback; + } + } +} diff --git a/src/StardewModdingAPI/Framework/CommandManager.cs b/src/StardewModdingAPI/Framework/CommandManager.cs new file mode 100644 index 00000000..79a23d03 --- /dev/null +++ b/src/StardewModdingAPI/Framework/CommandManager.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Manages console commands.</summary> + internal class CommandManager + { + /********* + ** Properties + *********/ + /// <summary>The commands registered with SMAPI.</summary> + private readonly IDictionary<string, Command> Commands = new Dictionary<string, Command>(StringComparer.InvariantCultureIgnoreCase); + + + /********* + ** Public methods + *********/ + /// <summary>Add a console command.</summary> + /// <param name="modName">The friendly mod name for this instance.</param> + /// <param name="name">The command name, which the user must type to trigger it.</param> + /// <param name="documentation">The human-readable documentation shown when the player runs the built-in 'help' command.</param> + /// <param name="callback">The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user.</param> + /// <param name="allowNullCallback">Whether to allow a null <paramref name="callback"/> argument; this should only used for backwards compatibility.</param> + /// <exception cref="ArgumentNullException">The <paramref name="name"/> or <paramref name="callback"/> is null or empty.</exception> + /// <exception cref="FormatException">The <paramref name="name"/> is not a valid format.</exception> + /// <exception cref="ArgumentException">There's already a command with that name.</exception> + public void Add(string modName, string name, string documentation, Action<string, string[]> callback, bool allowNullCallback = false) + { + name = this.GetNormalisedName(name); + + // validate format + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentNullException(nameof(name), "Can't register a command with no name."); + if (name.Any(char.IsWhiteSpace)) + throw new FormatException($"Can't register the '{name}' command because the name can't contain whitespace."); + if (callback == null && !allowNullCallback) + throw new ArgumentNullException(nameof(callback), $"Can't register the '{name}' command because without a callback."); + + // ensure uniqueness + if (this.Commands.ContainsKey(name)) + throw new ArgumentException(nameof(callback), $"Can't register the '{name}' command because there's already a command with that name."); + + // add command + this.Commands.Add(name, new Command(modName, name, documentation, callback)); + } + + /// <summary>Get a command by its unique name.</summary> + /// <param name="name">The command name.</param> + /// <returns>Returns the matching command, or <c>null</c> if not found.</returns> + public Command Get(string name) + { + name = this.GetNormalisedName(name); + this.Commands.TryGetValue(name, out Command command); + return command; + } + + /// <summary>Get all registered commands.</summary> + public IEnumerable<Command> GetAll() + { + return this.Commands + .Values + .OrderBy(p => p.Name); + } + + /// <summary>Trigger a command.</summary> + /// <param name="input">The raw command input.</param> + /// <returns>Returns whether a matching command was triggered.</returns> + public bool Trigger(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return false; + + string[] args = input.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + string name = args[0]; + args = args.Skip(1).ToArray(); + + return this.Trigger(name, args); + } + + /// <summary>Trigger a command.</summary> + /// <param name="name">The command name.</param> + /// <param name="arguments">The command arguments.</param> + /// <returns>Returns whether a matching command was triggered.</returns> + public bool Trigger(string name, string[] arguments) + { + // get normalised name + name = this.GetNormalisedName(name); + if (name == null) + return false; + + // get command + if (this.Commands.TryGetValue(name, out Command command)) + { + command.Callback.Invoke(name, arguments); + return true; + } + return false; + } + + + /********* + ** Private methods + *********/ + /// <summary>Get a normalised command name.</summary> + /// <param name="name">The command name.</param> + private string GetNormalisedName(string name) + { + name = name?.Trim().ToLower(); + return !string.IsNullOrWhiteSpace(name) + ? name + : null; + } + } +} diff --git a/src/StardewModdingAPI/Framework/Content/AssetData.cs b/src/StardewModdingAPI/Framework/Content/AssetData.cs new file mode 100644 index 00000000..1ab9eebd --- /dev/null +++ b/src/StardewModdingAPI/Framework/Content/AssetData.cs @@ -0,0 +1,44 @@ +using System; + +namespace StardewModdingAPI.Framework.Content +{ + /// <summary>Base implementation for a content helper which encapsulates access and changes to content being read from a data file.</summary> + /// <typeparam name="TValue">The interface value type.</typeparam> + internal class AssetData<TValue> : AssetInfo, IAssetData<TValue> + { + /********* + ** Accessors + *********/ + /// <summary>The content data being read.</summary> + public TValue Data { get; protected set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="locale">The content's locale code, if the content is localised.</param> + /// <param name="assetName">The normalised asset name being read.</param> + /// <param name="data">The content data being read.</param> + /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + public AssetData(string locale, string assetName, TValue data, Func<string, string> getNormalisedPath) + : base(locale, assetName, data.GetType(), getNormalisedPath) + { + this.Data = data; + } + + /// <summary>Replace the entire content value with the given value. This is generally not recommended, since it may break compatibility with other mods or different versions of the game.</summary> + /// <param name="value">The new content value.</param> + /// <exception cref="ArgumentNullException">The <paramref name="value"/> is null.</exception> + /// <exception cref="InvalidCastException">The <paramref name="value"/>'s type is not compatible with the loaded asset's type.</exception> + public void ReplaceWith(TValue value) + { + if (value == null) + throw new ArgumentNullException(nameof(value), "Can't set a loaded asset to a null value."); + if (!this.DataType.IsInstanceOfType(value)) + throw new InvalidCastException($"Can't replace loaded asset of type {this.GetFriendlyTypeName(this.DataType)} with value of type {this.GetFriendlyTypeName(value.GetType())}. The new type must be compatible to prevent game errors."); + + this.Data = value; + } + } +} diff --git a/src/StardewModdingAPI/Framework/Content/AssetDataForDictionary.cs b/src/StardewModdingAPI/Framework/Content/AssetDataForDictionary.cs new file mode 100644 index 00000000..e9b29b12 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Content/AssetDataForDictionary.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace StardewModdingAPI.Framework.Content +{ + /// <summary>Encapsulates access and changes to dictionary content being read from a data file.</summary> + internal class AssetDataForDictionary<TKey, TValue> : AssetData<IDictionary<TKey, TValue>>, IAssetDataForDictionary<TKey, TValue> + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="locale">The content's locale code, if the content is localised.</param> + /// <param name="assetName">The normalised asset name being read.</param> + /// <param name="data">The content data being read.</param> + /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + public AssetDataForDictionary(string locale, string assetName, IDictionary<TKey, TValue> data, Func<string, string> getNormalisedPath) + : base(locale, assetName, data, getNormalisedPath) { } + + /// <summary>Add or replace an entry in the dictionary.</summary> + /// <param name="key">The entry key.</param> + /// <param name="value">The entry value.</param> + public void Set(TKey key, TValue value) + { + this.Data[key] = value; + } + + /// <summary>Add or replace an entry in the dictionary.</summary> + /// <param name="key">The entry key.</param> + /// <param name="value">A callback which accepts the current value and returns the new value.</param> + public void Set(TKey key, Func<TValue, TValue> value) + { + this.Data[key] = value(this.Data[key]); + } + + /// <summary>Dynamically replace values in the dictionary.</summary> + /// <param name="replacer">A lambda which takes the current key and value for an entry, and returns the new value.</param> + public void Set(Func<TKey, TValue, TValue> replacer) + { + foreach (var pair in this.Data.ToArray()) + this.Data[pair.Key] = replacer(pair.Key, pair.Value); + } + } +} diff --git a/src/StardewModdingAPI/Framework/Content/AssetDataForImage.cs b/src/StardewModdingAPI/Framework/Content/AssetDataForImage.cs new file mode 100644 index 00000000..45c5588b --- /dev/null +++ b/src/StardewModdingAPI/Framework/Content/AssetDataForImage.cs @@ -0,0 +1,70 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace StardewModdingAPI.Framework.Content +{ + /// <summary>Encapsulates access and changes to dictionary content being read from a data file.</summary> + internal class AssetDataForImage : AssetData<Texture2D>, IAssetDataForImage + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="locale">The content's locale code, if the content is localised.</param> + /// <param name="assetName">The normalised asset name being read.</param> + /// <param name="data">The content data being read.</param> + /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + public AssetDataForImage(string locale, string assetName, Texture2D data, Func<string, string> getNormalisedPath) + : base(locale, assetName, data, getNormalisedPath) { } + + /// <summary>Overwrite part of the image.</summary> + /// <param name="source">The image to patch into the content.</param> + /// <param name="sourceArea">The part of the <paramref name="source"/> to copy (or <c>null</c> to take the whole texture). This must be within the bounds of the <paramref name="source"/> texture.</param> + /// <param name="targetArea">The part of the content to patch (or <c>null</c> to patch the whole texture). The original content within this area will be erased. This must be within the bounds of the existing spritesheet.</param> + /// <param name="patchMode">Indicates how an image should be patched.</param> + /// <exception cref="ArgumentNullException">One of the arguments is null.</exception> + /// <exception cref="ArgumentOutOfRangeException">The <paramref name="targetArea"/> is outside the bounds of the spritesheet.</exception> + public void PatchImage(Texture2D source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace) + { + // get texture + Texture2D target = this.Data; + + // get areas + sourceArea = sourceArea ?? new Rectangle(0, 0, source.Width, source.Height); + targetArea = targetArea ?? new Rectangle(0, 0, Math.Min(sourceArea.Value.Width, target.Width), Math.Min(sourceArea.Value.Height, target.Height)); + + // validate + if (source == null) + throw new ArgumentNullException(nameof(source), "Can't patch from a null source texture."); + if (sourceArea.Value.X < 0 || sourceArea.Value.Y < 0 || sourceArea.Value.Right > source.Width || sourceArea.Value.Bottom > source.Height) + throw new ArgumentOutOfRangeException(nameof(sourceArea), "The source area is outside the bounds of the source texture."); + if (targetArea.Value.X < 0 || targetArea.Value.Y < 0 || targetArea.Value.Right > target.Width || targetArea.Value.Bottom > target.Height) + throw new ArgumentOutOfRangeException(nameof(targetArea), "The target area is outside the bounds of the target texture."); + if (sourceArea.Value.Width != targetArea.Value.Width || sourceArea.Value.Height != targetArea.Value.Height) + throw new InvalidOperationException("The source and target areas must be the same size."); + + // get source data + int pixelCount = sourceArea.Value.Width * sourceArea.Value.Height; + Color[] sourceData = new Color[pixelCount]; + source.GetData(0, sourceArea, sourceData, 0, pixelCount); + + // merge data in overlay mode + if (patchMode == PatchMode.Overlay) + { + Color[] newData = new Color[targetArea.Value.Width * targetArea.Value.Height]; + target.GetData(0, targetArea, newData, 0, newData.Length); + for (int i = 0; i < sourceData.Length; i++) + { + Color pixel = sourceData[i]; + if (pixel.A != 0) // not transparent + newData[i] = pixel; + } + sourceData = newData; + } + + // patch target texture + target.SetData(0, targetArea, sourceData, 0, pixelCount); + } + } +} diff --git a/src/StardewModdingAPI/Framework/Content/AssetDataForObject.cs b/src/StardewModdingAPI/Framework/Content/AssetDataForObject.cs new file mode 100644 index 00000000..f30003e4 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Content/AssetDataForObject.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework.Graphics; + +namespace StardewModdingAPI.Framework.Content +{ + /// <summary>Encapsulates access and changes to content being read from a data file.</summary> + internal class AssetDataForObject : AssetData<object>, IAssetData + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="locale">The content's locale code, if the content is localised.</param> + /// <param name="assetName">The normalised asset name being read.</param> + /// <param name="data">The content data being read.</param> + /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + public AssetDataForObject(string locale, string assetName, object data, Func<string, string> getNormalisedPath) + : base(locale, assetName, data, getNormalisedPath) { } + + /// <summary>Construct an instance.</summary> + /// <param name="info">The asset metadata.</param> + /// <param name="data">The content data being read.</param> + /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + public AssetDataForObject(IAssetInfo info, object data, Func<string, string> getNormalisedPath) + : this(info.Locale, info.AssetName, data, getNormalisedPath) { } + + /// <summary>Get a helper to manipulate the data as a dictionary.</summary> + /// <typeparam name="TKey">The expected dictionary key.</typeparam> + /// <typeparam name="TValue">The expected dictionary balue.</typeparam> + /// <exception cref="InvalidOperationException">The content being read isn't a dictionary.</exception> + public IAssetDataForDictionary<TKey, TValue> AsDictionary<TKey, TValue>() + { + return new AssetDataForDictionary<TKey, TValue>(this.Locale, this.AssetName, this.GetData<IDictionary<TKey, TValue>>(), this.GetNormalisedPath); + } + + /// <summary>Get a helper to manipulate the data as an image.</summary> + /// <exception cref="InvalidOperationException">The content being read isn't an image.</exception> + public IAssetDataForImage AsImage() + { + return new AssetDataForImage(this.Locale, this.AssetName, this.GetData<Texture2D>(), this.GetNormalisedPath); + } + + /// <summary>Get the data as a given type.</summary> + /// <typeparam name="TData">The expected data type.</typeparam> + /// <exception cref="InvalidCastException">The data can't be converted to <typeparamref name="TData"/>.</exception> + public TData GetData<TData>() + { + if (!(this.Data is TData)) + throw new InvalidCastException($"The content data of type {this.Data.GetType().FullName} can't be converted to the requested {typeof(TData).FullName}."); + return (TData)this.Data; + } + } +} diff --git a/src/StardewModdingAPI/Framework/Content/AssetInfo.cs b/src/StardewModdingAPI/Framework/Content/AssetInfo.cs new file mode 100644 index 00000000..d580dc06 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Content/AssetInfo.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework.Graphics; + +namespace StardewModdingAPI.Framework.Content +{ + internal class AssetInfo : IAssetInfo + { + /********* + ** Properties + *********/ + /// <summary>Normalises an asset key to match the cache key.</summary> + protected readonly Func<string, string> GetNormalisedPath; + + + /********* + ** Accessors + *********/ + /// <summary>The content's locale code, if the content is localised.</summary> + public string Locale { get; } + + /// <summary>The normalised asset name being read. The format may change between platforms; see <see cref="AssetNameEquals"/> to compare with a known path.</summary> + public string AssetName { get; } + + /// <summary>The content data type.</summary> + public Type DataType { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="locale">The content's locale code, if the content is localised.</param> + /// <param name="assetName">The normalised asset name being read.</param> + /// <param name="type">The content type being read.</param> + /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + public AssetInfo(string locale, string assetName, Type type, Func<string, string> getNormalisedPath) + { + this.Locale = locale; + this.AssetName = assetName; + this.DataType = type; + this.GetNormalisedPath = getNormalisedPath; + } + + /// <summary>Get whether the asset name being loaded matches a given name after normalisation.</summary> + /// <param name="path">The expected asset path, relative to the game's content folder and without the .xnb extension or locale suffix (like 'Data\ObjectInformation').</param> + public bool AssetNameEquals(string path) + { + path = this.GetNormalisedPath(path); + return this.AssetName.Equals(path, StringComparison.InvariantCultureIgnoreCase); + } + + + /********* + ** Protected methods + *********/ + /// <summary>Get a human-readable type name.</summary> + /// <param name="type">The type to name.</param> + protected string GetFriendlyTypeName(Type type) + { + // dictionary + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Dictionary<,>)) + { + Type[] genericArgs = type.GetGenericArguments(); + return $"Dictionary<{this.GetFriendlyTypeName(genericArgs[0])}, {this.GetFriendlyTypeName(genericArgs[1])}>"; + } + + // texture + if (type == typeof(Texture2D)) + return type.Name; + + // native type + if (type == typeof(int)) + return "int"; + if (type == typeof(string)) + return "string"; + + // default + return type.FullName; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ContentManagerShim.cs b/src/StardewModdingAPI/Framework/ContentManagerShim.cs new file mode 100644 index 00000000..d46f23a3 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ContentManagerShim.cs @@ -0,0 +1,50 @@ +using StardewValley; + +namespace StardewModdingAPI.Framework +{ + /// <summary>A minimal content manager which defers to SMAPI's main content manager.</summary> + internal class ContentManagerShim : LocalizedContentManager + { + /********* + ** Properties + *********/ + /// <summary>SMAPI's underlying content manager.</summary> + private readonly SContentManager ContentManager; + + + /********* + ** Accessors + *********/ + /// <summary>The content manager's name for logs (if any).</summary> + public string Name { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="contentManager">SMAPI's underlying content manager.</param> + /// <param name="name">The content manager's name for logs (if any).</param> + public ContentManagerShim(SContentManager contentManager, string name) + : base(contentManager.ServiceProvider, contentManager.RootDirectory, contentManager.CurrentCulture, contentManager.LanguageCodeOverride) + { + this.ContentManager = contentManager; + this.Name = name; + } + + /// <summary>Load an asset that has been processed by the content pipeline.</summary> + /// <typeparam name="T">The type of asset to load.</typeparam> + /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> + public override T Load<T>(string assetName) + { + return this.ContentManager.LoadFor<T>(assetName, this); + } + + /// <summary>Dispose held resources.</summary> + /// <param name="disposing">Whether the content manager is disposing (rather than finalising).</param> + protected override void Dispose(bool disposing) + { + this.ContentManager.DisposeFor(this); + } + } +} diff --git a/src/StardewModdingAPI/Framework/CursorPosition.cs b/src/StardewModdingAPI/Framework/CursorPosition.cs new file mode 100644 index 00000000..db02b3d1 --- /dev/null +++ b/src/StardewModdingAPI/Framework/CursorPosition.cs @@ -0,0 +1,35 @@ +using Microsoft.Xna.Framework; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Defines a position on a given map at different reference points.</summary> + internal class CursorPosition : ICursorPosition + { + /********* + ** Accessors + *********/ + /// <summary>The pixel position relative to the top-left corner of the visible screen.</summary> + public Vector2 ScreenPixels { get; } + + /// <summary>The tile position under the cursor relative to the top-left corner of the map.</summary> + public Vector2 Tile { get; } + + /// <summary>The tile position that the game considers under the cursor for purposes of clicking actions. This may be different than <see cref="Tile"/> if that's too far from the player.</summary> + public Vector2 GrabTile { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="screenPixels">The pixel position relative to the top-left corner of the visible screen.</param> + /// <param name="tile">The tile position relative to the top-left corner of the map.</param> + /// <param name="grabTile">The tile position that the game considers under the cursor for purposes of clicking actions.</param> + public CursorPosition(Vector2 screenPixels, Vector2 tile, Vector2 grabTile) + { + this.ScreenPixels = screenPixels; + this.Tile = tile; + this.GrabTile = grabTile; + } + } +} diff --git a/src/StardewModdingAPI/Framework/DeprecationLevel.cs b/src/StardewModdingAPI/Framework/DeprecationLevel.cs new file mode 100644 index 00000000..c0044053 --- /dev/null +++ b/src/StardewModdingAPI/Framework/DeprecationLevel.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Framework +{ + /// <summary>Indicates how deprecated something is.</summary> + internal enum DeprecationLevel + { + /// <summary>It's deprecated but won't be removed soon. Mod authors have some time to update their mods. Deprecation warnings should be logged, but not written to the console.</summary> + Notice, + + /// <summary>Mods should no longer be using it. Deprecation messages should be debug entries in the console.</summary> + Info, + + /// <summary>The code will be removed soon. Deprecation messages should be warnings in the console.</summary> + PendingRemoval + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Framework/DeprecationManager.cs b/src/StardewModdingAPI/Framework/DeprecationManager.cs new file mode 100644 index 00000000..b07c6c7d --- /dev/null +++ b/src/StardewModdingAPI/Framework/DeprecationManager.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Manages deprecation warnings.</summary> + internal class DeprecationManager + { + /********* + ** Properties + *********/ + /// <summary>The deprecations which have already been logged (as 'mod name::noun phrase::version').</summary> + private readonly HashSet<string> LoggedDeprecations = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); + + /// <summary>Encapsulates monitoring and logging for a given module.</summary> + private readonly IMonitor Monitor; + + /// <summary>Tracks the installed mods.</summary> + private readonly ModRegistry ModRegistry; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="monitor">Encapsulates monitoring and logging for a given module.</param> + /// <param name="modRegistry">Tracks the installed mods.</param> + public DeprecationManager(IMonitor monitor, ModRegistry modRegistry) + { + this.Monitor = monitor; + this.ModRegistry = modRegistry; + } + + /// <summary>Log a deprecation warning.</summary> + /// <param name="nounPhrase">A noun phrase describing what is deprecated.</param> + /// <param name="version">The SMAPI version which deprecated it.</param> + /// <param name="severity">How deprecated the code is.</param> + public void Warn(string nounPhrase, string version, DeprecationLevel severity) + { + this.Warn(this.ModRegistry.GetModFromStack(), nounPhrase, version, severity); + } + + /// <summary>Log a deprecation warning.</summary> + /// <param name="source">The friendly mod name which used the deprecated code.</param> + /// <param name="nounPhrase">A noun phrase describing what is deprecated.</param> + /// <param name="version">The SMAPI version which deprecated it.</param> + /// <param name="severity">How deprecated the code is.</param> + public void Warn(string source, string nounPhrase, string version, DeprecationLevel severity) + { + // ignore if already warned + if (!this.MarkWarned(source ?? "<unknown>", nounPhrase, version)) + return; + + // build message + string message = $"{source ?? "An unknown mod"} uses deprecated code ({nounPhrase})."; + if (source == null) + message += $"{Environment.NewLine}{Environment.StackTrace}"; + + // log message + switch (severity) + { + case DeprecationLevel.Notice: + this.Monitor.Log(message, LogLevel.Trace); + break; + + case DeprecationLevel.Info: + this.Monitor.Log(message, LogLevel.Debug); + break; + + case DeprecationLevel.PendingRemoval: + this.Monitor.Log(message, LogLevel.Warn); + break; + + default: + throw new NotSupportedException($"Unknown deprecation level '{severity}'"); + } + } + + /// <summary>Mark a deprecation warning as already logged.</summary> + /// <param name="nounPhrase">A noun phrase describing what is deprecated (e.g. "the Extensions.AsInt32 method").</param> + /// <param name="version">The SMAPI version which deprecated it.</param> + /// <returns>Returns whether the deprecation was successfully marked as warned. Returns <c>false</c> if it was already marked.</returns> + public bool MarkWarned(string nounPhrase, string version) + { + return this.MarkWarned(this.ModRegistry.GetModFromStack(), nounPhrase, version); + } + + /// <summary>Mark a deprecation warning as already logged.</summary> + /// <param name="source">The friendly name of the assembly which used the deprecated code.</param> + /// <param name="nounPhrase">A noun phrase describing what is deprecated (e.g. "the Extensions.AsInt32 method").</param> + /// <param name="version">The SMAPI version which deprecated it.</param> + /// <returns>Returns whether the deprecation was successfully marked as warned. Returns <c>false</c> if it was already marked.</returns> + public bool MarkWarned(string source, string nounPhrase, string version) + { + if (string.IsNullOrWhiteSpace(source)) + throw new InvalidOperationException("The deprecation source cannot be empty."); + + string key = $"{source}::{nounPhrase}::{version}"; + if (this.LoggedDeprecations.Contains(key)) + return false; + this.LoggedDeprecations.Add(key); + return true; + } + } +} diff --git a/src/StardewModdingAPI/Framework/Exceptions/SAssemblyLoadFailedException.cs b/src/StardewModdingAPI/Framework/Exceptions/SAssemblyLoadFailedException.cs new file mode 100644 index 00000000..ec9279f1 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Exceptions/SAssemblyLoadFailedException.cs @@ -0,0 +1,16 @@ +using System; + +namespace StardewModdingAPI.Framework.Exceptions +{ + /// <summary>An exception thrown when an assembly can't be loaded by SMAPI, with all the relevant details in the message.</summary> + internal class SAssemblyLoadFailedException : Exception + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="message">The error message.</param> + public SAssemblyLoadFailedException(string message) + : base(message) { } + } +} diff --git a/src/StardewModdingAPI/Framework/Exceptions/SContentLoadException.cs b/src/StardewModdingAPI/Framework/Exceptions/SContentLoadException.cs new file mode 100644 index 00000000..85d85e3d --- /dev/null +++ b/src/StardewModdingAPI/Framework/Exceptions/SContentLoadException.cs @@ -0,0 +1,18 @@ +using System; +using Microsoft.Xna.Framework.Content; + +namespace StardewModdingAPI.Framework.Exceptions +{ + /// <summary>An implementation of <see cref="ContentLoadException"/> used by SMAPI to detect whether it was thrown by SMAPI or the underlying framework.</summary> + internal class SContentLoadException : ContentLoadException + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="message">The error message.</param> + /// <param name="ex">The underlying exception, if any.</param> + public SContentLoadException(string message, Exception ex = null) + : base(message, ex) { } + } +} diff --git a/src/StardewModdingAPI/Framework/Exceptions/SParseException.cs b/src/StardewModdingAPI/Framework/Exceptions/SParseException.cs new file mode 100644 index 00000000..f7133ee7 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Exceptions/SParseException.cs @@ -0,0 +1,17 @@ +using System; + +namespace StardewModdingAPI.Framework.Exceptions +{ + /// <summary>A format exception which provides a user-facing error message.</summary> + internal class SParseException : FormatException + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="message">The error message.</param> + /// <param name="ex">The underlying exception, if any.</param> + public SParseException(string message, Exception ex = null) + : base(message, ex) { } + } +} diff --git a/src/StardewModdingAPI/Framework/GameVersion.cs b/src/StardewModdingAPI/Framework/GameVersion.cs new file mode 100644 index 00000000..48159f61 --- /dev/null +++ b/src/StardewModdingAPI/Framework/GameVersion.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; + +namespace StardewModdingAPI.Framework +{ + /// <summary>An implementation of <see cref="ISemanticVersion"/> that correctly handles the non-semantic versions used by older Stardew Valley releases.</summary> + internal class GameVersion : SemanticVersion + { + /********* + ** Private methods + *********/ + /// <summary>A mapping of game to semantic versions.</summary> + private static readonly IDictionary<string, string> VersionMap = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase) + { + ["1.01"] = "1.0.1", + ["1.02"] = "1.0.2", + ["1.03"] = "1.0.3", + ["1.04"] = "1.0.4", + ["1.05"] = "1.0.5", + ["1.051"] = "1.0.6-prerelease1", // not a very good mapping, but good enough for SMAPI's purposes. + ["1.051b"] = "1.0.6-prelease2", + ["1.06"] = "1.0.6", + ["1.07"] = "1.0.7", + ["1.07a"] = "1.0.8-prerelease1", + ["1.11"] = "1.1.1" + }; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="version">The game version string.</param> + public GameVersion(string version) + : base(GameVersion.GetSemanticVersionString(version)) { } + + /// <summary>Get a string representation of the version.</summary> + public override string ToString() + { + return GameVersion.GetGameVersionString(base.ToString()); + } + + + /********* + ** Private methods + *********/ + /// <summary>Convert a game version string to a semantic version string.</summary> + /// <param name="gameVersion">The game version string.</param> + private static string GetSemanticVersionString(string gameVersion) + { + return GameVersion.VersionMap.TryGetValue(gameVersion, out string semanticVersion) + ? semanticVersion + : gameVersion; + } + + /// <summary>Convert a game version string to a semantic version string.</summary> + /// <param name="gameVersion">The game version string.</param> + private static string GetGameVersionString(string gameVersion) + { + foreach (var mapping in GameVersion.VersionMap) + { + if (mapping.Value.Equals(gameVersion, StringComparison.InvariantCultureIgnoreCase)) + return mapping.Key; + } + return gameVersion; + } + } +} diff --git a/src/StardewModdingAPI/Framework/IModMetadata.cs b/src/StardewModdingAPI/Framework/IModMetadata.cs new file mode 100644 index 00000000..c21734a7 --- /dev/null +++ b/src/StardewModdingAPI/Framework/IModMetadata.cs @@ -0,0 +1,47 @@ +using StardewModdingAPI.Framework.Models; +using StardewModdingAPI.Framework.ModLoading; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Metadata for a mod.</summary> + internal interface IModMetadata + { + /********* + ** Accessors + *********/ + /// <summary>The mod's display name.</summary> + string DisplayName { get; } + + /// <summary>The mod's full directory path.</summary> + string DirectoryPath { get; } + + /// <summary>The mod manifest.</summary> + IManifest Manifest { get; } + + /// <summary>>Metadata about the mod from SMAPI's internal data (if any).</summary> + ModDataRecord DataRecord { get; } + + /// <summary>The metadata resolution status.</summary> + ModMetadataStatus Status { get; } + + /// <summary>The reason the metadata is invalid, if any.</summary> + string Error { get; } + + /// <summary>The mod instance (if it was loaded).</summary> + IMod Mod { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Set the mod status.</summary> + /// <param name="status">The metadata resolution status.</param> + /// <param name="error">The reason the metadata is invalid, if any.</param> + /// <returns>Return the instance for chaining.</returns> + IModMetadata SetStatus(ModMetadataStatus status, string error = null); + + /// <summary>Set the mod instance.</summary> + /// <param name="mod">The mod instance to set.</param> + IModMetadata SetMod(IMod mod); + } +} diff --git a/src/StardewModdingAPI/Framework/InternalExtensions.cs b/src/StardewModdingAPI/Framework/InternalExtensions.cs new file mode 100644 index 00000000..3709e05d --- /dev/null +++ b/src/StardewModdingAPI/Framework/InternalExtensions.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework.Reflection; +using StardewValley; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Provides extension methods for SMAPI's internal use.</summary> + internal static class InternalExtensions + { + /**** + ** IMonitor + ****/ + /// <summary>Safely raise an <see cref="EventHandler"/> event, and intercept any exceptions thrown by its handlers.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="name">The event name for error messages.</param> + /// <param name="handlers">The event handlers.</param> + /// <param name="sender">The event sender.</param> + /// <param name="args">The event arguments (or <c>null</c> to pass <see cref="EventArgs.Empty"/>).</param> + public static void SafelyRaisePlainEvent(this IMonitor monitor, string name, IEnumerable<Delegate> handlers, object sender = null, EventArgs args = null) + { + if (handlers == null) + return; + + foreach (EventHandler handler in handlers.Cast<EventHandler>()) + { + // handle SMAPI exiting + if (monitor.IsExiting) + { + monitor.Log($"SMAPI shutting down: aborting {name} event.", LogLevel.Warn); + return; + } + + // raise event + try + { + handler.Invoke(sender, args ?? EventArgs.Empty); + } + catch (Exception ex) + { + monitor.Log($"A mod failed handling the {name} event:\n{ex.GetLogSummary()}", LogLevel.Error); + } + } + } + + /// <summary>Safely raise an <see cref="EventHandler{TEventArgs}"/> event, and intercept any exceptions thrown by its handlers.</summary> + /// <typeparam name="TEventArgs">The event argument object type.</typeparam> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="name">The event name for error messages.</param> + /// <param name="handlers">The event handlers.</param> + /// <param name="sender">The event sender.</param> + /// <param name="args">The event arguments.</param> + public static void SafelyRaiseGenericEvent<TEventArgs>(this IMonitor monitor, string name, IEnumerable<Delegate> handlers, object sender, TEventArgs args) + { + if (handlers == null) + return; + + foreach (EventHandler<TEventArgs> handler in handlers.Cast<EventHandler<TEventArgs>>()) + { + try + { + handler.Invoke(sender, args); + } + catch (Exception ex) + { + monitor.Log($"A mod failed handling the {name} event:\n{ex.GetLogSummary()}", LogLevel.Error); + } + } + } + + /// <summary>Log a message for the player or developer the first time it occurs.</summary> + /// <param name="monitor">The monitor through which to log the message.</param> + /// <param name="hash">The hash of logged messages.</param> + /// <param name="message">The message to log.</param> + /// <param name="level">The log severity level.</param> + public static void LogOnce(this IMonitor monitor, HashSet<string> hash, string message, LogLevel level = LogLevel.Trace) + { + if (!hash.Contains(message)) + { + monitor.Log(message, level); + hash.Add(message); + } + } + + /**** + ** Exceptions + ****/ + /// <summary>Get a string representation of an exception suitable for writing to the error log.</summary> + /// <param name="exception">The error to summarise.</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(); + } + } + + /**** + ** Sprite batch + ****/ + /// <summary>Get whether the sprite batch is between a begin and end pair.</summary> + /// <param name="spriteBatch">The sprite batch to check.</param> + /// <param name="reflection">The reflection helper with which to access private fields.</param> + public static bool IsOpen(this SpriteBatch spriteBatch, Reflector reflection) + { + // get field name + const string fieldName = +#if SMAPI_FOR_WINDOWS + "inBeginEndPair"; +#else + "_beginCalled"; +#endif + + // get result + return reflection.GetPrivateField<bool>(Game1.spriteBatch, fieldName).GetValue(); + } + } +} diff --git a/src/StardewModdingAPI/Framework/Logging/ConsoleInterceptionManager.cs b/src/StardewModdingAPI/Framework/Logging/ConsoleInterceptionManager.cs new file mode 100644 index 00000000..b8f2c34e --- /dev/null +++ b/src/StardewModdingAPI/Framework/Logging/ConsoleInterceptionManager.cs @@ -0,0 +1,86 @@ +using System; + +namespace StardewModdingAPI.Framework.Logging +{ + /// <summary>Manages console output interception.</summary> + internal class ConsoleInterceptionManager : IDisposable + { + /********* + ** Properties + *********/ + /// <summary>The intercepting console writer.</summary> + private readonly InterceptingTextWriter Output; + + + /********* + ** Accessors + *********/ + /// <summary>Whether the current console supports color formatting.</summary> + public bool SupportsColor { get; } + + /// <summary>The event raised when a message is written to the console directly.</summary> + public event Action<string> OnMessageIntercepted; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public ConsoleInterceptionManager() + { + // redirect output through interceptor + this.Output = new InterceptingTextWriter(Console.Out); + this.Output.OnMessageIntercepted += line => this.OnMessageIntercepted?.Invoke(line); + Console.SetOut(this.Output); + + // test color support + this.SupportsColor = this.TestColorSupport(); + } + + /// <summary>Get an exclusive lock and write to the console output without interception.</summary> + /// <param name="action">The action to perform within the exclusive write block.</param> + public void ExclusiveWriteWithoutInterception(Action action) + { + lock (Console.Out) + { + try + { + this.Output.ShouldIntercept = false; + action(); + } + finally + { + this.Output.ShouldIntercept = true; + } + } + } + + /// <summary>Release all resources.</summary> + public void Dispose() + { + Console.SetOut(this.Output.Out); + this.Output.Dispose(); + } + + + /********* + ** private methods + *********/ + /// <summary>Test whether the current console supports color formatting.</summary> + private bool TestColorSupport() + { + try + { + this.ExclusiveWriteWithoutInterception(() => + { + Console.ForegroundColor = Console.ForegroundColor; + }); + return true; + } + catch (Exception) + { + return false; // Mono bug + } + } + } +} diff --git a/src/StardewModdingAPI/Framework/Logging/InterceptingTextWriter.cs b/src/StardewModdingAPI/Framework/Logging/InterceptingTextWriter.cs new file mode 100644 index 00000000..9ca61b59 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Logging/InterceptingTextWriter.cs @@ -0,0 +1,63 @@ +using System; +using System.IO; +using System.Text; + +namespace StardewModdingAPI.Framework.Logging +{ + /// <summary>A text writer which allows intercepting output.</summary> + internal class InterceptingTextWriter : TextWriter + { + /********* + ** Accessors + *********/ + /// <summary>The underlying console output.</summary> + public TextWriter Out { get; } + + /// <summary>The character encoding in which the output is written.</summary> + public override Encoding Encoding => this.Out.Encoding; + + /// <summary>Whether to intercept console output.</summary> + public bool ShouldIntercept { get; set; } + + /// <summary>The event raised when a message is written to the console directly.</summary> + public event Action<string> OnMessageIntercepted; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="output">The underlying output writer.</param> + public InterceptingTextWriter(TextWriter output) + { + this.Out = output; + } + + /// <summary>Writes a subarray of characters to the text string or stream.</summary> + /// <param name="buffer">The character array to write data from.</param> + /// <param name="index">The character position in the buffer at which to start retrieving data.</param> + /// <param name="count">The number of characters to write.</param> + public override void Write(char[] buffer, int index, int count) + { + if (this.ShouldIntercept) + this.OnMessageIntercepted?.Invoke(new string(buffer, index, count).TrimEnd('\r', '\n')); + else + this.Out.Write(buffer, index, count); + } + + /// <summary>Writes a character to the text string or stream.</summary> + /// <param name="ch">The character to write to the text stream.</param> + /// <remarks>Console log messages from the game should be caught by <see cref="Write(char[],int,int)"/>. This method passes through anything that bypasses that method for some reason, since it's better to show it to users than hide it from everyone.</remarks> + public override void Write(char ch) + { + this.Out.Write(ch); + } + + /// <summary>Releases the unmanaged resources used by the <see cref="T:System.IO.TextWriter" /> and optionally releases the managed resources.</summary> + /// <param name="disposing">true to release both managed and unmanaged resources; false to release only unmanaged resources.</param> + protected override void Dispose(bool disposing) + { + this.OnMessageIntercepted = null; + } + } +} diff --git a/src/StardewModdingAPI/Framework/Logging/LogFileManager.cs b/src/StardewModdingAPI/Framework/Logging/LogFileManager.cs new file mode 100644 index 00000000..8cfe0527 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Logging/LogFileManager.cs @@ -0,0 +1,57 @@ +using System; +using System.IO; + +namespace StardewModdingAPI.Framework.Logging +{ + /// <summary>Manages reading and writing to log file.</summary> + internal class LogFileManager : IDisposable + { + /********* + ** Properties + *********/ + /// <summary>The underlying stream writer.</summary> + private readonly StreamWriter Stream; + + + /********* + ** Accessors + *********/ + /// <summary>The full path to the log file being written.</summary> + public string Path { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="path">The log file to write.</param> + public LogFileManager(string path) + { + this.Path = path; + + // create log directory if needed + string logDir = System.IO.Path.GetDirectoryName(path); + if (logDir == null) + throw new ArgumentException($"The log path '{path}' is not valid."); + Directory.CreateDirectory(logDir); + + // open log file stream + this.Stream = new StreamWriter(path, append: false) { AutoFlush = true }; + } + + /// <summary>Write a message to the log.</summary> + /// <param name="message">The message to log.</param> + public void WriteLine(string message) + { + // always use Windows-style line endings for convenience + // (Linux/Mac editors are fine with them, Windows editors often require them) + this.Stream.Write(message + "\r\n"); + } + + /// <summary>Release all resources.</summary> + public void Dispose() + { + this.Stream.Dispose(); + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModHelpers/BaseHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/BaseHelper.cs new file mode 100644 index 00000000..16032da1 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModHelpers/BaseHelper.cs @@ -0,0 +1,23 @@ +namespace StardewModdingAPI.Framework.ModHelpers +{ + /// <summary>The common base class for mod helpers.</summary> + internal abstract class BaseHelper : IModLinked + { + /********* + ** Accessors + *********/ + /// <summary>The unique ID of the mod for which the helper was created.</summary> + public string ModID { get; } + + + /********* + ** Protected methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="modID">The unique ID of the relevant mod.</param> + protected BaseHelper(string modID) + { + this.ModID = modID; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModHelpers/CommandHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/CommandHelper.cs new file mode 100644 index 00000000..bdedb07c --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModHelpers/CommandHelper.cs @@ -0,0 +1,54 @@ +using System; + +namespace StardewModdingAPI.Framework.ModHelpers +{ + /// <summary>Provides an API for managing console commands.</summary> + internal class CommandHelper : BaseHelper, ICommandHelper + { + /********* + ** Accessors + *********/ + /// <summary>The friendly mod name for this instance.</summary> + private readonly string ModName; + + /// <summary>Manages console commands.</summary> + private readonly CommandManager CommandManager; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="modID">The unique ID of the relevant mod.</param> + /// <param name="modName">The friendly mod name for this instance.</param> + /// <param name="commandManager">Manages console commands.</param> + public CommandHelper(string modID, string modName, CommandManager commandManager) + : base(modID) + { + this.ModName = modName; + this.CommandManager = commandManager; + } + + /// <summary>Add a console command.</summary> + /// <param name="name">The command name, which the user must type to trigger it.</param> + /// <param name="documentation">The human-readable documentation shown when the player runs the built-in 'help' command.</param> + /// <param name="callback">The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user.</param> + /// <exception cref="ArgumentNullException">The <paramref name="name"/> or <paramref name="callback"/> is null or empty.</exception> + /// <exception cref="FormatException">The <paramref name="name"/> is not a valid format.</exception> + /// <exception cref="ArgumentException">There's already a command with that name.</exception> + public ICommandHelper Add(string name, string documentation, Action<string, string[]> callback) + { + this.CommandManager.Add(this.ModName, name, documentation, callback); + return this; + } + + /// <summary>Trigger a command.</summary> + /// <param name="name">The command name.</param> + /// <param name="arguments">The command arguments.</param> + /// <returns>Returns whether a matching command was triggered.</returns> + public bool Trigger(string name, string[] arguments) + { + return this.CommandManager.Trigger(name, arguments); + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs new file mode 100644 index 00000000..4440ae40 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs @@ -0,0 +1,476 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework.Exceptions; +using StardewValley; +using xTile; +using xTile.Format; +using xTile.Tiles; + +namespace StardewModdingAPI.Framework.ModHelpers +{ + /// <summary>Provides an API for loading content assets.</summary> + internal class ContentHelper : BaseHelper, IContentHelper + { + /********* + ** Properties + *********/ + /// <summary>SMAPI's underlying content manager.</summary> + private readonly SContentManager ContentManager; + + /// <summary>The absolute path to the mod folder.</summary> + private readonly string ModFolderPath; + + /// <summary>The path to the mod's folder, relative to the game's content folder (e.g. "../Mods/ModName").</summary> + private readonly string ModFolderPathFromContent; + + /// <summary>The friendly mod name for use in errors.</summary> + private readonly string ModName; + + /// <summary>Encapsulates monitoring and logging for a given module.</summary> + private readonly IMonitor Monitor; + + + /********* + ** Accessors + *********/ + /// <summary>The game's current locale code (like <c>pt-BR</c>).</summary> + public string CurrentLocale => this.ContentManager.GetLocale(); + + /// <summary>The game's current locale as an enum value.</summary> + public LocalizedContentManager.LanguageCode CurrentLocaleConstant => this.ContentManager.GetCurrentLanguage(); + + /// <summary>The observable implementation of <see cref="AssetEditors"/>.</summary> + internal ObservableCollection<IAssetEditor> ObservableAssetEditors { get; } = new ObservableCollection<IAssetEditor>(); + + /// <summary>The observable implementation of <see cref="AssetLoaders"/>.</summary> + internal ObservableCollection<IAssetLoader> ObservableAssetLoaders { get; } = new ObservableCollection<IAssetLoader>(); + + /// <summary>Interceptors which provide the initial versions of matching content assets.</summary> + public IList<IAssetLoader> AssetLoaders => this.ObservableAssetLoaders; + + /// <summary>Interceptors which edit matching content assets after they're loaded.</summary> + public IList<IAssetEditor> AssetEditors => this.ObservableAssetEditors; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="contentManager">SMAPI's underlying content manager.</param> + /// <param name="modFolderPath">The absolute path to the mod folder.</param> + /// <param name="modID">The unique ID of the relevant mod.</param> + /// <param name="modName">The friendly mod name for use in errors.</param> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + public ContentHelper(SContentManager contentManager, string modFolderPath, string modID, string modName, IMonitor monitor) + : base(modID) + { + this.ContentManager = contentManager; + this.ModFolderPath = modFolderPath; + this.ModName = modName; + this.ModFolderPathFromContent = this.GetRelativePath(contentManager.FullRootDirectory, modFolderPath); + this.Monitor = monitor; + } + + /// <summary>Load content from the game folder or mod folder (if not already cached), and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary> + /// <typeparam name="T">The expected data type. The main supported types are <see cref="Texture2D"/> and dictionaries; other types may be supported by the game's content pipeline.</typeparam> + /// <param name="key">The asset key to fetch (if the <paramref name="source"/> is <see cref="ContentSource.GameContent"/>), or the local path to a content file relative to the mod folder.</param> + /// <param name="source">Where to search for a matching content asset.</param> + /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> + /// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception> + public T Load<T>(string key, ContentSource source = ContentSource.ModFolder) + { + SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}: {reasonPhrase}."); + + this.AssertValidAssetKeyFormat(key); + try + { + switch (source) + { + case ContentSource.GameContent: + return this.ContentManager.Load<T>(key); + + case ContentSource.ModFolder: + // get file + FileInfo file = this.GetModFile(key); + if (!file.Exists) + throw GetContentError($"there's no matching file at path '{file.FullName}'."); + + // get asset path + string assetPath = this.GetModAssetPath(key, file.FullName); + + // try cache + if (this.ContentManager.IsLoaded(assetPath)) + return this.ContentManager.Load<T>(assetPath); + + // load content + switch (file.Extension.ToLower()) + { + // XNB file + case ".xnb": + { + T asset = this.ContentManager.Load<T>(assetPath); + if (asset is Map) + this.FixLocalMapTilesheets(asset as Map, key); + return asset; + } + + // unpacked map + case ".tbin": + { + // validate + if (typeof(T) != typeof(Map)) + throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'."); + + // fetch & cache + FormatManager formatManager = FormatManager.Instance; + Map map = formatManager.LoadMap(file.FullName); + this.FixLocalMapTilesheets(map, key); + + // inject map + this.ContentManager.Inject(assetPath, map); + return (T)(object)map; + } + + // unpacked image + case ".png": + // validate + if (typeof(T) != typeof(Texture2D)) + throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'."); + + // fetch & cache + using (FileStream stream = File.OpenRead(file.FullName)) + { + Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream); + texture = this.PremultiplyTransparency(texture); + this.ContentManager.Inject(assetPath, texture); + return (T)(object)texture; + } + + default: + throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.png', '.tbin', or '.xnb'."); + } + + default: + throw GetContentError($"unknown content source '{source}'."); + } + } + catch (Exception ex) when (!(ex is SContentLoadException)) + { + throw new SContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}.", ex); + } + } + + /// <summary>Get the underlying key in the game's content cache for an asset. This can be used to load custom map tilesheets, but should be avoided when you can use the content API instead. This does not validate whether the asset exists.</summary> + /// <param name="key">The asset key to fetch (if the <paramref name="source"/> is <see cref="ContentSource.GameContent"/>), or the local path to a content file relative to the mod folder.</param> + /// <param name="source">Where to search for a matching content asset.</param> + /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> + public string GetActualAssetKey(string key, ContentSource source = ContentSource.ModFolder) + { + switch (source) + { + case ContentSource.GameContent: + return this.ContentManager.NormaliseAssetName(key); + + case ContentSource.ModFolder: + FileInfo file = this.GetModFile(key); + return this.ContentManager.NormaliseAssetName(this.GetModAssetPath(key, file.FullName)); + + default: + throw new NotSupportedException($"Unknown content source '{source}'."); + } + } + + /// <summary>Remove an asset from the content cache so it's reloaded on the next request. This will reload core game assets if needed, but references to the former asset will still show the previous content.</summary> + /// <param name="key">The asset key to invalidate in the content folder.</param> + /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> + /// <returns>Returns whether the given asset key was cached.</returns> + public bool InvalidateCache(string key) + { + this.Monitor.Log($"Requested cache invalidation for '{key}'.", LogLevel.Trace); + string actualKey = this.GetActualAssetKey(key, ContentSource.GameContent); + return this.ContentManager.InvalidateCache((otherKey, type) => otherKey.Equals(actualKey, StringComparison.InvariantCultureIgnoreCase)); + } + + /// <summary>Remove all assets of the given type from the cache so they're reloaded on the next request. <b>This can be a very expensive operation and should only be used in very specific cases.</b> This will reload core game assets if needed, but references to the former assets will still show the previous content.</summary> + /// <typeparam name="T">The asset type to remove from the cache.</typeparam> + /// <returns>Returns whether any assets were invalidated.</returns> + public bool InvalidateCache<T>() + { + this.Monitor.Log($"Requested cache invalidation for all assets of type {typeof(T)}. This is an expensive operation and should be avoided if possible.", LogLevel.Trace); + return this.ContentManager.InvalidateCache((key, type) => typeof(T).IsAssignableFrom(type)); + } + + /********* + ** Private methods + *********/ + /// <summary>Fix the tilesheets for a map loaded from the mod folder.</summary> + /// <param name="map">The map whose tilesheets to fix.</param> + /// <param name="mapKey">The map asset key within the mod folder.</param> + /// <exception cref="ContentLoadException">The map tilesheets could not be loaded.</exception> + /// <remarks> + /// The game's logic for tilesheets in <see cref="Game1.setGraphicsForSeason"/> is a bit specialised. It boils down to this: + /// * If the location is indoors or the desert, or the image source contains 'path' or 'object', it's loaded as-is relative to the <c>Content</c> folder. + /// * Else it's loaded from <c>Content\Maps</c> with a seasonal prefix. + /// + /// That logic doesn't work well in our case, mainly because we have no location metadata at this point. + /// Instead we use a more heuristic approach: check relative to the map file first, then relative to + /// <c>Content\Maps</c>, then <c>Content</c>. If the image source filename contains a seasonal prefix, we try + /// for a seasonal variation and then an exact match. + /// + /// While that doesn't exactly match the game logic, it's close enough that it's unlikely to make a difference. + /// </remarks> + private void FixLocalMapTilesheets(Map map, string mapKey) + { + // check map info + if (!map.TileSheets.Any()) + return; + mapKey = this.ContentManager.NormaliseAssetName(mapKey); // Mono's Path.GetDirectoryName doesn't handle Windows dir separators + string relativeMapFolder = Path.GetDirectoryName(mapKey) ?? ""; // folder path containing the map, relative to the mod folder + + // fix tilesheets + foreach (TileSheet tilesheet in map.TileSheets) + { + string imageSource = tilesheet.ImageSource; + + // get seasonal name (if applicable) + string seasonalImageSource = null; + if (Game1.currentSeason != null) + { + string filename = Path.GetFileName(imageSource); + bool hasSeasonalPrefix = + filename.StartsWith("spring_", StringComparison.CurrentCultureIgnoreCase) + || filename.StartsWith("summer_", StringComparison.CurrentCultureIgnoreCase) + || filename.StartsWith("fall_", StringComparison.CurrentCultureIgnoreCase) + || filename.StartsWith("winter_", StringComparison.CurrentCultureIgnoreCase); + if (hasSeasonalPrefix && !filename.StartsWith(Game1.currentSeason + "_")) + { + string dirPath = imageSource.Substring(0, imageSource.LastIndexOf(filename, StringComparison.CurrentCultureIgnoreCase)); + seasonalImageSource = $"{dirPath}{Game1.currentSeason}_{filename.Substring(filename.IndexOf("_", StringComparison.CurrentCultureIgnoreCase) + 1)}"; + } + } + + // load best match + try + { + string key = + this.TryLoadTilesheetImageSource(relativeMapFolder, seasonalImageSource) + ?? this.TryLoadTilesheetImageSource(relativeMapFolder, imageSource); + if (key != null) + { + tilesheet.ImageSource = key; + continue; + } + } + catch (Exception ex) + { + throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder.", ex); + } + + // none found + throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder."); + } + } + + /// <summary>Load a tilesheet image source if the file exists.</summary> + /// <param name="relativeMapFolder">The folder path containing the map, relative to the mod folder.</param> + /// <param name="imageSource">The tilesheet image source to load.</param> + /// <returns>Returns the loaded asset key (if it was loaded successfully).</returns> + /// <remarks>See remarks on <see cref="FixLocalMapTilesheets"/>.</remarks> + private string TryLoadTilesheetImageSource(string relativeMapFolder, string imageSource) + { + if (imageSource == null) + return null; + + // check relative to map file + { + string localKey = Path.Combine(relativeMapFolder, imageSource); + FileInfo localFile = this.GetModFile(localKey); + if (localFile.Exists) + { + try + { + this.Load<Texture2D>(localKey); + } + catch (Exception ex) + { + throw new ContentLoadException($"The local '{imageSource}' tilesheet couldn't be loaded.", ex); + } + + return this.GetActualAssetKey(localKey); + } + } + + // check relative to content folder + { + foreach (string candidateKey in new[] { imageSource, $@"Maps\{imageSource}" }) + { + string contentKey = candidateKey.EndsWith(".png") + ? candidateKey.Substring(0, imageSource.Length - 4) + : candidateKey; + + try + { + this.Load<Texture2D>(contentKey, ContentSource.GameContent); + return contentKey; + } + catch + { + // ignore file-not-found errors + // TODO: while it's useful to suppress a asset-not-found error here to avoid + // confusion, this is a pretty naive approach. Even if the file doesn't exist, + // the file may have been loaded through an IAssetLoader which failed. So even + // if the content file doesn't exist, that doesn't mean the error here is a + // content-not-found error. Unfortunately XNA doesn't provide a good way to + // detect the error type. + if (this.GetContentFolderFile(contentKey).Exists) + throw; + } + } + } + + // not found + return null; + } + + /// <summary>Assert that the given key has a valid format.</summary> + /// <param name="key">The asset key to check.</param> + /// <exception cref="ArgumentException">The asset key is empty or contains invalid characters.</exception> + [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "Parameter is only used for assertion checks by design.")] + private void AssertValidAssetKeyFormat(string key) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("The asset key or local path is empty."); + if (key.Intersect(Path.GetInvalidPathChars()).Any()) + throw new ArgumentException("The asset key or local path contains invalid characters."); + } + + /// <summary>Get a file from the mod folder.</summary> + /// <param name="path">The asset path relative to the mod folder.</param> + private FileInfo GetModFile(string path) + { + // try exact match + path = Path.Combine(this.ModFolderPath, this.ContentManager.NormalisePathSeparators(path)); + FileInfo file = new FileInfo(path); + + // try with default extension + if (!file.Exists && file.Extension.ToLower() != ".xnb") + { + FileInfo result = new FileInfo(path + ".xnb"); + if (result.Exists) + file = result; + } + + return file; + } + + /// <summary>Get a file from the game's content folder.</summary> + /// <param name="key">The asset key.</param> + private FileInfo GetContentFolderFile(string key) + { + // get file path + string path = Path.Combine(this.ContentManager.FullRootDirectory, key); + if (!path.EndsWith(".xnb")) + path += ".xnb"; + + // get file + return new FileInfo(path); + } + + /// <summary>Get the asset path which loads a mod folder through a content manager.</summary> + /// <param name="localPath">The file path relative to the mod's folder.</param> + /// <param name="absolutePath">The absolute file path.</param> + private string GetModAssetPath(string localPath, string absolutePath) + { +#if SMAPI_FOR_WINDOWS + // XNA doesn't allow absolute asset paths, so get a path relative to the content folder + return Path.Combine(this.ModFolderPathFromContent, localPath); +#else + // MonoGame is weird about relative paths on Mac, but allows absolute paths + return absolutePath; +#endif + } + + /// <summary>Get a directory path relative to a given root.</summary> + /// <param name="rootPath">The root path from which the path should be relative.</param> + /// <param name="targetPath">The target file path.</param> + private string GetRelativePath(string rootPath, string targetPath) + { + // convert to URIs + Uri from = new Uri(rootPath + "/"); + Uri to = new Uri(targetPath + "/"); + if (from.Scheme != to.Scheme) + throw new InvalidOperationException($"Can't get path for '{targetPath}' relative to '{rootPath}'."); + + // get relative path + return Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString()) + .Replace(Path.DirectorySeparatorChar == '/' ? '\\' : '/', Path.DirectorySeparatorChar); // use correct separator for platform + } + + /// <summary>Premultiply a texture's alpha values to avoid transparency issues in the game. This is only possible if the game isn't currently drawing.</summary> + /// <param name="texture">The texture to premultiply.</param> + /// <returns>Returns a premultiplied texture.</returns> + /// <remarks>Based on <a href="https://gist.github.com/Layoric/6255384">code by Layoric</a>.</remarks> + private Texture2D PremultiplyTransparency(Texture2D texture) + { + // validate + if (Context.IsInDrawLoop) + throw new NotSupportedException("Can't load a PNG file while the game is drawing to the screen. Make sure you load content outside the draw loop."); + + // process texture + SpriteBatch spriteBatch = Game1.spriteBatch; + GraphicsDevice gpu = Game1.graphics.GraphicsDevice; + using (RenderTarget2D renderTarget = new RenderTarget2D(Game1.graphics.GraphicsDevice, texture.Width, texture.Height)) + { + // create blank render target to premultiply + gpu.SetRenderTarget(renderTarget); + gpu.Clear(Color.Black); + + // multiply each color by the source alpha, and write just the color values into the final texture + spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState + { + ColorDestinationBlend = Blend.Zero, + ColorWriteChannels = ColorWriteChannels.Red | ColorWriteChannels.Green | ColorWriteChannels.Blue, + AlphaDestinationBlend = Blend.Zero, + AlphaSourceBlend = Blend.SourceAlpha, + ColorSourceBlend = Blend.SourceAlpha + }); + spriteBatch.Draw(texture, texture.Bounds, Color.White); + spriteBatch.End(); + + // copy the alpha values from the source texture into the final one without multiplying them + spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState + { + ColorWriteChannels = ColorWriteChannels.Alpha, + AlphaDestinationBlend = Blend.Zero, + ColorDestinationBlend = Blend.Zero, + AlphaSourceBlend = Blend.One, + ColorSourceBlend = Blend.One + }); + spriteBatch.Draw(texture, texture.Bounds, Color.White); + spriteBatch.End(); + + // release GPU + gpu.SetRenderTarget(null); + + // extract premultiplied data + Color[] data = new Color[texture.Width * texture.Height]; + renderTarget.GetData(data); + + // unset texture from GPU to regain control + gpu.Textures[0] = null; + + // update texture with premultiplied data + texture.SetData(data); + } + + return texture; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModHelpers/ModHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/ModHelper.cs new file mode 100644 index 00000000..665b9cf4 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModHelpers/ModHelper.cs @@ -0,0 +1,129 @@ +using System; +using System.IO; +using StardewModdingAPI.Framework.Serialisation; + +namespace StardewModdingAPI.Framework.ModHelpers +{ + /// <summary>Provides simplified APIs for writing mods.</summary> + internal class ModHelper : BaseHelper, IModHelper, IDisposable + { + /********* + ** Properties + *********/ + /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> + private readonly JsonHelper JsonHelper; + + + /********* + ** Accessors + *********/ + /// <summary>The full path to the mod's folder.</summary> + public string DirectoryPath { get; } + + /// <summary>An API for loading content assets.</summary> + public IContentHelper Content { get; } + + /// <summary>An API for accessing private game code.</summary> + public IReflectionHelper Reflection { get; } + + /// <summary>an API for fetching metadata about loaded mods.</summary> + public IModRegistry ModRegistry { get; } + + /// <summary>An API for managing console commands.</summary> + public ICommandHelper ConsoleCommands { get; } + + /// <summary>An API for reading translations stored in the mod's <c>i18n</c> folder, with one file per locale (like <c>en.json</c>) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like <c>pt-BR.json</c> < <c>pt.json</c> < <c>default.json</c>).</summary> + public ITranslationHelper Translation { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="modID">The mod's unique ID.</param> + /// <param name="modDirectory">The full path to the mod's folder.</param> + /// <param name="jsonHelper">Encapsulate SMAPI's JSON parsing.</param> + /// <param name="contentHelper">An API for loading content assets.</param> + /// <param name="commandHelper">An API for managing console commands.</param> + /// <param name="modRegistry">an API for fetching metadata about loaded mods.</param> + /// <param name="reflectionHelper">An API for accessing private game code.</param> + /// <param name="translationHelper">An API for reading translations stored in the mod's <c>i18n</c> folder.</param> + /// <exception cref="ArgumentNullException">An argument is null or empty.</exception> + /// <exception cref="InvalidOperationException">The <paramref name="modDirectory"/> path does not exist on disk.</exception> + public ModHelper(string modID, string modDirectory, JsonHelper jsonHelper, IContentHelper contentHelper, ICommandHelper commandHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, ITranslationHelper translationHelper) + : base(modID) + { + // validate directory + if (string.IsNullOrWhiteSpace(modDirectory)) + throw new ArgumentNullException(nameof(modDirectory)); + if (!Directory.Exists(modDirectory)) + throw new InvalidOperationException("The specified mod directory does not exist."); + + // initialise + this.DirectoryPath = modDirectory; + this.JsonHelper = jsonHelper ?? throw new ArgumentNullException(nameof(jsonHelper)); + this.Content = contentHelper ?? throw new ArgumentNullException(nameof(contentHelper)); + this.ModRegistry = modRegistry ?? throw new ArgumentNullException(nameof(modRegistry)); + this.ConsoleCommands = commandHelper ?? throw new ArgumentNullException(nameof(commandHelper)); + this.Reflection = reflectionHelper ?? throw new ArgumentNullException(nameof(reflectionHelper)); + this.Translation = translationHelper ?? throw new ArgumentNullException(nameof(translationHelper)); + } + + /**** + ** Mod config file + ****/ + /// <summary>Read the mod's configuration file (and create it if needed).</summary> + /// <typeparam name="TConfig">The config class type. This should be a plain class that has public properties for the settings you want. These can be complex types.</typeparam> + public TConfig ReadConfig<TConfig>() + where TConfig : class, new() + { + TConfig config = this.ReadJsonFile<TConfig>("config.json") ?? new TConfig(); + this.WriteConfig(config); // create file or fill in missing fields + return config; + } + + /// <summary>Save to the mod's configuration file.</summary> + /// <typeparam name="TConfig">The config class type.</typeparam> + /// <param name="config">The config settings to save.</param> + public void WriteConfig<TConfig>(TConfig config) + where TConfig : class, new() + { + this.WriteJsonFile("config.json", config); + } + + /**** + ** Generic JSON files + ****/ + /// <summary>Read a JSON file.</summary> + /// <typeparam name="TModel">The model type.</typeparam> + /// <param name="path">The file path relative to the mod directory.</param> + /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + public TModel ReadJsonFile<TModel>(string path) + where TModel : class + { + path = Path.Combine(this.DirectoryPath, path); + return this.JsonHelper.ReadJsonFile<TModel>(path); + } + + /// <summary>Save to a JSON file.</summary> + /// <typeparam name="TModel">The model type.</typeparam> + /// <param name="path">The file path relative to the mod directory.</param> + /// <param name="model">The model to save.</param> + public void WriteJsonFile<TModel>(string path, TModel model) + where TModel : class + { + path = Path.Combine(this.DirectoryPath, path); + this.JsonHelper.WriteJsonFile(path, model); + } + + + /**** + ** Disposal + ****/ + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + public void Dispose() + { + // nothing to dispose yet + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModHelpers/ModRegistryHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/ModRegistryHelper.cs new file mode 100644 index 00000000..9e824694 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModHelpers/ModRegistryHelper.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; + +namespace StardewModdingAPI.Framework.ModHelpers +{ + /// <summary>Provides metadata about installed mods.</summary> + internal class ModRegistryHelper : BaseHelper, IModRegistry + { + /********* + ** Properties + *********/ + /// <summary>The underlying mod registry.</summary> + private readonly ModRegistry Registry; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="modID">The unique ID of the relevant mod.</param> + /// <param name="registry">The underlying mod registry.</param> + public ModRegistryHelper(string modID, ModRegistry registry) + : base(modID) + { + this.Registry = registry; + } + + /// <summary>Get metadata for all loaded mods.</summary> + public IEnumerable<IManifest> GetAll() + { + return this.Registry.GetAll(); + } + + /// <summary>Get metadata for a loaded mod.</summary> + /// <param name="uniqueID">The mod's unique ID.</param> + /// <returns>Returns the matching mod's metadata, or <c>null</c> if not found.</returns> + public IManifest Get(string uniqueID) + { + return this.Registry.Get(uniqueID); + } + + /// <summary>Get whether a mod has been loaded.</summary> + /// <param name="uniqueID">The mod's unique ID.</param> + public bool IsLoaded(string uniqueID) + { + return this.Registry.IsLoaded(uniqueID); + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModHelpers/ReflectionHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/ReflectionHelper.cs new file mode 100644 index 00000000..8d435416 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModHelpers/ReflectionHelper.cs @@ -0,0 +1,200 @@ +using System; +using StardewModdingAPI.Framework.Reflection; + +namespace StardewModdingAPI.Framework.ModHelpers +{ + /// <summary>Provides helper methods for accessing private game code.</summary> + /// <remarks>This implementation searches up the type hierarchy, and caches the reflected fields and methods with a sliding expiry (to optimise performance without unnecessary memory usage).</remarks> + internal class ReflectionHelper : BaseHelper, IReflectionHelper + { + /********* + ** Properties + *********/ + /// <summary>The underlying reflection helper.</summary> + private readonly Reflector Reflector; + + /// <summary>The mod name for error messages.</summary> + private readonly string ModName; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="modID">The unique ID of the relevant mod.</param> + /// <param name="modName">The mod name for error messages.</param> + /// <param name="reflector">The underlying reflection helper.</param> + public ReflectionHelper(string modID, string modName, Reflector reflector) + : base(modID) + { + this.ModName = modName; + this.Reflector = reflector; + } + + /**** + ** Fields + ****/ + /// <summary>Get a private instance field.</summary> + /// <typeparam name="TValue">The field type.</typeparam> + /// <param name="obj">The object which has the field.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + /// <returns>Returns the field wrapper, or <c>null</c> if the field doesn't exist and <paramref name="required"/> is <c>false</c>.</returns> + public IPrivateField<TValue> GetPrivateField<TValue>(object obj, string name, bool required = true) + { + this.AssertAccessAllowed(obj); + return this.Reflector.GetPrivateField<TValue>(obj, name, required); + } + + /// <summary>Get a private static field.</summary> + /// <typeparam name="TValue">The field type.</typeparam> + /// <param name="type">The type which has the field.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + public IPrivateField<TValue> GetPrivateField<TValue>(Type type, string name, bool required = true) + { + this.AssertAccessAllowed(type); + return this.Reflector.GetPrivateField<TValue>(type, name, required); + } + + /**** + ** Properties + ****/ + /// <summary>Get a private instance property.</summary> + /// <typeparam name="TValue">The property type.</typeparam> + /// <param name="obj">The object which has the property.</param> + /// <param name="name">The property name.</param> + /// <param name="required">Whether to throw an exception if the private property is not found.</param> + public IPrivateProperty<TValue> GetPrivateProperty<TValue>(object obj, string name, bool required = true) + { + this.AssertAccessAllowed(obj); + return this.Reflector.GetPrivateProperty<TValue>(obj, name, required); + } + + /// <summary>Get a private static property.</summary> + /// <typeparam name="TValue">The property type.</typeparam> + /// <param name="type">The type which has the property.</param> + /// <param name="name">The property name.</param> + /// <param name="required">Whether to throw an exception if the private property is not found.</param> + public IPrivateProperty<TValue> GetPrivateProperty<TValue>(Type type, string name, bool required = true) + { + this.AssertAccessAllowed(type); + return this.Reflector.GetPrivateProperty<TValue>(type, name, required); + } + + /**** + ** Field values + ** (shorthand since this is the most common case) + ****/ + /// <summary>Get the value of a private instance field.</summary> + /// <typeparam name="TValue">The field type.</typeparam> + /// <param name="obj">The object which has the field.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + /// <returns>Returns the field value, or the default value for <typeparamref name="TValue"/> if the field wasn't found and <paramref name="required"/> is false.</returns> + /// <remarks> + /// This is a shortcut for <see cref="GetPrivateField{TValue}(object,string,bool)"/> followed by <see cref="IPrivateField{TValue}.GetValue"/>. + /// When <paramref name="required" /> is false, this will return the default value if reflection fails. If you need to check whether the field exists, use <see cref="GetPrivateField{TValue}(object,string,bool)" /> instead. + /// </remarks> + public TValue GetPrivateValue<TValue>(object obj, string name, bool required = true) + { + this.AssertAccessAllowed(obj); + IPrivateField<TValue> field = this.GetPrivateField<TValue>(obj, name, required); + return field != null + ? field.GetValue() + : default(TValue); + } + + /// <summary>Get the value of a private static field.</summary> + /// <typeparam name="TValue">The field type.</typeparam> + /// <param name="type">The type which has the field.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + /// <returns>Returns the field value, or the default value for <typeparamref name="TValue"/> if the field wasn't found and <paramref name="required"/> is false.</returns> + /// <remarks> + /// This is a shortcut for <see cref="GetPrivateField{TValue}(Type,string,bool)"/> followed by <see cref="IPrivateField{TValue}.GetValue"/>. + /// When <paramref name="required" /> is false, this will return the default value if reflection fails. If you need to check whether the field exists, use <see cref="GetPrivateField{TValue}(Type,string,bool)" /> instead. + /// </remarks> + public TValue GetPrivateValue<TValue>(Type type, string name, bool required = true) + { + this.AssertAccessAllowed(type); + IPrivateField<TValue> field = this.GetPrivateField<TValue>(type, name, required); + return field != null + ? field.GetValue() + : default(TValue); + } + + /**** + ** Methods + ****/ + /// <summary>Get a private instance method.</summary> + /// <param name="obj">The object which has the method.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + public IPrivateMethod GetPrivateMethod(object obj, string name, bool required = true) + { + this.AssertAccessAllowed(obj); + return this.Reflector.GetPrivateMethod(obj, name, required); + } + + /// <summary>Get a private static method.</summary> + /// <param name="type">The type which has the method.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + public IPrivateMethod GetPrivateMethod(Type type, string name, bool required = true) + { + this.AssertAccessAllowed(type); + return this.Reflector.GetPrivateMethod(type, name, required); + } + + /**** + ** Methods by signature + ****/ + /// <summary>Get a private instance method.</summary> + /// <param name="obj">The object which has the method.</param> + /// <param name="name">The field name.</param> + /// <param name="argumentTypes">The argument types of the method signature to find.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + public IPrivateMethod GetPrivateMethod(object obj, string name, Type[] argumentTypes, bool required = true) + { + this.AssertAccessAllowed(obj); + return this.Reflector.GetPrivateMethod(obj, name, argumentTypes, required); + } + + /// <summary>Get a private static method.</summary> + /// <param name="type">The type which has the method.</param> + /// <param name="name">The field name.</param> + /// <param name="argumentTypes">The argument types of the method signature to find.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + public IPrivateMethod GetPrivateMethod(Type type, string name, Type[] argumentTypes, bool required = true) + { + this.AssertAccessAllowed(type); + return this.Reflector.GetPrivateMethod(type, name, argumentTypes, required); + } + + + /********* + ** Private methods + *********/ + /// <summary>Assert that mods can use the reflection helper to access the given type.</summary> + /// <param name="type">The type being accessed.</param> + private void AssertAccessAllowed(Type type) + { + // validate type namespace + if (type.Namespace != null) + { + string rootSmapiNamespace = typeof(Program).Namespace; + if (type.Namespace == rootSmapiNamespace || type.Namespace.StartsWith(rootSmapiNamespace + ".")) + throw new InvalidOperationException($"SMAPI blocked access by {this.ModName} to its internals through the reflection API. Accessing the SMAPI internals is strongly discouraged since they're subject to change, which means the mod can break without warning."); + } + } + + /// <summary>Assert that mods can use the reflection helper to access the given type.</summary> + /// <param name="obj">The object being accessed.</param> + private void AssertAccessAllowed(object obj) + { + if (obj != null) + this.AssertAccessAllowed(obj.GetType()); + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModHelpers/TranslationHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/TranslationHelper.cs new file mode 100644 index 00000000..bbe3a81a --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModHelpers/TranslationHelper.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewValley; + +namespace StardewModdingAPI.Framework.ModHelpers +{ + /// <summary>Provides translations stored in the mod's <c>i18n</c> folder, with one file per locale (like <c>en.json</c>) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like <c>pt-BR.json</c> < <c>pt.json</c> < <c>default.json</c>).</summary> + internal class TranslationHelper : BaseHelper, ITranslationHelper + { + /********* + ** Properties + *********/ + /// <summary>The name of the relevant mod for error messages.</summary> + private readonly string ModName; + + /// <summary>The translations for each locale.</summary> + private readonly IDictionary<string, IDictionary<string, string>> All = new Dictionary<string, IDictionary<string, string>>(StringComparer.InvariantCultureIgnoreCase); + + /// <summary>The translations for the current locale, with locale fallback taken into account.</summary> + private IDictionary<string, Translation> ForLocale; + + + /********* + ** Accessors + *********/ + /// <summary>The current locale.</summary> + public string Locale { get; private set; } + + /// <summary>The game's current language code.</summary> + public LocalizedContentManager.LanguageCode LocaleEnum { get; private set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="modID">The unique ID of the relevant mod.</param> + /// <param name="modName">The name of the relevant mod for error messages.</param> + /// <param name="locale">The initial locale.</param> + /// <param name="languageCode">The game's current language code.</param> + public TranslationHelper(string modID, string modName, string locale, LocalizedContentManager.LanguageCode languageCode) + : base(modID) + { + // save data + this.ModName = modName; + + // set locale + this.SetLocale(locale, languageCode); + } + + /// <summary>Get all translations for the current locale.</summary> + public IEnumerable<Translation> GetTranslations() + { + return this.ForLocale.Values.ToArray(); + } + + /// <summary>Get a translation for the current locale.</summary> + /// <param name="key">The translation key.</param> + public Translation Get(string key) + { + this.ForLocale.TryGetValue(key, out Translation translation); + return translation ?? new Translation(this.ModName, this.Locale, key, null); + } + + /// <summary>Get a translation for the current locale.</summary> + /// <param name="key">The translation key.</param> + /// <param name="tokens">An object containing token key/value pairs. This can be an anonymous object (like <c>new { value = 42, name = "Cranberries" }</c>), a dictionary, or a class instance.</param> + public Translation Get(string key, object tokens) + { + return this.Get(key).Tokens(tokens); + } + + /// <summary>Set the translations to use.</summary> + /// <param name="translations">The translations to use.</param> + internal TranslationHelper SetTranslations(IDictionary<string, IDictionary<string, string>> translations) + { + // reset translations + this.All.Clear(); + foreach (var pair in translations) + this.All[pair.Key] = new Dictionary<string, string>(pair.Value, StringComparer.InvariantCultureIgnoreCase); + + // rebuild cache + this.SetLocale(this.Locale, this.LocaleEnum); + + return this; + } + + /// <summary>Set the current locale and precache translations.</summary> + /// <param name="locale">The current locale.</param> + /// <param name="localeEnum">The game's current language code.</param> + internal void SetLocale(string locale, LocalizedContentManager.LanguageCode localeEnum) + { + this.Locale = locale.ToLower().Trim(); + this.LocaleEnum = localeEnum; + + this.ForLocale = new Dictionary<string, Translation>(StringComparer.InvariantCultureIgnoreCase); + foreach (string next in this.GetRelevantLocales(this.Locale)) + { + // skip if locale not defined + if (!this.All.TryGetValue(next, out IDictionary<string, string> translations)) + continue; + + // add missing translations + foreach (var pair in translations) + { + if (!this.ForLocale.ContainsKey(pair.Key)) + this.ForLocale.Add(pair.Key, new Translation(this.ModName, this.Locale, pair.Key, pair.Value)); + } + } + } + + + /********* + ** Private methods + *********/ + /// <summary>Get the locales which can provide translations for the given locale, in precedence order.</summary> + /// <param name="locale">The locale for which to find valid locales.</param> + private IEnumerable<string> GetRelevantLocales(string locale) + { + // given locale + yield return locale; + + // broader locales (like pt-BR => pt) + while (true) + { + int dashIndex = locale.LastIndexOf('-'); + if (dashIndex <= 0) + break; + + locale = locale.Substring(0, dashIndex); + yield return locale; + } + + // default + if (locale != "default") + yield return "default"; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs new file mode 100644 index 00000000..4378798c --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using Mono.Cecil; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>A minimal assembly definition resolver which resolves references to known assemblies.</summary> + internal class AssemblyDefinitionResolver : DefaultAssemblyResolver + { + /********* + ** Properties + *********/ + /// <summary>The known assemblies.</summary> + private readonly IDictionary<string, AssemblyDefinition> Loaded = new Dictionary<string, AssemblyDefinition>(); + + + /********* + ** Public methods + *********/ + /// <summary>Add known assemblies to the resolver.</summary> + /// <param name="assemblies">The known assemblies.</param> + public void Add(params AssemblyDefinition[] assemblies) + { + foreach (AssemblyDefinition assembly in assemblies) + { + this.Loaded[assembly.Name.Name] = assembly; + this.Loaded[assembly.Name.FullName] = assembly; + } + } + + /// <summary>Resolve an assembly reference.</summary> + /// <param name="name">The assembly name.</param> + public override AssemblyDefinition Resolve(AssemblyNameReference name) => this.ResolveName(name.Name) ?? base.Resolve(name); + + /// <summary>Resolve an assembly reference.</summary> + /// <param name="name">The assembly name.</param> + /// <param name="parameters">The assembly reader parameters.</param> + public override AssemblyDefinition Resolve(AssemblyNameReference name, ReaderParameters parameters) => this.ResolveName(name.Name) ?? base.Resolve(name, parameters); + + /// <summary>Resolve an assembly reference.</summary> + /// <param name="fullName">The assembly full name (including version, etc).</param> + public override AssemblyDefinition Resolve(string fullName) => this.ResolveName(fullName) ?? base.Resolve(fullName); + + /// <summary>Resolve an assembly reference.</summary> + /// <param name="fullName">The assembly full name (including version, etc).</param> + /// <param name="parameters">The assembly reader parameters.</param> + public override AssemblyDefinition Resolve(string fullName, ReaderParameters parameters) => this.ResolveName(fullName) ?? base.Resolve(fullName, parameters); + + + /********* + ** Private methods + *********/ + /// <summary>Resolve a known assembly definition based on its short or full name.</summary> + /// <param name="name">The assembly's short or full name.</param> + private AssemblyDefinition ResolveName(string name) + { + return this.Loaded.ContainsKey(name) + ? this.Loaded[name] + : null; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoadStatus.cs b/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoadStatus.cs new file mode 100644 index 00000000..11be19fc --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoadStatus.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>Indicates the result of an assembly load.</summary> + internal enum AssemblyLoadStatus + { + /// <summary>The assembly was loaded successfully.</summary> + Okay = 1, + + /// <summary>The assembly could not be loaded.</summary> + Failed = 2, + + /// <summary>The assembly is already loaded.</summary> + AlreadyLoaded = 3 + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs b/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs new file mode 100644 index 00000000..1e3c4a05 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs @@ -0,0 +1,333 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Mono.Cecil; +using Mono.Cecil.Cil; +using StardewModdingAPI.Framework.Exceptions; +using StardewModdingAPI.Metadata; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>Preprocesses and loads mod assemblies.</summary> + internal class AssemblyLoader + { + /********* + ** Properties + *********/ + /// <summary>Metadata for mapping assemblies to the current platform.</summary> + private readonly PlatformAssemblyMap AssemblyMap; + + /// <summary>A type => assembly lookup for types which should be rewritten.</summary> + private readonly IDictionary<string, Assembly> TypeAssemblies; + + /// <summary>Encapsulates monitoring and logging.</summary> + private readonly IMonitor Monitor; + + /// <summary>Whether to enable developer mode logging.</summary> + private readonly bool IsDeveloperMode; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="targetPlatform">The current game platform.</param> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="isDeveloperMode">Whether to enable developer mode logging.</param> + public AssemblyLoader(Platform targetPlatform, IMonitor monitor, bool isDeveloperMode) + { + this.Monitor = monitor; + this.IsDeveloperMode = isDeveloperMode; + this.AssemblyMap = Constants.GetAssemblyMap(targetPlatform); + + // generate type => assembly lookup for types which should be rewritten + this.TypeAssemblies = new Dictionary<string, Assembly>(); + foreach (Assembly assembly in this.AssemblyMap.Targets) + { + ModuleDefinition module = this.AssemblyMap.TargetModules[assembly]; + foreach (TypeDefinition type in module.GetTypes()) + { + if (!type.IsPublic) + continue; // no need to rewrite + if (type.Namespace.Contains("<")) + continue; // ignore assembly metadata + this.TypeAssemblies[type.FullName] = assembly; + } + } + } + + /// <summary>Preprocess and load an assembly.</summary> + /// <param name="mod">The mod for which the assembly is being loaded.</param> + /// <param name="assemblyPath">The assembly file path.</param> + /// <param name="assumeCompatible">Assume the mod is compatible, even if incompatible code is detected.</param> + /// <returns>Returns the rewrite metadata for the preprocessed assembly.</returns> + /// <exception cref="IncompatibleInstructionException">An incompatible CIL instruction was found while rewriting the assembly.</exception> + public Assembly Load(IModMetadata mod, string assemblyPath, bool assumeCompatible) + { + // get referenced local assemblies + AssemblyParseResult[] assemblies; + { + AssemblyDefinitionResolver resolver = new AssemblyDefinitionResolver(); + HashSet<string> visitedAssemblyNames = new HashSet<string>(AppDomain.CurrentDomain.GetAssemblies().Select(p => p.GetName().Name)); // don't try loading assemblies that are already loaded + assemblies = this.GetReferencedLocalAssemblies(new FileInfo(assemblyPath), visitedAssemblyNames, resolver).ToArray(); + } + + // validate load + if (!assemblies.Any() || assemblies[0].Status == AssemblyLoadStatus.Failed) + { + throw new SAssemblyLoadFailedException(!File.Exists(assemblyPath) + ? $"Could not load '{assemblyPath}' because it doesn't exist." + : $"Could not load '{assemblyPath}'." + ); + } + if (assemblies.Last().Status == AssemblyLoadStatus.AlreadyLoaded) // mod assembly is last in dependency order + throw new SAssemblyLoadFailedException($"Could not load '{assemblyPath}' because it was already loaded. Do you have two copies of this mod?"); + + // rewrite & load assemblies in leaf-to-root order + bool oneAssembly = assemblies.Length == 1; + Assembly lastAssembly = null; + HashSet<string> loggedMessages = new HashSet<string>(); + foreach (AssemblyParseResult assembly in assemblies) + { + if (assembly.Status == AssemblyLoadStatus.AlreadyLoaded) + continue; + + bool changed = this.RewriteAssembly(mod, assembly.Definition, assumeCompatible, loggedMessages, logPrefix: " "); + if (changed) + { + if (!oneAssembly) + this.Monitor.Log($" Loading {assembly.File.Name} (rewritten in memory)...", LogLevel.Trace); + using (MemoryStream outStream = new MemoryStream()) + { + assembly.Definition.Write(outStream); + byte[] bytes = outStream.ToArray(); + lastAssembly = Assembly.Load(bytes); + } + } + else + { + if (!oneAssembly) + this.Monitor.Log($" Loading {assembly.File.Name}...", LogLevel.Trace); + lastAssembly = Assembly.UnsafeLoadFrom(assembly.File.FullName); + } + } + + // last assembly loaded is the root + return lastAssembly; + } + + /// <summary>Resolve an assembly by its name.</summary> + /// <param name="name">The assembly name.</param> + /// <remarks> + /// This implementation returns the first loaded assembly which matches the short form of + /// the assembly name, to resolve assembly resolution issues when rewriting + /// assemblies (especially with Mono). Since this is meant to be called on <see cref="AppDomain.AssemblyResolve"/>, + /// the implicit assumption is that loading the exact assembly failed. + /// </remarks> + public Assembly ResolveAssembly(string name) + { + string shortName = name.Split(new[] { ',' }, 2).First(); // get simple name (without version and culture) + return AppDomain.CurrentDomain + .GetAssemblies() + .FirstOrDefault(p => p.GetName().Name == shortName); + } + + + /********* + ** Private methods + *********/ + /**** + ** Assembly parsing + ****/ + /// <summary>Get a list of referenced local assemblies starting from the mod assembly, ordered from leaf to root.</summary> + /// <param name="file">The assembly file to load.</param> + /// <param name="visitedAssemblyNames">The assembly names that should be skipped.</param> + /// <param name="assemblyResolver">A resolver which resolves references to known assemblies.</param> + /// <returns>Returns the rewrite metadata for the preprocessed assembly.</returns> + private IEnumerable<AssemblyParseResult> GetReferencedLocalAssemblies(FileInfo file, HashSet<string> visitedAssemblyNames, IAssemblyResolver assemblyResolver) + { + // validate + if (file.Directory == null) + throw new InvalidOperationException($"Could not get directory from file path '{file.FullName}'."); + if (!file.Exists) + yield break; // not a local assembly + + // read assembly + byte[] assemblyBytes = File.ReadAllBytes(file.FullName); + AssemblyDefinition assembly; + using (Stream readStream = new MemoryStream(assemblyBytes)) + assembly = AssemblyDefinition.ReadAssembly(readStream, new ReaderParameters(ReadingMode.Deferred) { AssemblyResolver = assemblyResolver }); + + // skip if already visited + if (visitedAssemblyNames.Contains(assembly.Name.Name)) + yield return new AssemblyParseResult(file, null, AssemblyLoadStatus.AlreadyLoaded); + visitedAssemblyNames.Add(assembly.Name.Name); + + // yield referenced assemblies + foreach (AssemblyNameReference dependency in assembly.MainModule.AssemblyReferences) + { + FileInfo dependencyFile = new FileInfo(Path.Combine(file.Directory.FullName, $"{dependency.Name}.dll")); + foreach (AssemblyParseResult result in this.GetReferencedLocalAssemblies(dependencyFile, visitedAssemblyNames, assemblyResolver)) + yield return result; + } + + // yield assembly + yield return new AssemblyParseResult(file, assembly, AssemblyLoadStatus.Okay); + } + + /**** + ** Assembly rewriting + ****/ + /// <summary>Rewrite the types referenced by an assembly.</summary> + /// <param name="mod">The mod for which the assembly is being loaded.</param> + /// <param name="assembly">The assembly to rewrite.</param> + /// <param name="assumeCompatible">Assume the mod is compatible, even if incompatible code is detected.</param> + /// <param name="loggedMessages">The messages that have already been logged for this mod.</param> + /// <param name="logPrefix">A string to prefix to log messages.</param> + /// <returns>Returns whether the assembly was modified.</returns> + /// <exception cref="IncompatibleInstructionException">An incompatible CIL instruction was found while rewriting the assembly.</exception> + private bool RewriteAssembly(IModMetadata mod, AssemblyDefinition assembly, bool assumeCompatible, HashSet<string> loggedMessages, string logPrefix) + { + ModuleDefinition module = assembly.MainModule; + string filename = $"{assembly.Name.Name}.dll"; + + // swap assembly references if needed (e.g. XNA => MonoGame) + bool platformChanged = false; + for (int i = 0; i < module.AssemblyReferences.Count; i++) + { + // remove old assembly reference + if (this.AssemblyMap.RemoveNames.Any(name => module.AssemblyReferences[i].Name == name)) + { + this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Rewriting {filename} for OS..."); + platformChanged = true; + module.AssemblyReferences.RemoveAt(i); + i--; + } + } + if (platformChanged) + { + // add target assembly references + foreach (AssemblyNameReference target in this.AssemblyMap.TargetReferences.Values) + module.AssemblyReferences.Add(target); + + // rewrite type scopes to use target assemblies + IEnumerable<TypeReference> typeReferences = module.GetTypeReferences().OrderBy(p => p.FullName); + foreach (TypeReference type in typeReferences) + this.ChangeTypeScope(type); + } + + // find (and optionally rewrite) incompatible instructions + bool anyRewritten = false; + IInstructionHandler[] handlers = new InstructionMetadata().GetHandlers().ToArray(); + foreach (MethodDefinition method in this.GetMethods(module)) + { + // check method definition + foreach (IInstructionHandler handler in handlers) + { + InstructionHandleResult result = handler.Handle(module, method, this.AssemblyMap, platformChanged); + this.ProcessInstructionHandleResult(mod, handler, result, loggedMessages, logPrefix, assumeCompatible, filename); + if (result == InstructionHandleResult.Rewritten) + anyRewritten = true; + } + + // check CIL instructions + ILProcessor cil = method.Body.GetILProcessor(); + foreach (Instruction instruction in cil.Body.Instructions.ToArray()) + { + foreach (IInstructionHandler handler in handlers) + { + InstructionHandleResult result = handler.Handle(module, cil, instruction, this.AssemblyMap, platformChanged); + this.ProcessInstructionHandleResult(mod, handler, result, loggedMessages, logPrefix, assumeCompatible, filename); + if (result == InstructionHandleResult.Rewritten) + anyRewritten = true; + } + } + } + + return platformChanged || anyRewritten; + } + + /// <summary>Process the result from an instruction handler.</summary> + /// <param name="mod">The mod being analysed.</param> + /// <param name="handler">The instruction handler.</param> + /// <param name="result">The result returned by the handler.</param> + /// <param name="loggedMessages">The messages already logged for the current mod.</param> + /// <param name="assumeCompatible">Assume the mod is compatible, even if incompatible code is detected.</param> + /// <param name="logPrefix">A string to prefix to log messages.</param> + /// <param name="filename">The assembly filename for log messages.</param> + private void ProcessInstructionHandleResult(IModMetadata mod, IInstructionHandler handler, InstructionHandleResult result, HashSet<string> loggedMessages, string logPrefix, bool assumeCompatible, string filename) + { + switch (result) + { + case InstructionHandleResult.Rewritten: + this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Rewrote {filename} to fix {handler.NounPhrase}..."); + break; + + case InstructionHandleResult.NotCompatible: + if (!assumeCompatible) + throw new IncompatibleInstructionException(handler.NounPhrase, $"Found an incompatible CIL instruction ({handler.NounPhrase}) while loading assembly {filename}."); + this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Found an incompatible CIL instruction ({handler.NounPhrase}) while loading assembly {filename}, but SMAPI is configured to allow it anyway. The mod may crash or behave unexpectedly.", LogLevel.Warn); + break; + + case InstructionHandleResult.DetectedGamePatch: + this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected game patcher ({handler.NounPhrase}) in assembly {filename}."); + this.Monitor.LogOnce(loggedMessages, $"{mod.DisplayName} patches the game, which may impact game stability. If you encounter problems, try removing this mod first.", LogLevel.Warn); + break; + + case InstructionHandleResult.DetectedSaveSerialiser: + this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected possible save serialiser change ({handler.NounPhrase}) in assembly {filename}."); + this.Monitor.LogOnce(loggedMessages, $"{mod.DisplayName} seems to change the save serialiser. It may change your saves in such a way that they won't work without this mod in the future.", LogLevel.Warn); + break; + + case InstructionHandleResult.DetectedDynamic: + this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected 'dynamic' keyword ({handler.NounPhrase}) in assembly {filename}."); + this.Monitor.LogOnce(loggedMessages, $"{mod.DisplayName} uses the 'dynamic' keyword, which isn't compatible with Stardew Valley on Linux or Mac.", +#if SMAPI_FOR_WINDOWS + this.IsDeveloperMode ? LogLevel.Warn : LogLevel.Debug +#else + LogLevel.Warn +#endif + ); + break; + + case InstructionHandleResult.None: + break; + + default: + throw new NotSupportedException($"Unrecognised instruction handler result '{result}'."); + } + } + + /// <summary>Get the correct reference to use for compatibility with the current platform.</summary> + /// <param name="type">The type reference to rewrite.</param> + private void ChangeTypeScope(TypeReference type) + { + // check skip conditions + if (type == null || type.FullName.StartsWith("System.")) + return; + + // get assembly + if (!this.TypeAssemblies.TryGetValue(type.FullName, out Assembly assembly)) + return; + + // replace scope + AssemblyNameReference assemblyRef = this.AssemblyMap.TargetReferences[assembly]; + type.Scope = assemblyRef; + } + + /// <summary>Get all methods in a module.</summary> + /// <param name="module">The module to search.</param> + private IEnumerable<MethodDefinition> GetMethods(ModuleDefinition module) + { + return ( + from type in module.GetTypes() + where type.HasMethods + from method in type.Methods + where method.HasBody + select method + ); + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/AssemblyParseResult.cs b/src/StardewModdingAPI/Framework/ModLoading/AssemblyParseResult.cs new file mode 100644 index 00000000..b56a776c --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/AssemblyParseResult.cs @@ -0,0 +1,36 @@ +using System.IO; +using Mono.Cecil; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>Metadata about a parsed assembly definition.</summary> + internal class AssemblyParseResult + { + /********* + ** Accessors + *********/ + /// <summary>The original assembly file.</summary> + public readonly FileInfo File; + + /// <summary>The assembly definition.</summary> + public readonly AssemblyDefinition Definition; + + /// <summary>The result of the assembly load.</summary> + public AssemblyLoadStatus Status; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="file">The original assembly file.</param> + /// <param name="assembly">The assembly definition.</param> + /// <param name="status">The result of the assembly load.</param> + public AssemblyParseResult(FileInfo file, AssemblyDefinition assembly, AssemblyLoadStatus status) + { + this.File = file; + this.Definition = assembly; + this.Status = status; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/Finders/EventFinder.cs b/src/StardewModdingAPI/Framework/ModLoading/Finders/EventFinder.cs new file mode 100644 index 00000000..e4beb7a9 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/Finders/EventFinder.cs @@ -0,0 +1,82 @@ +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace StardewModdingAPI.Framework.ModLoading.Finders +{ + /// <summary>Finds incompatible CIL instructions that reference a given event.</summary> + internal class EventFinder : IInstructionHandler + { + /********* + ** Properties + *********/ + /// <summary>The full type name for which to find references.</summary> + private readonly string FullTypeName; + + /// <summary>The event name for which to find references.</summary> + private readonly string EventName; + + /// <summary>The result to return for matching instructions.</summary> + private readonly InstructionHandleResult Result; + + + /********* + ** Accessors + *********/ + /// <summary>A brief noun phrase indicating what the instruction finder matches.</summary> + public string NounPhrase { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="fullTypeName">The full type name for which to find references.</param> + /// <param name="eventName">The event name for which to find references.</param> + /// <param name="result">The result to return for matching instructions.</param> + public EventFinder(string fullTypeName, string eventName, InstructionHandleResult result) + { + this.FullTypeName = fullTypeName; + this.EventName = eventName; + this.Result = result; + this.NounPhrase = $"{fullTypeName}.{eventName} event"; + } + + /// <summary>Perform the predefined logic for a method if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="method">The method definition containing the instruction.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public virtual InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + return InstructionHandleResult.None; + } + + /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="cil">The CIL processor.</param> + /// <param name="instruction">The instruction to handle.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public virtual InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + return this.IsMatch(instruction) + ? this.Result + : InstructionHandleResult.None; + } + + + /********* + ** Protected methods + *********/ + /// <summary>Get whether a CIL instruction matches.</summary> + /// <param name="instruction">The IL instruction.</param> + protected bool IsMatch(Instruction instruction) + { + MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + return + methodRef != null + && methodRef.DeclaringType.FullName == this.FullTypeName + && (methodRef.Name == "add_" + this.EventName || methodRef.Name == "remove_" + this.EventName); + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/Finders/FieldFinder.cs b/src/StardewModdingAPI/Framework/ModLoading/Finders/FieldFinder.cs new file mode 100644 index 00000000..00805815 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/Finders/FieldFinder.cs @@ -0,0 +1,82 @@ +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace StardewModdingAPI.Framework.ModLoading.Finders +{ + /// <summary>Finds incompatible CIL instructions that reference a given field.</summary> + internal class FieldFinder : IInstructionHandler + { + /********* + ** Properties + *********/ + /// <summary>The full type name for which to find references.</summary> + private readonly string FullTypeName; + + /// <summary>The field name for which to find references.</summary> + private readonly string FieldName; + + /// <summary>The result to return for matching instructions.</summary> + private readonly InstructionHandleResult Result; + + + /********* + ** Accessors + *********/ + /// <summary>A brief noun phrase indicating what the instruction finder matches.</summary> + public string NounPhrase { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="fullTypeName">The full type name for which to find references.</param> + /// <param name="fieldName">The field name for which to find references.</param> + /// <param name="result">The result to return for matching instructions.</param> + public FieldFinder(string fullTypeName, string fieldName, InstructionHandleResult result) + { + this.FullTypeName = fullTypeName; + this.FieldName = fieldName; + this.Result = result; + this.NounPhrase = $"{fullTypeName}.{fieldName} field"; + } + + /// <summary>Perform the predefined logic for a method if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="method">The method definition containing the instruction.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public virtual InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + return InstructionHandleResult.None; + } + + /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="cil">The CIL processor.</param> + /// <param name="instruction">The instruction to handle.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public virtual InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + return this.IsMatch(instruction) + ? this.Result + : InstructionHandleResult.None; + } + + + /********* + ** Protected methods + *********/ + /// <summary>Get whether a CIL instruction matches.</summary> + /// <param name="instruction">The IL instruction.</param> + protected bool IsMatch(Instruction instruction) + { + FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); + return + fieldRef != null + && fieldRef.DeclaringType.FullName == this.FullTypeName + && fieldRef.Name == this.FieldName; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/Finders/MethodFinder.cs b/src/StardewModdingAPI/Framework/ModLoading/Finders/MethodFinder.cs new file mode 100644 index 00000000..5358f181 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/Finders/MethodFinder.cs @@ -0,0 +1,82 @@ +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace StardewModdingAPI.Framework.ModLoading.Finders +{ + /// <summary>Finds incompatible CIL instructions that reference a given method.</summary> + internal class MethodFinder : IInstructionHandler + { + /********* + ** Properties + *********/ + /// <summary>The full type name for which to find references.</summary> + private readonly string FullTypeName; + + /// <summary>The method name for which to find references.</summary> + private readonly string MethodName; + + /// <summary>The result to return for matching instructions.</summary> + private readonly InstructionHandleResult Result; + + + /********* + ** Accessors + *********/ + /// <summary>A brief noun phrase indicating what the instruction finder matches.</summary> + public string NounPhrase { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="fullTypeName">The full type name for which to find references.</param> + /// <param name="methodName">The method name for which to find references.</param> + /// <param name="result">The result to return for matching instructions.</param> + public MethodFinder(string fullTypeName, string methodName, InstructionHandleResult result) + { + this.FullTypeName = fullTypeName; + this.MethodName = methodName; + this.Result = result; + this.NounPhrase = $"{fullTypeName}.{methodName} method"; + } + + /// <summary>Perform the predefined logic for a method if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="method">The method definition containing the instruction.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public virtual InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + return InstructionHandleResult.None; + } + + /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="cil">The CIL processor.</param> + /// <param name="instruction">The instruction to handle.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + return this.IsMatch(instruction) + ? this.Result + : InstructionHandleResult.None; + } + + + /********* + ** Protected methods + *********/ + /// <summary>Get whether a CIL instruction matches.</summary> + /// <param name="instruction">The IL instruction.</param> + protected bool IsMatch(Instruction instruction) + { + MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + return + methodRef != null + && methodRef.DeclaringType.FullName == this.FullTypeName + && methodRef.Name == this.MethodName; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/Finders/PropertyFinder.cs b/src/StardewModdingAPI/Framework/ModLoading/Finders/PropertyFinder.cs new file mode 100644 index 00000000..e54c86cf --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/Finders/PropertyFinder.cs @@ -0,0 +1,82 @@ +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace StardewModdingAPI.Framework.ModLoading.Finders +{ + /// <summary>Finds incompatible CIL instructions that reference a given property.</summary> + internal class PropertyFinder : IInstructionHandler + { + /********* + ** Properties + *********/ + /// <summary>The full type name for which to find references.</summary> + private readonly string FullTypeName; + + /// <summary>The property name for which to find references.</summary> + private readonly string PropertyName; + + /// <summary>The result to return for matching instructions.</summary> + private readonly InstructionHandleResult Result; + + + /********* + ** Accessors + *********/ + /// <summary>A brief noun phrase indicating what the instruction finder matches.</summary> + public string NounPhrase { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="fullTypeName">The full type name for which to find references.</param> + /// <param name="propertyName">The property name for which to find references.</param> + /// <param name="result">The result to return for matching instructions.</param> + public PropertyFinder(string fullTypeName, string propertyName, InstructionHandleResult result) + { + this.FullTypeName = fullTypeName; + this.PropertyName = propertyName; + this.Result = result; + this.NounPhrase = $"{fullTypeName}.{propertyName} property"; + } + + /// <summary>Perform the predefined logic for a method if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="method">The method definition containing the instruction.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public virtual InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + return InstructionHandleResult.None; + } + + /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="cil">The CIL processor.</param> + /// <param name="instruction">The instruction to handle.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public virtual InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + return this.IsMatch(instruction) + ? this.Result + : InstructionHandleResult.None; + } + + + /********* + ** Protected methods + *********/ + /// <summary>Get whether a CIL instruction matches.</summary> + /// <param name="instruction">The IL instruction.</param> + protected bool IsMatch(Instruction instruction) + { + MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + return + methodRef != null + && methodRef.DeclaringType.FullName == this.FullTypeName + && (methodRef.Name == "get_" + this.PropertyName || methodRef.Name == "set_" + this.PropertyName); + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/Finders/TypeFinder.cs b/src/StardewModdingAPI/Framework/ModLoading/Finders/TypeFinder.cs new file mode 100644 index 00000000..45349def --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/Finders/TypeFinder.cs @@ -0,0 +1,133 @@ +using System.Linq; +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace StardewModdingAPI.Framework.ModLoading.Finders +{ + /// <summary>Finds incompatible CIL instructions that reference a given type.</summary> + internal class TypeFinder : IInstructionHandler + { + /********* + ** Accessors + *********/ + /// <summary>The full type name for which to find references.</summary> + private readonly string FullTypeName; + + /// <summary>The result to return for matching instructions.</summary> + private readonly InstructionHandleResult Result; + + + /********* + ** Accessors + *********/ + /// <summary>A brief noun phrase indicating what the instruction finder matches.</summary> + public string NounPhrase { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="fullTypeName">The full type name to match.</param> + /// <param name="result">The result to return for matching instructions.</param> + public TypeFinder(string fullTypeName, InstructionHandleResult result) + { + this.FullTypeName = fullTypeName; + this.Result = result; + this.NounPhrase = $"{fullTypeName} type"; + } + + /// <summary>Perform the predefined logic for a method if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="method">The method definition containing the instruction.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public virtual InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + return this.IsMatch(method) + ? this.Result + : InstructionHandleResult.None; + } + + /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="cil">The CIL processor.</param> + /// <param name="instruction">The instruction to handle.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public virtual InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + return this.IsMatch(instruction) + ? this.Result + : InstructionHandleResult.None; + } + + + /********* + ** Protected methods + *********/ + /// <summary>Get whether a CIL instruction matches.</summary> + /// <param name="method">The method deifnition.</param> + protected bool IsMatch(MethodDefinition method) + { + if (this.IsMatch(method.ReturnType)) + return true; + + foreach (VariableDefinition variable in method.Body.Variables) + { + if (this.IsMatch(variable.VariableType)) + return true; + } + + return false; + } + + /// <summary>Get whether a CIL instruction matches.</summary> + /// <param name="instruction">The IL instruction.</param> + protected bool IsMatch(Instruction instruction) + { + // field reference + FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); + if (fieldRef != null) + { + return + this.IsMatch(fieldRef.DeclaringType) // field on target class + || this.IsMatch(fieldRef.FieldType); // field value is target class + } + + // method reference + MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + if (methodRef != null) + { + return + this.IsMatch(methodRef.DeclaringType) // method on target class + || this.IsMatch(methodRef.ReturnType) // method returns target class + || methodRef.Parameters.Any(p => this.IsMatch(p.ParameterType)); // method parameters + } + + return false; + } + + /// <summary>Get whether a type reference matches the expected type.</summary> + /// <param name="type">The type to check.</param> + protected bool IsMatch(TypeReference type) + { + // root type + if (type.FullName == this.FullTypeName) + return true; + + // generic arguments + if (type is GenericInstanceType genericType) + { + if (genericType.GenericArguments.Any(this.IsMatch)) + return true; + } + + // generic parameters (e.g. constraints) + if (type.GenericParameters.Any(this.IsMatch)) + return true; + + return false; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/IInstructionHandler.cs b/src/StardewModdingAPI/Framework/ModLoading/IInstructionHandler.cs new file mode 100644 index 00000000..8830cc74 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/IInstructionHandler.cs @@ -0,0 +1,34 @@ +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>Performs predefined logic for detected CIL instructions.</summary> + internal interface IInstructionHandler + { + /********* + ** Accessors + *********/ + /// <summary>A brief noun phrase indicating what the handler matches.</summary> + string NounPhrase { get; } + + + /********* + ** Methods + *********/ + /// <summary>Perform the predefined logic for a method if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="method">The method definition containing the instruction.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged); + + /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="cil">The CIL processor.</param> + /// <param name="instruction">The instruction to handle.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged); + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/IncompatibleInstructionException.cs b/src/StardewModdingAPI/Framework/ModLoading/IncompatibleInstructionException.cs new file mode 100644 index 00000000..17ec24b1 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/IncompatibleInstructionException.cs @@ -0,0 +1,35 @@ +using System; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>An exception raised when an incompatible instruction is found while loading a mod assembly.</summary> + internal class IncompatibleInstructionException : Exception + { + /********* + ** Accessors + *********/ + /// <summary>A brief noun phrase which describes the incompatible instruction that was found.</summary> + public string NounPhrase { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="nounPhrase">A brief noun phrase which describes the incompatible instruction that was found.</param> + public IncompatibleInstructionException(string nounPhrase) + : base($"Found an incompatible CIL instruction ({nounPhrase}).") + { + this.NounPhrase = nounPhrase; + } + + /// <summary>Construct an instance.</summary> + /// <param name="nounPhrase">A brief noun phrase which describes the incompatible instruction that was found.</param> + /// <param name="message">A message which describes the error.</param> + public IncompatibleInstructionException(string nounPhrase, string message) + : base(message) + { + this.NounPhrase = nounPhrase; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/InstructionHandleResult.cs b/src/StardewModdingAPI/Framework/ModLoading/InstructionHandleResult.cs new file mode 100644 index 00000000..0ae598fc --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/InstructionHandleResult.cs @@ -0,0 +1,24 @@ +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>Indicates how an instruction was handled.</summary> + internal enum InstructionHandleResult + { + /// <summary>No special handling is needed.</summary> + None, + + /// <summary>The instruction was successfully rewritten for compatibility.</summary> + Rewritten, + + /// <summary>The instruction is not compatible and can't be rewritten for compatibility.</summary> + NotCompatible, + + /// <summary>The instruction is compatible, but patches the game in a way that may impact stability.</summary> + DetectedGamePatch, + + /// <summary>The instruction is compatible, but affects the save serializer in a way that may make saves unloadable without the mod.</summary> + DetectedSaveSerialiser, + + /// <summary>The instruction is compatible, but uses the <c>dynamic</c> keyword which won't work on Linux/Mac.</summary> + DetectedDynamic + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/InvalidModStateException.cs b/src/StardewModdingAPI/Framework/ModLoading/InvalidModStateException.cs new file mode 100644 index 00000000..075e237a --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/InvalidModStateException.cs @@ -0,0 +1,14 @@ +using System; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>An exception which indicates that something went seriously wrong while loading mods, and SMAPI should abort outright.</summary> + internal class InvalidModStateException : Exception + { + /// <summary>Construct an instance.</summary> + /// <param name="message">The error message.</param> + /// <param name="ex">The underlying exception, if any.</param> + public InvalidModStateException(string message, Exception ex = null) + : base(message, ex) { } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModDependencyStatus.cs b/src/StardewModdingAPI/Framework/ModLoading/ModDependencyStatus.cs new file mode 100644 index 00000000..0774b487 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/ModDependencyStatus.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>The status of a given mod in the dependency-sorting algorithm.</summary> + internal enum ModDependencyStatus + { + /// <summary>The mod hasn't been visited yet.</summary> + Queued, + + /// <summary>The mod is currently being analysed as part of a dependency chain.</summary> + Checking, + + /// <summary>The mod has already been sorted.</summary> + Sorted, + + /// <summary>The mod couldn't be sorted due to a metadata issue (e.g. missing dependencies).</summary> + Failed + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs b/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs new file mode 100644 index 00000000..5055da75 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs @@ -0,0 +1,68 @@ +using StardewModdingAPI.Framework.Models; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>Metadata for a mod.</summary> + internal class ModMetadata : IModMetadata + { + /********* + ** Accessors + *********/ + /// <summary>The mod's display name.</summary> + public string DisplayName { get; } + + /// <summary>The mod's full directory path.</summary> + public string DirectoryPath { get; } + + /// <summary>The mod manifest.</summary> + public IManifest Manifest { get; } + + /// <summary>Metadata about the mod from SMAPI's internal data (if any).</summary> + public ModDataRecord DataRecord { get; } + + /// <summary>The metadata resolution status.</summary> + public ModMetadataStatus Status { get; private set; } + + /// <summary>The reason the metadata is invalid, if any.</summary> + public string Error { get; private set; } + + /// <summary>The mod instance (if it was loaded).</summary> + public IMod Mod { get; private set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="displayName">The mod's display name.</param> + /// <param name="directoryPath">The mod's full directory path.</param> + /// <param name="manifest">The mod manifest.</param> + /// <param name="dataRecord">Metadata about the mod from SMAPI's internal data (if any).</param> + public ModMetadata(string displayName, string directoryPath, IManifest manifest, ModDataRecord dataRecord) + { + this.DisplayName = displayName; + this.DirectoryPath = directoryPath; + this.Manifest = manifest; + this.DataRecord = dataRecord; + } + + /// <summary>Set the mod status.</summary> + /// <param name="status">The metadata resolution status.</param> + /// <param name="error">The reason the metadata is invalid, if any.</param> + /// <returns>Return the instance for chaining.</returns> + public IModMetadata SetStatus(ModMetadataStatus status, string error = null) + { + this.Status = status; + this.Error = error; + return this; + } + + /// <summary>Set the mod instance.</summary> + /// <param name="mod">The mod instance to set.</param> + public IModMetadata SetMod(IMod mod) + { + this.Mod = mod; + return this; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModMetadataStatus.cs b/src/StardewModdingAPI/Framework/ModLoading/ModMetadataStatus.cs new file mode 100644 index 00000000..ab65f7b4 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/ModMetadataStatus.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>Indicates the status of a mod's metadata resolution.</summary> + internal enum ModMetadataStatus + { + /// <summary>The mod has been found, but hasn't been processed yet.</summary> + Found, + + /// <summary>The mod cannot be loaded.</summary> + Failed + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs new file mode 100644 index 00000000..d0ef1b08 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs @@ -0,0 +1,366 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using StardewModdingAPI.Framework.Exceptions; +using StardewModdingAPI.Framework.Models; +using StardewModdingAPI.Framework.Serialisation; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>Finds and processes mod metadata.</summary> + internal class ModResolver + { + /********* + ** Public methods + *********/ + /// <summary>Get manifest metadata for each folder in the given root path.</summary> + /// <param name="rootPath">The root path to search for mods.</param> + /// <param name="jsonHelper">The JSON helper with which to read manifests.</param> + /// <param name="dataRecords">Metadata about mods from SMAPI's internal data.</param> + /// <returns>Returns the manifests by relative folder.</returns> + public IEnumerable<IModMetadata> ReadManifests(string rootPath, JsonHelper jsonHelper, IEnumerable<ModDataRecord> dataRecords) + { + dataRecords = dataRecords.ToArray(); + + foreach (DirectoryInfo modDir in this.GetModFolders(rootPath)) + { + // read file + Manifest manifest = null; + string path = Path.Combine(modDir.FullName, "manifest.json"); + string error = null; + try + { + // read manifest + manifest = jsonHelper.ReadJsonFile<Manifest>(path); + + // validate + if (manifest == null) + { + error = File.Exists(path) + ? "its manifest is invalid." + : "it doesn't have a manifest."; + } + else if (string.IsNullOrWhiteSpace(manifest.EntryDll)) + error = "its manifest doesn't set an entry DLL."; + } + catch (SParseException ex) + { + error = $"parsing its manifest failed: {ex.Message}"; + } + catch (Exception ex) + { + error = $"parsing its manifest failed:\n{ex.GetLogSummary()}"; + } + + // get internal data record (if any) + ModDataRecord dataRecord = null; + if (manifest != null) + { + string key = !string.IsNullOrWhiteSpace(manifest.UniqueID) ? manifest.UniqueID : manifest.EntryDll; + dataRecord = dataRecords.FirstOrDefault(record => record.ID.Matches(key, manifest)); + } + + // add default update keys + if (manifest != null && manifest.UpdateKeys == null && dataRecord?.UpdateKeys != null) + manifest.UpdateKeys = dataRecord.UpdateKeys; + + // build metadata + string displayName = !string.IsNullOrWhiteSpace(manifest?.Name) + ? manifest.Name + : modDir.FullName.Replace(rootPath, "").Trim('/', '\\'); + ModMetadataStatus status = error == null + ? ModMetadataStatus.Found + : ModMetadataStatus.Failed; + + yield return new ModMetadata(displayName, modDir.FullName, manifest, dataRecord).SetStatus(status, error); + } + } + + /// <summary>Validate manifest metadata.</summary> + /// <param name="mods">The mod manifests to validate.</param> + /// <param name="apiVersion">The current SMAPI version.</param> + /// <param name="vendorModUrls">Maps vendor keys (like <c>Nexus</c>) to their mod URL template (where <c>{0}</c> is the mod ID).</param> + public void ValidateManifests(IEnumerable<IModMetadata> mods, ISemanticVersion apiVersion, IDictionary<string, string> vendorModUrls) + { + mods = mods.ToArray(); + + // validate each manifest + foreach (IModMetadata mod in mods) + { + // skip if already failed + if (mod.Status == ModMetadataStatus.Failed) + continue; + + // validate compatibility + ModCompatibility compatibility = mod.DataRecord?.GetCompatibility(mod.Manifest.Version); + switch (compatibility?.Status) + { + case ModStatus.Obsolete: + mod.SetStatus(ModMetadataStatus.Failed, $"it's obsolete: {compatibility.ReasonPhrase}"); + continue; + + case ModStatus.AssumeBroken: + { + // get reason + string reasonPhrase = compatibility.ReasonPhrase ?? "it's no longer compatible"; + + // get update URLs + List<string> updateUrls = new List<string>(); + foreach (string key in mod.Manifest.UpdateKeys ?? new string[0]) + { + string[] parts = key.Split(new[] { ':' }, 2); + if (parts.Length != 2) + continue; + + string vendorKey = parts[0].Trim(); + string modID = parts[1].Trim(); + + if (vendorModUrls.TryGetValue(vendorKey, out string urlTemplate)) + updateUrls.Add(string.Format(urlTemplate, modID)); + } + if (mod.DataRecord.AlternativeUrl != null) + updateUrls.Add(mod.DataRecord.AlternativeUrl); + + // build error + string error = $"{reasonPhrase}. Please check for a "; + if (mod.Manifest.Version.Equals(compatibility.UpperVersion)) + error += "newer version"; + else + error += $"version newer than {compatibility.UpperVersion}"; + error += " at " + string.Join(" or ", updateUrls); + + mod.SetStatus(ModMetadataStatus.Failed, error); + } + continue; + } + + // validate SMAPI version + if (mod.Manifest.MinimumApiVersion?.IsNewerThan(apiVersion) == true) + { + mod.SetStatus(ModMetadataStatus.Failed, $"it needs SMAPI {mod.Manifest.MinimumApiVersion} or later. Please update SMAPI to the latest version to use this mod."); + continue; + } + + // validate DLL path + string assemblyPath = Path.Combine(mod.DirectoryPath, mod.Manifest.EntryDll); + if (!File.Exists(assemblyPath)) + { + mod.SetStatus(ModMetadataStatus.Failed, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist."); + continue; + } + + // validate required fields + { + List<string> missingFields = new List<string>(3); + + if (string.IsNullOrWhiteSpace(mod.Manifest.Name)) + missingFields.Add(nameof(IManifest.Name)); + if (mod.Manifest.Version == null || mod.Manifest.Version.ToString() == "0.0") + missingFields.Add(nameof(IManifest.Version)); + if (string.IsNullOrWhiteSpace(mod.Manifest.UniqueID)) + missingFields.Add(nameof(IManifest.UniqueID)); + + if (missingFields.Any()) + mod.SetStatus(ModMetadataStatus.Failed, $"its manifest is missing required fields ({string.Join(", ", missingFields)})."); + } + } + + // validate IDs are unique + { + var duplicatesByID = mods + .GroupBy(mod => mod.Manifest?.UniqueID?.Trim(), mod => mod, StringComparer.InvariantCultureIgnoreCase) + .Where(p => p.Count() > 1); + foreach (var group in duplicatesByID) + { + foreach (IModMetadata mod in group) + { + if (mod.Status == ModMetadataStatus.Failed) + continue; // don't replace metadata error + mod.SetStatus(ModMetadataStatus.Failed, $"its unique ID '{mod.Manifest.UniqueID}' is used by multiple mods ({string.Join(", ", group.Select(p => p.DisplayName))})."); + } + } + } + } + + /// <summary>Sort the given mods by the order they should be loaded.</summary> + /// <param name="mods">The mods to process.</param> + public IEnumerable<IModMetadata> ProcessDependencies(IEnumerable<IModMetadata> mods) + { + // initialise metadata + mods = mods.ToArray(); + var sortedMods = new Stack<IModMetadata>(); + var states = mods.ToDictionary(mod => mod, mod => ModDependencyStatus.Queued); + + // handle failed mods + foreach (IModMetadata mod in mods.Where(m => m.Status == ModMetadataStatus.Failed)) + { + states[mod] = ModDependencyStatus.Failed; + sortedMods.Push(mod); + } + + // sort mods + foreach (IModMetadata mod in mods) + this.ProcessDependencies(mods.ToArray(), mod, states, sortedMods, new List<IModMetadata>()); + + return sortedMods.Reverse(); + } + + + /********* + ** Private methods + *********/ + /// <summary>Sort a mod's dependencies by the order they should be loaded, and remove any mods that can't be loaded due to missing or conflicting dependencies.</summary> + /// <param name="mods">The full list of mods being validated.</param> + /// <param name="mod">The mod whose dependencies to process.</param> + /// <param name="states">The dependency state for each mod.</param> + /// <param name="sortedMods">The list in which to save mods sorted by dependency order.</param> + /// <param name="currentChain">The current change of mod dependencies.</param> + /// <returns>Returns the mod dependency status.</returns> + private ModDependencyStatus ProcessDependencies(IModMetadata[] mods, IModMetadata mod, IDictionary<IModMetadata, ModDependencyStatus> states, Stack<IModMetadata> sortedMods, ICollection<IModMetadata> currentChain) + { + // check if already visited + switch (states[mod]) + { + // already sorted or failed + case ModDependencyStatus.Sorted: + case ModDependencyStatus.Failed: + return states[mod]; + + // dependency loop + case ModDependencyStatus.Checking: + // This should never happen. The higher-level mod checks if the dependency is + // already being checked, so it can fail without visiting a mod twice. If this + // case is hit, that logic didn't catch the dependency loop for some reason. + throw new InvalidModStateException($"A dependency loop was not caught by the calling iteration ({string.Join(" => ", currentChain.Select(p => p.DisplayName))} => {mod.DisplayName}))."); + + // not visited yet, start processing + case ModDependencyStatus.Queued: + break; + + // sanity check + default: + throw new InvalidModStateException($"Unknown dependency status '{states[mod]}'."); + } + + // no dependencies, mark sorted + if (mod.Manifest.Dependencies == null || !mod.Manifest.Dependencies.Any()) + { + sortedMods.Push(mod); + return states[mod] = ModDependencyStatus.Sorted; + } + + // get dependencies + var dependencies = + ( + from entry in mod.Manifest.Dependencies + let dependencyMod = mods.FirstOrDefault(m => string.Equals(m.Manifest?.UniqueID, entry.UniqueID, StringComparison.InvariantCultureIgnoreCase)) + orderby entry.UniqueID + select new + { + ID = entry.UniqueID, + MinVersion = entry.MinimumVersion, + Mod = dependencyMod, + IsRequired = entry.IsRequired + } + ) + .ToArray(); + + // missing required dependencies, mark failed + { + string[] failedIDs = (from entry in dependencies where entry.IsRequired && entry.Mod == null select entry.ID).ToArray(); + if (failedIDs.Any()) + { + sortedMods.Push(mod); + mod.SetStatus(ModMetadataStatus.Failed, $"it requires mods which aren't installed ({string.Join(", ", failedIDs)})."); + return states[mod] = ModDependencyStatus.Failed; + } + } + + // dependency min version not met, mark failed + { + string[] failedLabels = + ( + from entry in dependencies + where entry.Mod != null && entry.MinVersion != null && entry.MinVersion.IsNewerThan(entry.Mod.Manifest.Version) + select $"{entry.Mod.DisplayName} (needs {entry.MinVersion} or later)" + ) + .ToArray(); + if (failedLabels.Any()) + { + sortedMods.Push(mod); + mod.SetStatus(ModMetadataStatus.Failed, $"it needs newer versions of some mods: {string.Join(", ", failedLabels)}."); + return states[mod] = ModDependencyStatus.Failed; + } + } + + // process dependencies + { + states[mod] = ModDependencyStatus.Checking; + + // recursively sort dependencies + foreach (var dependency in dependencies) + { + IModMetadata requiredMod = dependency.Mod; + var subchain = new List<IModMetadata>(currentChain) { mod }; + + // ignore missing optional dependency + if (!dependency.IsRequired && requiredMod == null) + continue; + + // detect dependency loop + if (states[requiredMod] == ModDependencyStatus.Checking) + { + sortedMods.Push(mod); + mod.SetStatus(ModMetadataStatus.Failed, $"its dependencies have a circular reference: {string.Join(" => ", subchain.Select(p => p.DisplayName))} => {requiredMod.DisplayName})."); + return states[mod] = ModDependencyStatus.Failed; + } + + // recursively process each dependency + var substatus = this.ProcessDependencies(mods, requiredMod, states, sortedMods, subchain); + switch (substatus) + { + // sorted successfully + case ModDependencyStatus.Sorted: + break; + + // failed, which means this mod can't be loaded either + case ModDependencyStatus.Failed: + sortedMods.Push(mod); + mod.SetStatus(ModMetadataStatus.Failed, $"it needs the '{requiredMod.DisplayName}' mod, which couldn't be loaded."); + return states[mod] = ModDependencyStatus.Failed; + + // unexpected status + case ModDependencyStatus.Queued: + case ModDependencyStatus.Checking: + throw new InvalidModStateException($"Something went wrong sorting dependencies: mod '{requiredMod.DisplayName}' unexpectedly stayed in the '{substatus}' status."); + + // sanity check + default: + throw new InvalidModStateException($"Unknown dependency status '{states[mod]}'."); + } + } + + // all requirements sorted successfully + sortedMods.Push(mod); + return states[mod] = ModDependencyStatus.Sorted; + } + } + + /// <summary>Get all mod folders in a root folder, passing through empty folders as needed.</summary> + /// <param name="rootPath">The root folder path to search.</param> + private IEnumerable<DirectoryInfo> GetModFolders(string rootPath) + { + foreach (string modRootPath in Directory.GetDirectories(rootPath)) + { + DirectoryInfo directory = new DirectoryInfo(modRootPath); + + // if a folder only contains another folder, check the inner folder instead + while (!directory.GetFiles().Any() && directory.GetDirectories().Length == 1) + directory = directory.GetDirectories().First(); + + yield return directory; + } + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/Platform.cs b/src/StardewModdingAPI/Framework/ModLoading/Platform.cs new file mode 100644 index 00000000..45e881c4 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/Platform.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>The game's platform version.</summary> + internal enum Platform + { + /// <summary>The Linux/Mac version of the game.</summary> + Mono, + + /// <summary>The Windows version of the game.</summary> + Windows + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/PlatformAssemblyMap.cs b/src/StardewModdingAPI/Framework/ModLoading/PlatformAssemblyMap.cs new file mode 100644 index 00000000..463f45e8 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/PlatformAssemblyMap.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Mono.Cecil; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>Metadata for mapping assemblies to the current <see cref="Platform"/>.</summary> + internal class PlatformAssemblyMap + { + /********* + ** Accessors + *********/ + /**** + ** Data + ****/ + /// <summary>The target game platform.</summary> + public readonly Platform TargetPlatform; + + /// <summary>The short assembly names to remove as assembly reference, and replace with the <see cref="Targets"/>. These should be short names (like "Stardew Valley").</summary> + public readonly string[] RemoveNames; + + /**** + ** Metadata + ****/ + /// <summary>The assemblies to target. Equivalent types should be rewritten to use these assemblies.</summary> + public readonly Assembly[] Targets; + + /// <summary>An assembly => reference cache.</summary> + public readonly IDictionary<Assembly, AssemblyNameReference> TargetReferences; + + /// <summary>An assembly => module cache.</summary> + public readonly IDictionary<Assembly, ModuleDefinition> TargetModules; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="targetPlatform">The target game platform.</param> + /// <param name="removeAssemblyNames">The assembly short names to remove (like <c>Stardew Valley</c>).</param> + /// <param name="targetAssemblies">The assemblies to target.</param> + public PlatformAssemblyMap(Platform targetPlatform, string[] removeAssemblyNames, Assembly[] targetAssemblies) + { + // save data + this.TargetPlatform = targetPlatform; + this.RemoveNames = removeAssemblyNames; + + // cache assembly metadata + this.Targets = targetAssemblies; + this.TargetReferences = this.Targets.ToDictionary(assembly => assembly, assembly => AssemblyNameReference.Parse(assembly.FullName)); + this.TargetModules = this.Targets.ToDictionary(assembly => assembly, assembly => ModuleDefinition.ReadModule(assembly.Modules.Single().FullyQualifiedName)); + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/RewriteHelper.cs b/src/StardewModdingAPI/Framework/ModLoading/RewriteHelper.cs new file mode 100644 index 00000000..56a60a72 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/RewriteHelper.cs @@ -0,0 +1,94 @@ +using System; +using System.Linq; +using System.Reflection; +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>Provides helper methods for field rewriters.</summary> + internal static class RewriteHelper + { + /********* + ** Public methods + *********/ + /// <summary>Get the field reference from an instruction if it matches.</summary> + /// <param name="instruction">The IL instruction.</param> + public static FieldReference AsFieldReference(Instruction instruction) + { + return instruction.OpCode == OpCodes.Ldfld || instruction.OpCode == OpCodes.Ldsfld || instruction.OpCode == OpCodes.Stfld || instruction.OpCode == OpCodes.Stsfld + ? (FieldReference)instruction.Operand + : null; + } + + /// <summary>Get the method reference from an instruction if it matches.</summary> + /// <param name="instruction">The IL instruction.</param> + public static MethodReference AsMethodReference(Instruction instruction) + { + return instruction.OpCode == OpCodes.Call || instruction.OpCode == OpCodes.Callvirt + ? (MethodReference)instruction.Operand + : null; + } + + /// <summary>Get whether a type matches a type reference.</summary> + /// <param name="type">The defined type.</param> + /// <param name="reference">The type reference.</param> + public static bool IsSameType(Type type, TypeReference reference) + { + // same namespace & name + if (type.Namespace != reference.Namespace || type.Name != reference.Name) + return false; + + // same generic parameters + if (type.IsGenericType) + { + if (!reference.IsGenericInstance) + return false; + + Type[] defGenerics = type.GetGenericArguments(); + TypeReference[] refGenerics = ((GenericInstanceType)reference).GenericArguments.ToArray(); + if (defGenerics.Length != refGenerics.Length) + return false; + for (int i = 0; i < defGenerics.Length; i++) + { + if (!RewriteHelper.IsSameType(defGenerics[i], refGenerics[i])) + return false; + } + } + + return true; + } + + /// <summary>Get whether a method definition matches the signature expected by a method reference.</summary> + /// <param name="definition">The method definition.</param> + /// <param name="reference">The method reference.</param> + public static bool HasMatchingSignature(MethodInfo definition, MethodReference reference) + { + // same name + if (definition.Name != reference.Name) + return false; + + // same arguments + ParameterInfo[] definitionParameters = definition.GetParameters(); + ParameterDefinition[] referenceParameters = reference.Parameters.ToArray(); + if (referenceParameters.Length != definitionParameters.Length) + return false; + for (int i = 0; i < referenceParameters.Length; i++) + { + if (!RewriteHelper.IsSameType(definitionParameters[i].ParameterType, referenceParameters[i].ParameterType)) + return false; + } + return true; + } + + /// <summary>Get whether a type has a method whose signature matches the one expected by a method reference.</summary> + /// <param name="type">The type to check.</param> + /// <param name="reference">The method reference.</param> + public static bool HasMatchingSignature(Type type, MethodReference reference) + { + return type + .GetMethods(BindingFlags.Instance | BindingFlags.DeclaredOnly | BindingFlags.Public) + .Any(method => RewriteHelper.HasMatchingSignature(method, reference)); + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs b/src/StardewModdingAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs new file mode 100644 index 00000000..63358b39 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs @@ -0,0 +1,50 @@ +using System; +using System.Reflection; +using Mono.Cecil; +using Mono.Cecil.Cil; +using StardewModdingAPI.Framework.ModLoading.Finders; + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters +{ + /// <summary>Rewrites references to one field with another.</summary> + internal class FieldReplaceRewriter : FieldFinder + { + /********* + ** Properties + *********/ + /// <summary>The new field to reference.</summary> + private readonly FieldInfo ToField; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="type">The type whose field to which references should be rewritten.</param> + /// <param name="fromFieldName">The field name to rewrite.</param> + /// <param name="toFieldName">The new field name to reference.</param> + public FieldReplaceRewriter(Type type, string fromFieldName, string toFieldName) + : base(type.FullName, fromFieldName, InstructionHandleResult.None) + { + this.ToField = type.GetField(toFieldName); + if (this.ToField == null) + throw new InvalidOperationException($"The {type.FullName} class doesn't have a {toFieldName} field."); + } + + /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="cil">The CIL processor.</param> + /// <param name="instruction">The instruction to handle.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public override InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + if (!this.IsMatch(instruction)) + return InstructionHandleResult.None; + + FieldReference newRef = module.Import(this.ToField); + cil.Replace(instruction, cil.Create(instruction.OpCode, newRef)); + return InstructionHandleResult.Rewritten; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/Rewriters/FieldToPropertyRewriter.cs b/src/StardewModdingAPI/Framework/ModLoading/Rewriters/FieldToPropertyRewriter.cs new file mode 100644 index 00000000..a20b8bee --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/Rewriters/FieldToPropertyRewriter.cs @@ -0,0 +1,51 @@ +using System; +using Mono.Cecil; +using Mono.Cecil.Cil; +using StardewModdingAPI.Framework.ModLoading.Finders; + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters +{ + /// <summary>Rewrites field references into property references.</summary> + internal class FieldToPropertyRewriter : FieldFinder + { + /********* + ** Properties + *********/ + /// <summary>The type whose field to which references should be rewritten.</summary> + private readonly Type Type; + + /// <summary>The field name to rewrite.</summary> + private readonly string FieldName; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="type">The type whose field to which references should be rewritten.</param> + /// <param name="fieldName">The field name to rewrite.</param> + public FieldToPropertyRewriter(Type type, string fieldName) + : base(type.FullName, fieldName, InstructionHandleResult.None) + { + this.Type = type; + this.FieldName = fieldName; + } + + /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="cil">The CIL processor.</param> + /// <param name="instruction">The instruction to handle.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public override InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + if (!this.IsMatch(instruction)) + return InstructionHandleResult.None; + + string methodPrefix = instruction.OpCode == OpCodes.Ldsfld || instruction.OpCode == OpCodes.Ldfld ? "get" : "set"; + MethodReference propertyRef = module.Import(this.Type.GetMethod($"{methodPrefix}_{this.FieldName}")); + cil.Replace(instruction, cil.Create(OpCodes.Call, propertyRef)); + return InstructionHandleResult.Rewritten; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs b/src/StardewModdingAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs new file mode 100644 index 00000000..974fcf4c --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs @@ -0,0 +1,88 @@ +using System; +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters +{ + /// <summary>Rewrites method references from one parent type to another if the signatures match.</summary> + internal class MethodParentRewriter : IInstructionHandler + { + /********* + ** Properties + *********/ + /// <summary>The type whose methods to remap.</summary> + private readonly Type FromType; + + /// <summary>The type with methods to map to.</summary> + private readonly Type ToType; + + /// <summary>Whether to only rewrite references if loading the assembly on a different platform than it was compiled on.</summary> + private readonly bool OnlyIfPlatformChanged; + + + /********* + ** Accessors + *********/ + /// <summary>A brief noun phrase indicating what the instruction finder matches.</summary> + public string NounPhrase { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="fromType">The type whose methods to remap.</param> + /// <param name="toType">The type with methods to map to.</param> + /// <param name="onlyIfPlatformChanged">Whether to only rewrite references if loading the assembly on a different platform than it was compiled on.</param> + public MethodParentRewriter(Type fromType, Type toType, bool onlyIfPlatformChanged = false) + { + this.FromType = fromType; + this.ToType = toType; + this.NounPhrase = $"{fromType.Name} methods"; + this.OnlyIfPlatformChanged = onlyIfPlatformChanged; + } + + /// <summary>Perform the predefined logic for a method if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="method">The method definition containing the instruction.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + return InstructionHandleResult.None; + } + + /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="cil">The CIL processor.</param> + /// <param name="instruction">The instruction to handle.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + if (!this.IsMatch(instruction, platformChanged)) + return InstructionHandleResult.None; + + MethodReference methodRef = (MethodReference)instruction.Operand; + methodRef.DeclaringType = module.Import(this.ToType); + return InstructionHandleResult.Rewritten; + } + + + /********* + ** Protected methods + *********/ + /// <summary>Get whether a CIL instruction matches.</summary> + /// <param name="instruction">The IL instruction.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + protected bool IsMatch(Instruction instruction, bool platformChanged) + { + MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + return + methodRef != null + && (platformChanged || !this.OnlyIfPlatformChanged) + && methodRef.DeclaringType.FullName == this.FromType.FullName + && RewriteHelper.HasMatchingSignature(this.ToType, methodRef); + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs b/src/StardewModdingAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs new file mode 100644 index 00000000..74f2fcdd --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs @@ -0,0 +1,154 @@ +using System; +using Mono.Cecil; +using Mono.Cecil.Cil; +using StardewModdingAPI.Framework.ModLoading.Finders; + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters +{ + /// <summary>Rewrites all references to a type.</summary> + internal class TypeReferenceRewriter : TypeFinder + { + /********* + ** Properties + *********/ + /// <summary>The full type name to which to find references.</summary> + private readonly string FromTypeName; + + /// <summary>The new type to reference.</summary> + private readonly Type ToType; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="fromTypeFullName">The full type name to which to find references.</param> + /// <param name="toType">The new type to reference.</param> + public TypeReferenceRewriter(string fromTypeFullName, Type toType) + : base(fromTypeFullName, InstructionHandleResult.None) + { + this.FromTypeName = fromTypeFullName; + this.ToType = toType; + } + + /// <summary>Perform the predefined logic for a method if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="method">The method definition containing the instruction.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public override InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + bool rewritten = false; + + // return type + if (this.IsMatch(method.ReturnType)) + { + method.ReturnType = this.RewriteIfNeeded(module, method.ReturnType); + rewritten = true; + } + + // parameters + foreach (ParameterDefinition parameter in method.Parameters) + { + if (this.IsMatch(parameter.ParameterType)) + { + parameter.ParameterType = this.RewriteIfNeeded(module, parameter.ParameterType); + rewritten = true; + } + } + + // generic parameters + for (int i = 0; i < method.GenericParameters.Count; i++) + { + var parameter = method.GenericParameters[i]; + if (this.IsMatch(parameter)) + { + TypeReference newType = this.RewriteIfNeeded(module, parameter); + if (newType != parameter) + method.GenericParameters[i] = new GenericParameter(parameter.Name, newType); + rewritten = true; + } + } + + // local variables + foreach (VariableDefinition variable in method.Body.Variables) + { + if (this.IsMatch(variable.VariableType)) + { + variable.VariableType = this.RewriteIfNeeded(module, variable.VariableType); + rewritten = true; + } + } + + return rewritten + ? InstructionHandleResult.Rewritten + : InstructionHandleResult.None; + } + + /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="cil">The CIL processor.</param> + /// <param name="instruction">The instruction to handle.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public override InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + if (!this.IsMatch(instruction) && !instruction.ToString().Contains(this.FromTypeName)) + return InstructionHandleResult.None; + + // field reference + FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); + if (fieldRef != null) + { + fieldRef.DeclaringType = this.RewriteIfNeeded(module, fieldRef.DeclaringType); + fieldRef.FieldType = this.RewriteIfNeeded(module, fieldRef.FieldType); + } + + // method reference + MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + if (methodRef != null) + { + methodRef.DeclaringType = this.RewriteIfNeeded(module, methodRef.DeclaringType); + methodRef.ReturnType = this.RewriteIfNeeded(module, methodRef.ReturnType); + foreach (var parameter in methodRef.Parameters) + parameter.ParameterType = this.RewriteIfNeeded(module, parameter.ParameterType); + } + + // type reference + if (instruction.Operand is TypeReference typeRef) + { + TypeReference newRef = this.RewriteIfNeeded(module, typeRef); + if (typeRef != newRef) + cil.Replace(instruction, cil.Create(instruction.OpCode, newRef)); + } + + return InstructionHandleResult.Rewritten; + } + + /********* + ** Private methods + *********/ + /// <summary>Get the adjusted type reference if it matches, else the same value.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="type">The type to replace if it matches.</param> + private TypeReference RewriteIfNeeded(ModuleDefinition module, TypeReference type) + { + // root type + if (type.FullName == this.FromTypeName) + return module.Import(this.ToType); + + // generic arguments + if (type is GenericInstanceType genericType) + { + for (int i = 0; i < genericType.GenericArguments.Count; i++) + genericType.GenericArguments[i] = this.RewriteIfNeeded(module, genericType.GenericArguments[i]); + } + + // generic parameters (e.g. constraints) + for (int i = 0; i < type.GenericParameters.Count; i++) + type.GenericParameters[i] = new GenericParameter(this.RewriteIfNeeded(module, type.GenericParameters[i])); + + return type; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/Rewriters/VirtualEntryCallRemover.cs b/src/StardewModdingAPI/Framework/ModLoading/Rewriters/VirtualEntryCallRemover.cs new file mode 100644 index 00000000..322a7df1 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/Rewriters/VirtualEntryCallRemover.cs @@ -0,0 +1,90 @@ +using System; +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters +{ + /// <summary>Rewrites virtual calls to the <see cref="Mod.Entry"/> method.</summary> + internal class VirtualEntryCallRemover : IInstructionHandler + { + /********* + ** Properties + *********/ + /// <summary>The type containing the method.</summary> + private readonly Type ToType; + + /// <summary>The name of the method.</summary> + private readonly string MethodName; + + + /********* + ** Accessors + *********/ + /// <summary>A brief noun phrase indicating what the instruction finder matches.</summary> + public string NounPhrase { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public VirtualEntryCallRemover() + { + this.ToType = typeof(Mod); + this.MethodName = nameof(Mod.Entry); + this.NounPhrase = $"{this.ToType.Name}::{this.MethodName}"; + } + + /// <summary>Perform the predefined logic for a method if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="method">The method definition containing the instruction.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + return InstructionHandleResult.None; + } + + /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="cil">The CIL processor.</param> + /// <param name="instruction">The instruction to handle.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + if (!this.IsMatch(instruction)) + return InstructionHandleResult.None; + + // get instructions comprising method call + int index = cil.Body.Instructions.IndexOf(instruction); + Instruction loadArg0 = cil.Body.Instructions[index - 2]; + Instruction loadArg1 = cil.Body.Instructions[index - 1]; + if (loadArg0.OpCode != OpCodes.Ldarg_0) + throw new InvalidOperationException($"Unexpected instruction sequence while removing virtual {this.ToType.Name}.{this.MethodName} call: found {loadArg0.OpCode.Name} instead of {OpCodes.Ldarg_0.Name}"); + if (loadArg1.OpCode != OpCodes.Ldarg_1) + throw new InvalidOperationException($"Unexpected instruction sequence while removing virtual {this.ToType.Name}.{this.MethodName} call: found {loadArg1.OpCode.Name} instead of {OpCodes.Ldarg_1.Name}"); + + // remove method call + cil.Remove(loadArg0); + cil.Remove(loadArg1); + cil.Remove(instruction); + return InstructionHandleResult.Rewritten; + } + + + /********* + ** Protected methods + *********/ + /// <summary>Get whether a CIL instruction matches.</summary> + /// <param name="instruction">The IL instruction.</param> + protected bool IsMatch(Instruction instruction) + { + MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + return + methodRef != null + && methodRef.DeclaringType.FullName == this.ToType.FullName + && methodRef.Name == this.MethodName; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModRegistry.cs b/src/StardewModdingAPI/Framework/ModRegistry.cs new file mode 100644 index 00000000..9dde7a20 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModRegistry.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Tracks the installed mods.</summary> + internal class ModRegistry + { + /********* + ** Properties + *********/ + /// <summary>The registered mod data.</summary> + private readonly List<IModMetadata> Mods = new List<IModMetadata>(); + + /// <summary>The friendly mod names treated as deprecation warning sources (assembly full name => mod name).</summary> + private readonly IDictionary<string, string> ModNamesByAssembly = new Dictionary<string, string>(); + + + /********* + ** Public methods + *********/ + /**** + ** Basic metadata + ****/ + /// <summary>Get metadata for all loaded mods.</summary> + public IEnumerable<IManifest> GetAll() + { + return this.Mods.Select(p => p.Manifest); + } + + /// <summary>Get metadata for a loaded mod.</summary> + /// <param name="uniqueID">The mod's unique ID.</param> + /// <returns>Returns the matching mod's metadata, or <c>null</c> if not found.</returns> + public IManifest Get(string uniqueID) + { + // normalise search ID + if (string.IsNullOrWhiteSpace(uniqueID)) + return null; + uniqueID = uniqueID.Trim(); + + // find match + return this.GetAll().FirstOrDefault(p => p.UniqueID.Trim().Equals(uniqueID, StringComparison.InvariantCultureIgnoreCase)); + } + + /// <summary>Get whether a mod has been loaded.</summary> + /// <param name="uniqueID">The mod's unique ID.</param> + public bool IsLoaded(string uniqueID) + { + return this.Get(uniqueID) != null; + } + + /**** + ** Mod data + ****/ + /// <summary>Register a mod as a possible source of deprecation warnings.</summary> + /// <param name="metadata">The mod metadata.</param> + public void Add(IModMetadata metadata) + { + this.Mods.Add(metadata); + this.ModNamesByAssembly[metadata.Mod.GetType().Assembly.FullName] = metadata.DisplayName; + } + + /// <summary>Get all enabled mods.</summary> + public IEnumerable<IModMetadata> GetMods() + { + return (from mod in this.Mods select mod); + } + + /// <summary>Get the friendly mod name which defines a type.</summary> + /// <param name="type">The type to check.</param> + /// <returns>Returns the mod name, or <c>null</c> if the type isn't part of a known mod.</returns> + public string GetModFrom(Type type) + { + // null + if (type == null) + return null; + + // known type + string assemblyName = type.Assembly.FullName; + if (this.ModNamesByAssembly.ContainsKey(assemblyName)) + return this.ModNamesByAssembly[assemblyName]; + + // not found + return null; + } + + /// <summary>Get the friendly name for the closest assembly registered as a source of deprecation warnings.</summary> + /// <returns>Returns the source name, or <c>null</c> if no registered assemblies were found.</returns> + public string GetModFromStack() + { + // get stack frames + StackTrace stack = new StackTrace(); + StackFrame[] frames = stack.GetFrames(); + if (frames == null) + return null; + + // search stack for a source assembly + foreach (StackFrame frame in frames) + { + MethodBase method = frame.GetMethod(); + string name = this.GetModFrom(method.ReflectedType); + if (name != null) + return name; + } + + // no known assembly found + return null; + } + } +} diff --git a/src/StardewModdingAPI/Framework/Models/Manifest.cs b/src/StardewModdingAPI/Framework/Models/Manifest.cs new file mode 100644 index 00000000..b85787e5 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Models/Manifest.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using StardewModdingAPI.Framework.Serialisation; + +namespace StardewModdingAPI.Framework.Models +{ + /// <summary>A manifest which describes a mod for SMAPI.</summary> + internal class Manifest : IManifest + { + /********* + ** Accessors + *********/ + /// <summary>The mod name.</summary> + public string Name { get; set; } + + /// <summary>A brief description of the mod.</summary> + public string Description { get; set; } + + /// <summary>The mod author's name.</summary> + public string Author { get; set; } + + /// <summary>The mod version.</summary> + [JsonConverter(typeof(SFieldConverter))] + public ISemanticVersion Version { get; set; } + + /// <summary>The minimum SMAPI version required by this mod, if any.</summary> + [JsonConverter(typeof(SFieldConverter))] + public ISemanticVersion MinimumApiVersion { get; set; } + + /// <summary>The name of the DLL in the directory that has the <see cref="IMod.Entry"/> method.</summary> + public string EntryDll { get; set; } + + /// <summary>The other mods that must be loaded before this mod.</summary> + [JsonConverter(typeof(SFieldConverter))] + public IManifestDependency[] Dependencies { get; set; } + + /// <summary>The namespaced mod IDs to query for updates (like <c>Nexus:541</c>).</summary> + public string[] UpdateKeys { get; set; } + + /// <summary>The unique mod ID.</summary> + public string UniqueID { get; set; } + + /// <summary>Any manifest fields which didn't match a valid field.</summary> + [JsonExtensionData] + public IDictionary<string, object> ExtraFields { get; set; } + } +} diff --git a/src/StardewModdingAPI/Framework/Models/ManifestDependency.cs b/src/StardewModdingAPI/Framework/Models/ManifestDependency.cs new file mode 100644 index 00000000..5646b335 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Models/ManifestDependency.cs @@ -0,0 +1,34 @@ +namespace StardewModdingAPI.Framework.Models +{ + /// <summary>A mod dependency listed in a mod manifest.</summary> + internal class ManifestDependency : IManifestDependency + { + /********* + ** Accessors + *********/ + /// <summary>The unique mod ID to require.</summary> + public string UniqueID { get; set; } + + /// <summary>The minimum required version (if any).</summary> + public ISemanticVersion MinimumVersion { get; set; } + + /// <summary>Whether the dependency must be installed to use the mod.</summary> + public bool IsRequired { get; set; } + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="uniqueID">The unique mod ID to require.</param> + /// <param name="minimumVersion">The minimum required version (if any).</param> + /// <param name="required">Whether the dependency must be installed to use the mod.</param> + public ManifestDependency(string uniqueID, string minimumVersion, bool required = true) + { + this.UniqueID = uniqueID; + this.MinimumVersion = !string.IsNullOrWhiteSpace(minimumVersion) + ? new SemanticVersion(minimumVersion) + : null; + this.IsRequired = required; + } + } +} diff --git a/src/StardewModdingAPI/Framework/Models/ModCompatibility.cs b/src/StardewModdingAPI/Framework/Models/ModCompatibility.cs new file mode 100644 index 00000000..54737e6c --- /dev/null +++ b/src/StardewModdingAPI/Framework/Models/ModCompatibility.cs @@ -0,0 +1,55 @@ +using System; + +namespace StardewModdingAPI.Framework.Models +{ + /// <summary>Specifies the compatibility of a given mod version range.</summary> + internal class ModCompatibility + { + /********* + ** Accessors + *********/ + /// <summary>The lowest version in the range, or <c>null</c> for all past versions.</summary> + public ISemanticVersion LowerVersion { get; } + + /// <summary>The highest version in the range, or <c>null</c> for all future versions.</summary> + public ISemanticVersion UpperVersion { get; } + + /// <summary>The mod compatibility.</summary> + public ModStatus Status { get; } + + /// <summary>The reason phrase to show in log output, or <c>null</c> to use the default value.</summary> + /// <example>For example, "this version is incompatible with the latest version of the game".</example> + public string ReasonPhrase { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="versionRange">A version range, which consists of two version strings separated by a '~' character. Either side can be left blank for an unbounded range.</param> + /// <param name="status">The mod compatibility.</param> + /// <param name="reasonPhrase">The reason phrase to show in log output, or <c>null</c> to use the default value.</param> + public ModCompatibility(string versionRange, ModStatus status, string reasonPhrase) + { + // extract version strings + string[] versions = versionRange.Split('~'); + if (versions.Length != 2) + throw new FormatException($"Could not parse '{versionRange}' as a version range. It must have two version strings separated by a '~' character (either side can be left blank for an unbounded range)."); + + // initialise + this.LowerVersion = !string.IsNullOrWhiteSpace(versions[0]) ? new SemanticVersion(versions[0]) : null; + this.UpperVersion = !string.IsNullOrWhiteSpace(versions[1]) ? new SemanticVersion(versions[1]) : null; + this.Status = status; + this.ReasonPhrase = reasonPhrase; + } + + /// <summary>Get whether a given version is contained within this compatibility range.</summary> + /// <param name="version">The version to check.</param> + public bool MatchesVersion(ISemanticVersion version) + { + return + (this.LowerVersion == null || !version.IsOlderThan(this.LowerVersion)) + && (this.UpperVersion == null || !version.IsNewerThan(this.UpperVersion)); + } + } +} diff --git a/src/StardewModdingAPI/Framework/Models/ModDataID.cs b/src/StardewModdingAPI/Framework/Models/ModDataID.cs new file mode 100644 index 00000000..d19434fa --- /dev/null +++ b/src/StardewModdingAPI/Framework/Models/ModDataID.cs @@ -0,0 +1,85 @@ +using System; +using System.Linq; +using Newtonsoft.Json; + +namespace StardewModdingAPI.Framework.Models +{ + /// <summary>Uniquely identifies a mod in SMAPI's internal data.</summary> + /// <remarks> + /// This represents a custom format which uniquely identifies a mod across all versions, even + /// if its field values change or it doesn't specify a unique ID. This is mapped to a string + /// with the following format: + /// + /// 1. If the mod's identifier changed over time, multiple variants can be separated by the <c>|</c> + /// character. + /// 2. Each variant can take one of two forms: + /// - A simple string matching the mod's UniqueID value. + /// - A JSON structure containing any of three manifest fields (ID, Name, and Author) to match. + /// </remarks> + internal class ModDataID + { + /********* + ** Properties + *********/ + /// <summary>The unique sets of field values which identify this mod.</summary> + private readonly FieldSnapshot[] Snapshots; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public ModDataID() { } + + /// <summary>Construct an instance.</summary> + /// <param name="data">The mod identifier string (see remarks on <see cref="ModDataID"/>).</param> + public ModDataID(string data) + { + this.Snapshots = + ( + from string part in data.Split('|') + let str = part.Trim() + select str.StartsWith("{") + ? JsonConvert.DeserializeObject<FieldSnapshot>(str) + : new FieldSnapshot { ID = str } + ) + .ToArray(); + } + + /// <summary>Get whether this ID matches a given mod manifest.</summary> + /// <param name="id">The mod's unique ID, or a substitute ID if it isn't set in the manifest.</param> + /// <param name="manifest">The manifest to check.</param> + public bool Matches(string id, IManifest manifest) + { + return this.Snapshots.Any(snapshot => + snapshot.ID.Equals(id, StringComparison.InvariantCultureIgnoreCase) + && ( + snapshot.Author == null + || snapshot.Author.Equals(manifest.Author, StringComparison.InvariantCultureIgnoreCase) + || (manifest.ExtraFields.ContainsKey("Authour") && snapshot.Author.Equals(manifest.ExtraFields["Authour"].ToString(), StringComparison.InvariantCultureIgnoreCase)) + ) + && (snapshot.Name == null || snapshot.Name.Equals(manifest.Name, StringComparison.InvariantCultureIgnoreCase)) + ); + } + + + /********* + ** Private models + *********/ + /// <summary>A unique set of fields which identifies the mod.</summary> + private class FieldSnapshot + { + /********* + ** Accessors + *********/ + /// <summary>The unique mod ID.</summary> + public string ID { get; set; } + + /// <summary>The mod name, or <c>null</c> to ignore the mod name.</summary> + public string Name { get; set; } + + /// <summary>The author name, or <c>null</c> to ignore the author.</summary> + public string Author { get; set; } + } + } +} diff --git a/src/StardewModdingAPI/Framework/Models/ModDataRecord.cs b/src/StardewModdingAPI/Framework/Models/ModDataRecord.cs new file mode 100644 index 00000000..c6a12188 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Models/ModDataRecord.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using StardewModdingAPI.Framework.Serialisation; + +namespace StardewModdingAPI.Framework.Models +{ + /// <summary>Metadata about a mod from SMAPI's internal data.</summary> + internal class ModDataRecord + { + /********* + ** Accessors + *********/ + /// <summary>The unique mod identifier.</summary> + [JsonConverter(typeof(SFieldConverter))] + public ModDataID ID { get; set; } + + /// <summary>A value to inject into <see cref="IManifest.UpdateKeys"/> field if it's not already set.</summary> + public string[] UpdateKeys { get; set; } + + /// <summary>The URL where the player can get an unofficial or alternative version of the mod if the official version isn't compatible.</summary> + public string AlternativeUrl { get; set; } + + /// <summary>The compatibility of given mod versions (if any).</summary> + [JsonConverter(typeof(SFieldConverter))] + public ModCompatibility[] Compatibility { get; set; } = new ModCompatibility[0]; + + /// <summary>Map local versions to a semantic version for update checks.</summary> + public IDictionary<string, string> MapLocalVersions { get; set; } = new Dictionary<string, string>(); + + /// <summary>Map remote versions to a semantic version for update checks.</summary> + public IDictionary<string, string> MapRemoteVersions { get; set; } = new Dictionary<string, string>(); + + + /********* + ** Public methods + *********/ + /// <summary>Get the compatibility record for a given version, if any.</summary> + /// <param name="version">The mod version to check.</param> + public ModCompatibility GetCompatibility(ISemanticVersion version) + { + return this.Compatibility.FirstOrDefault(p => p.MatchesVersion(version)); + } + + /// <summary>Get a semantic local version for update checks.</summary> + /// <param name="version">The local version to normalise.</param> + public string GetLocalVersionForUpdateChecks(string version) + { + return this.MapLocalVersions != null && this.MapLocalVersions.TryGetValue(version, out string newVersion) + ? newVersion + : version; + } + + /// <summary>Get a semantic remote version for update checks.</summary> + /// <param name="version">The remote version to normalise.</param> + public string GetRemoteVersionForUpdateChecks(string version) + { + return this.MapRemoteVersions != null && this.MapRemoteVersions.TryGetValue(version, out string newVersion) + ? newVersion + : version; + } + } +} diff --git a/src/StardewModdingAPI/Framework/Models/ModStatus.cs b/src/StardewModdingAPI/Framework/Models/ModStatus.cs new file mode 100644 index 00000000..343ccb7e --- /dev/null +++ b/src/StardewModdingAPI/Framework/Models/ModStatus.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI.Framework.Models +{ + /// <summary>Indicates how SMAPI should treat a mod.</summary> + internal enum ModStatus + { + /// <summary>Don't override the status.</summary> + None, + + /// <summary>The mod is obsolete and shouldn't be used, regardless of version.</summary> + Obsolete, + + /// <summary>Assume the mod is not compatible, even if SMAPI doesn't detect any incompatible code.</summary> + AssumeBroken, + + /// <summary>Assume the mod is compatible, even if SMAPI detects incompatible code.</summary> + AssumeCompatible + } +} diff --git a/src/StardewModdingAPI/Framework/Models/SConfig.cs b/src/StardewModdingAPI/Framework/Models/SConfig.cs new file mode 100644 index 00000000..401e1a3a --- /dev/null +++ b/src/StardewModdingAPI/Framework/Models/SConfig.cs @@ -0,0 +1,27 @@ +namespace StardewModdingAPI.Framework.Models +{ + /// <summary>The SMAPI configuration settings.</summary> + internal class SConfig + { + /******** + ** Accessors + ********/ + /// <summary>Whether to enable development features.</summary> + public bool DeveloperMode { get; set; } + + /// <summary>Whether to check for newer versions of SMAPI and mods on startup.</summary> + public bool CheckForUpdates { get; set; } + + /// <summary>SMAPI's GitHub project name, used to perform update checks.</summary> + public string GitHubProjectName { get; set; } + + /// <summary>The base URL for SMAPI's web API, used to perform update checks.</summary> + public string WebApiBaseUrl { get; set; } + + /// <summary>Whether SMAPI should log more information about the game context.</summary> + public bool VerboseLogging { get; set; } + + /// <summary>Extra metadata about mods.</summary> + public ModDataRecord[] ModData { get; set; } + } +} diff --git a/src/StardewModdingAPI/Framework/Monitor.cs b/src/StardewModdingAPI/Framework/Monitor.cs new file mode 100644 index 00000000..bf338386 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Monitor.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using StardewModdingAPI.Framework.Logging; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Encapsulates monitoring and logic for a given module.</summary> + internal class Monitor : IMonitor + { + /********* + ** Properties + *********/ + /// <summary>The name of the module which logs messages using this instance.</summary> + private readonly string Source; + + /// <summary>Manages access to the console output.</summary> + private readonly ConsoleInterceptionManager ConsoleManager; + + /// <summary>The log file to which to write messages.</summary> + private readonly LogFileManager LogFile; + + /// <summary>The maximum length of the <see cref="LogLevel"/> values.</summary> + private static readonly int MaxLevelLength = (from level in Enum.GetValues(typeof(LogLevel)).Cast<LogLevel>() select level.ToString().Length).Max(); + + /// <summary>The console text color for each log level.</summary> + private static readonly IDictionary<LogLevel, ConsoleColor> Colors = Monitor.GetConsoleColorScheme(); + + /// <summary>Propagates notification that SMAPI should exit.</summary> + private readonly CancellationTokenSource ExitTokenSource; + + + /********* + ** Accessors + *********/ + /// <summary>Whether SMAPI is aborting. Mods don't need to worry about this unless they have background tasks.</summary> + public bool IsExiting => this.ExitTokenSource.IsCancellationRequested; + + /// <summary>Whether to show the full log stamps (with time/level/logger) in the console. If false, shows a simplified stamp with only the logger.</summary> + internal bool ShowFullStampInConsole { get; set; } + + /// <summary>Whether to show trace messages in the console.</summary> + internal bool ShowTraceInConsole { get; set; } + + /// <summary>Whether to write anything to the console. This should be disabled if no console is available.</summary> + internal bool WriteToConsole { get; set; } = true; + + /// <summary>Whether to write anything to the log file. This should almost always be enabled.</summary> + internal bool WriteToFile { get; set; } = true; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="source">The name of the module which logs messages using this instance.</param> + /// <param name="consoleManager">Manages access to the console output.</param> + /// <param name="logFile">The log file to which to write messages.</param> + /// <param name="exitTokenSource">Propagates notification that SMAPI should exit.</param> + public Monitor(string source, ConsoleInterceptionManager consoleManager, LogFileManager logFile, CancellationTokenSource exitTokenSource) + { + // validate + if (string.IsNullOrWhiteSpace(source)) + throw new ArgumentException("The log source cannot be empty."); + + // initialise + this.Source = source; + this.LogFile = logFile ?? throw new ArgumentNullException(nameof(logFile), "The log file manager cannot be null."); + this.ConsoleManager = consoleManager; + this.ExitTokenSource = exitTokenSource; + } + + /// <summary>Log a message for the player or developer.</summary> + /// <param name="message">The message to log.</param> + /// <param name="level">The log severity level.</param> + public void Log(string message, LogLevel level = LogLevel.Debug) + { + this.LogImpl(this.Source, message, level, Monitor.Colors[level]); + } + + /// <summary>Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</summary> + /// <param name="reason">The reason for the shutdown.</param> + public void ExitGameImmediately(string reason) + { + this.LogFatal($"{this.Source} requested an immediate game shutdown: {reason}"); + this.ExitTokenSource.Cancel(); + } + + /// <summary>Write a newline to the console and log file.</summary> + internal void Newline() + { + if (this.WriteToConsole) + this.ConsoleManager.ExclusiveWriteWithoutInterception(Console.WriteLine); + if (this.WriteToFile) + this.LogFile.WriteLine(""); + } + + + /********* + ** Private methods + *********/ + /// <summary>Log a fatal error message.</summary> + /// <param name="message">The message to log.</param> + private void LogFatal(string message) + { + this.LogImpl(this.Source, message, LogLevel.Error, ConsoleColor.White, background: ConsoleColor.Red); + } + + /// <summary>Write a message line to the log.</summary> + /// <param name="source">The name of the mod logging the message.</param> + /// <param name="message">The message to log.</param> + /// <param name="level">The log level.</param> + /// <param name="color">The console foreground color.</param> + /// <param name="background">The console background color (or <c>null</c> to leave it as-is).</param> + private void LogImpl(string source, string message, LogLevel level, ConsoleColor color, ConsoleColor? background = null) + { + // generate message + string levelStr = level.ToString().ToUpper().PadRight(Monitor.MaxLevelLength); + + string fullMessage = $"[{DateTime.Now:HH:mm:ss} {levelStr} {source}] {message}"; + string consoleMessage = this.ShowFullStampInConsole ? fullMessage : $"[{source}] {message}"; + + // write to console + if (this.WriteToConsole && (this.ShowTraceInConsole || level != LogLevel.Trace)) + { + this.ConsoleManager.ExclusiveWriteWithoutInterception(() => + { + if (this.ConsoleManager.SupportsColor) + { + if (background.HasValue) + Console.BackgroundColor = background.Value; + Console.ForegroundColor = color; + Console.WriteLine(consoleMessage); + Console.ResetColor(); + } + else + Console.WriteLine(consoleMessage); + }); + } + + // write to log file + if (this.WriteToFile) + this.LogFile.WriteLine(fullMessage); + } + + /// <summary>Get the color scheme to use for the current console.</summary> + private static IDictionary<LogLevel, ConsoleColor> GetConsoleColorScheme() + { + // scheme for dark console background + if (Monitor.IsDark(Console.BackgroundColor)) + { + return new Dictionary<LogLevel, ConsoleColor> + { + [LogLevel.Trace] = ConsoleColor.DarkGray, + [LogLevel.Debug] = ConsoleColor.DarkGray, + [LogLevel.Info] = ConsoleColor.White, + [LogLevel.Warn] = ConsoleColor.Yellow, + [LogLevel.Error] = ConsoleColor.Red, + [LogLevel.Alert] = ConsoleColor.Magenta + }; + } + + // scheme for light console background + return new Dictionary<LogLevel, ConsoleColor> + { + [LogLevel.Trace] = ConsoleColor.DarkGray, + [LogLevel.Debug] = ConsoleColor.DarkGray, + [LogLevel.Info] = ConsoleColor.Black, + [LogLevel.Warn] = ConsoleColor.DarkYellow, + [LogLevel.Error] = ConsoleColor.Red, + [LogLevel.Alert] = ConsoleColor.DarkMagenta + }; + } + + /// <summary>Get whether a console color should be considered dark, which is subjectively defined as 'white looks better than black on this text'.</summary> + /// <param name="color">The color to check.</param> + private static bool IsDark(ConsoleColor color) + { + switch (color) + { + case ConsoleColor.Black: + case ConsoleColor.Blue: + case ConsoleColor.DarkBlue: + case ConsoleColor.DarkRed: + case ConsoleColor.Red: + return true; + + default: + return false; + } + } + } +} diff --git a/src/StardewModdingAPI/Framework/Reflection/CacheEntry.cs b/src/StardewModdingAPI/Framework/Reflection/CacheEntry.cs new file mode 100644 index 00000000..30faca37 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Reflection/CacheEntry.cs @@ -0,0 +1,30 @@ +using System.Reflection; + +namespace StardewModdingAPI.Framework.Reflection +{ + /// <summary>A cached member reflection result.</summary> + internal struct CacheEntry + { + /********* + ** Accessors + *********/ + /// <summary>Whether the lookup found a valid match.</summary> + public bool IsValid; + + /// <summary>The reflection data for this member (or <c>null</c> if invalid).</summary> + public MemberInfo MemberInfo; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="isValid">Whether the lookup found a valid match.</param> + /// <param name="memberInfo">The reflection data for this member (or <c>null</c> if invalid).</param> + public CacheEntry(bool isValid, MemberInfo memberInfo) + { + this.IsValid = isValid; + this.MemberInfo = memberInfo; + } + } +} diff --git a/src/StardewModdingAPI/Framework/Reflection/PrivateField.cs b/src/StardewModdingAPI/Framework/Reflection/PrivateField.cs new file mode 100644 index 00000000..0bf45969 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Reflection/PrivateField.cs @@ -0,0 +1,93 @@ +using System; +using System.Reflection; + +namespace StardewModdingAPI.Framework.Reflection +{ + /// <summary>A private field obtained through reflection.</summary> + /// <typeparam name="TValue">The field value type.</typeparam> + internal class PrivateField<TValue> : IPrivateField<TValue> + { + /********* + ** Properties + *********/ + /// <summary>The type that has the field.</summary> + private readonly Type ParentType; + + /// <summary>The object that has the instance field (if applicable).</summary> + private readonly object Parent; + + /// <summary>The display name shown in error messages.</summary> + private string DisplayName => $"{this.ParentType.FullName}::{this.FieldInfo.Name}"; + + + /********* + ** Accessors + *********/ + /// <summary>The reflection metadata.</summary> + public FieldInfo FieldInfo { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="parentType">The type that has the field.</param> + /// <param name="obj">The object that has the instance field (if applicable).</param> + /// <param name="field">The reflection metadata.</param> + /// <param name="isStatic">Whether the field is static.</param> + /// <exception cref="ArgumentNullException">The <paramref name="parentType"/> or <paramref name="field"/> is null.</exception> + /// <exception cref="ArgumentException">The <paramref name="obj"/> is null for a non-static field, or not null for a static field.</exception> + public PrivateField(Type parentType, object obj, FieldInfo field, bool isStatic) + { + // validate + if (parentType == null) + throw new ArgumentNullException(nameof(parentType)); + if (field == null) + throw new ArgumentNullException(nameof(field)); + if (isStatic && obj != null) + throw new ArgumentException("A static field cannot have an object instance."); + if (!isStatic && obj == null) + throw new ArgumentException("A non-static field must have an object instance."); + + // save + this.ParentType = parentType; + this.Parent = obj; + this.FieldInfo = field; + } + + /// <summary>Get the field value.</summary> + public TValue GetValue() + { + try + { + return (TValue)this.FieldInfo.GetValue(this.Parent); + } + catch (InvalidCastException) + { + throw new InvalidCastException($"Can't convert the private {this.DisplayName} field from {this.FieldInfo.FieldType.FullName} to {typeof(TValue).FullName}."); + } + catch (Exception ex) + { + throw new Exception($"Couldn't get the value of the private {this.DisplayName} field", ex); + } + } + + /// <summary>Set the field value.</summary> + //// <param name="value">The value to set.</param> + public void SetValue(TValue value) + { + try + { + this.FieldInfo.SetValue(this.Parent, value); + } + catch (InvalidCastException) + { + throw new InvalidCastException($"Can't assign the private {this.DisplayName} field a {typeof(TValue).FullName} value, must be compatible with {this.FieldInfo.FieldType.FullName}."); + } + catch (Exception ex) + { + throw new Exception($"Couldn't set the value of the private {this.DisplayName} field", ex); + } + } + } +} diff --git a/src/StardewModdingAPI/Framework/Reflection/PrivateMethod.cs b/src/StardewModdingAPI/Framework/Reflection/PrivateMethod.cs new file mode 100644 index 00000000..ba2374f4 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Reflection/PrivateMethod.cs @@ -0,0 +1,99 @@ +using System; +using System.Reflection; + +namespace StardewModdingAPI.Framework.Reflection +{ + /// <summary>A private method obtained through reflection.</summary> + internal class PrivateMethod : IPrivateMethod + { + /********* + ** Properties + *********/ + /// <summary>The type that has the method.</summary> + private readonly Type ParentType; + + /// <summary>The object that has the instance method (if applicable).</summary> + private readonly object Parent; + + /// <summary>The display name shown in error messages.</summary> + private string DisplayName => $"{this.ParentType.FullName}::{this.MethodInfo.Name}"; + + + /********* + ** Accessors + *********/ + /// <summary>The reflection metadata.</summary> + public MethodInfo MethodInfo { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="parentType">The type that has the method.</param> + /// <param name="obj">The object that has the instance method(if applicable).</param> + /// <param name="method">The reflection metadata.</param> + /// <param name="isStatic">Whether the field is static.</param> + /// <exception cref="ArgumentNullException">The <paramref name="parentType"/> or <paramref name="method"/> is null.</exception> + /// <exception cref="ArgumentException">The <paramref name="obj"/> is null for a non-static method, or not null for a static method.</exception> + public PrivateMethod(Type parentType, object obj, MethodInfo method, bool isStatic) + { + // validate + if (parentType == null) + throw new ArgumentNullException(nameof(parentType)); + if (method == null) + throw new ArgumentNullException(nameof(method)); + if (isStatic && obj != null) + throw new ArgumentException("A static method cannot have an object instance."); + if (!isStatic && obj == null) + throw new ArgumentException("A non-static method must have an object instance."); + + // save + this.ParentType = parentType; + this.Parent = obj; + this.MethodInfo = method; + } + + /// <summary>Invoke the method.</summary> + /// <typeparam name="TValue">The return type.</typeparam> + /// <param name="arguments">The method arguments to pass in.</param> + public TValue Invoke<TValue>(params object[] arguments) + { + // invoke method + object result; + try + { + result = this.MethodInfo.Invoke(this.Parent, arguments); + } + catch (Exception ex) + { + throw new Exception($"Couldn't invoke the private {this.DisplayName} field", ex); + } + + // cast return value + try + { + return (TValue)result; + } + catch (InvalidCastException) + { + throw new InvalidCastException($"Can't convert the return value of the private {this.DisplayName} method from {this.MethodInfo.ReturnType.FullName} to {typeof(TValue).FullName}."); + } + } + + /// <summary>Invoke the method.</summary> + /// <param name="arguments">The method arguments to pass in.</param> + public void Invoke(params object[] arguments) + { + // invoke method + try + { + this.MethodInfo.Invoke(this.Parent, arguments); + } + catch (Exception ex) + { + throw new Exception($"Couldn't invoke the private {this.DisplayName} field", ex); + } + } + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Framework/Reflection/PrivateProperty.cs b/src/StardewModdingAPI/Framework/Reflection/PrivateProperty.cs new file mode 100644 index 00000000..08204b7e --- /dev/null +++ b/src/StardewModdingAPI/Framework/Reflection/PrivateProperty.cs @@ -0,0 +1,93 @@ +using System; +using System.Reflection; + +namespace StardewModdingAPI.Framework.Reflection +{ + /// <summary>A private property obtained through reflection.</summary> + /// <typeparam name="TValue">The property value type.</typeparam> + internal class PrivateProperty<TValue> : IPrivateProperty<TValue> + { + /********* + ** Properties + *********/ + /// <summary>The type that has the field.</summary> + private readonly Type ParentType; + + /// <summary>The object that has the instance field (if applicable).</summary> + private readonly object Parent; + + /// <summary>The display name shown in error messages.</summary> + private string DisplayName => $"{this.ParentType.FullName}::{this.PropertyInfo.Name}"; + + + /********* + ** Accessors + *********/ + /// <summary>The reflection metadata.</summary> + public PropertyInfo PropertyInfo { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="parentType">The type that has the field.</param> + /// <param name="obj">The object that has the instance field (if applicable).</param> + /// <param name="property">The reflection metadata.</param> + /// <param name="isStatic">Whether the field is static.</param> + /// <exception cref="ArgumentNullException">The <paramref name="parentType"/> or <paramref name="property"/> is null.</exception> + /// <exception cref="ArgumentException">The <paramref name="obj"/> is null for a non-static field, or not null for a static field.</exception> + public PrivateProperty(Type parentType, object obj, PropertyInfo property, bool isStatic) + { + // validate + if (parentType == null) + throw new ArgumentNullException(nameof(parentType)); + if (property == null) + throw new ArgumentNullException(nameof(property)); + if (isStatic && obj != null) + throw new ArgumentException("A static property cannot have an object instance."); + if (!isStatic && obj == null) + throw new ArgumentException("A non-static property must have an object instance."); + + // save + this.ParentType = parentType; + this.Parent = obj; + this.PropertyInfo = property; + } + + /// <summary>Get the property value.</summary> + public TValue GetValue() + { + try + { + return (TValue)this.PropertyInfo.GetValue(this.Parent); + } + catch (InvalidCastException) + { + throw new InvalidCastException($"Can't convert the private {this.DisplayName} property from {this.PropertyInfo.PropertyType.FullName} to {typeof(TValue).FullName}."); + } + catch (Exception ex) + { + throw new Exception($"Couldn't get the value of the private {this.DisplayName} property", ex); + } + } + + /// <summary>Set the property value.</summary> + //// <param name="value">The value to set.</param> + public void SetValue(TValue value) + { + try + { + this.PropertyInfo.SetValue(this.Parent, value); + } + catch (InvalidCastException) + { + throw new InvalidCastException($"Can't assign the private {this.DisplayName} property a {typeof(TValue).FullName} value, must be compatible with {this.PropertyInfo.PropertyType.FullName}."); + } + catch (Exception ex) + { + throw new Exception($"Couldn't set the value of the private {this.DisplayName} property", ex); + } + } + } +} diff --git a/src/StardewModdingAPI/Framework/Reflection/Reflector.cs b/src/StardewModdingAPI/Framework/Reflection/Reflector.cs new file mode 100644 index 00000000..5c2d90fa --- /dev/null +++ b/src/StardewModdingAPI/Framework/Reflection/Reflector.cs @@ -0,0 +1,276 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Runtime.Caching; + +namespace StardewModdingAPI.Framework.Reflection +{ + /// <summary>Provides helper methods for accessing private game code.</summary> + /// <remarks>This implementation searches up the type hierarchy, and caches the reflected fields and methods with a sliding expiry (to optimise performance without unnecessary memory usage).</remarks> + internal class Reflector + { + /********* + ** Properties + *********/ + /// <summary>The cached fields and methods found via reflection.</summary> + private readonly MemoryCache Cache = new MemoryCache(typeof(Reflector).FullName); + + /// <summary>The sliding cache expiration time.</summary> + private readonly TimeSpan SlidingCacheExpiry = TimeSpan.FromMinutes(5); + + + /********* + ** Public methods + *********/ + /**** + ** Fields + ****/ + /// <summary>Get a private instance field.</summary> + /// <typeparam name="TValue">The field type.</typeparam> + /// <param name="obj">The object which has the field.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + /// <returns>Returns the field wrapper, or <c>null</c> if the field doesn't exist and <paramref name="required"/> is <c>false</c>.</returns> + public IPrivateField<TValue> GetPrivateField<TValue>(object obj, string name, bool required = true) + { + // validate + if (obj == null) + throw new ArgumentNullException(nameof(obj), "Can't get a private instance field from a null object."); + + // get field from hierarchy + IPrivateField<TValue> field = this.GetFieldFromHierarchy<TValue>(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic); + if (required && field == null) + throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a private '{name}' instance field."); + return field; + } + + /// <summary>Get a private static field.</summary> + /// <typeparam name="TValue">The field type.</typeparam> + /// <param name="type">The type which has the field.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + public IPrivateField<TValue> GetPrivateField<TValue>(Type type, string name, bool required = true) + { + // get field from hierarchy + IPrivateField<TValue> field = this.GetFieldFromHierarchy<TValue>(type, null, name, BindingFlags.NonPublic | BindingFlags.Static); + if (required && field == null) + throw new InvalidOperationException($"The {type.FullName} object doesn't have a private '{name}' static field."); + return field; + } + + /**** + ** Properties + ****/ + /// <summary>Get a private instance property.</summary> + /// <typeparam name="TValue">The property type.</typeparam> + /// <param name="obj">The object which has the property.</param> + /// <param name="name">The property name.</param> + /// <param name="required">Whether to throw an exception if the private property is not found.</param> + public IPrivateProperty<TValue> GetPrivateProperty<TValue>(object obj, string name, bool required = true) + { + // validate + if (obj == null) + throw new ArgumentNullException(nameof(obj), "Can't get a private instance property from a null object."); + + // get property from hierarchy + IPrivateProperty<TValue> property = this.GetPropertyFromHierarchy<TValue>(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic); + if (required && property == null) + throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a private '{name}' instance property."); + return property; + } + + /// <summary>Get a private static property.</summary> + /// <typeparam name="TValue">The property type.</typeparam> + /// <param name="type">The type which has the property.</param> + /// <param name="name">The property name.</param> + /// <param name="required">Whether to throw an exception if the private property is not found.</param> + public IPrivateProperty<TValue> GetPrivateProperty<TValue>(Type type, string name, bool required = true) + { + // get field from hierarchy + IPrivateProperty<TValue> property = this.GetPropertyFromHierarchy<TValue>(type, null, name, BindingFlags.NonPublic | BindingFlags.Static); + if (required && property == null) + throw new InvalidOperationException($"The {type.FullName} object doesn't have a private '{name}' static property."); + return property; + } + + /**** + ** Methods + ****/ + /// <summary>Get a private instance method.</summary> + /// <param name="obj">The object which has the method.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + public IPrivateMethod GetPrivateMethod(object obj, string name, bool required = true) + { + // validate + if (obj == null) + throw new ArgumentNullException(nameof(obj), "Can't get a private instance method from a null object."); + + // get method from hierarchy + IPrivateMethod method = this.GetMethodFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic); + if (required && method == null) + throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a private '{name}' instance method."); + return method; + } + + /// <summary>Get a private static method.</summary> + /// <param name="type">The type which has the method.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + public IPrivateMethod GetPrivateMethod(Type type, string name, bool required = true) + { + // get method from hierarchy + IPrivateMethod method = this.GetMethodFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Static); + if (required && method == null) + throw new InvalidOperationException($"The {type.FullName} object doesn't have a private '{name}' static method."); + return method; + } + + /**** + ** Methods by signature + ****/ + /// <summary>Get a private instance method.</summary> + /// <param name="obj">The object which has the method.</param> + /// <param name="name">The field name.</param> + /// <param name="argumentTypes">The argument types of the method signature to find.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + public IPrivateMethod GetPrivateMethod(object obj, string name, Type[] argumentTypes, bool required = true) + { + // validate parent + if (obj == null) + throw new ArgumentNullException(nameof(obj), "Can't get a private instance method from a null object."); + + // get method from hierarchy + PrivateMethod method = this.GetMethodFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic, argumentTypes); + if (required && method == null) + throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a private '{name}' instance method with that signature."); + return method; + } + + /// <summary>Get a private static method.</summary> + /// <param name="type">The type which has the method.</param> + /// <param name="name">The field name.</param> + /// <param name="argumentTypes">The argument types of the method signature to find.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + public IPrivateMethod GetPrivateMethod(Type type, string name, Type[] argumentTypes, bool required = true) + { + // get field from hierarchy + PrivateMethod method = this.GetMethodFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Static, argumentTypes); + if (required && method == null) + throw new InvalidOperationException($"The {type.FullName} object doesn't have a private '{name}' static method with that signature."); + return method; + } + + + /********* + ** Private methods + *********/ + /// <summary>Get a field from the type hierarchy.</summary> + /// <typeparam name="TValue">The expected field type.</typeparam> + /// <param name="type">The type which has the field.</param> + /// <param name="obj">The object which has the field.</param> + /// <param name="name">The field name.</param> + /// <param name="bindingFlags">The reflection binding which flags which indicates what type of field to find.</param> + private IPrivateField<TValue> GetFieldFromHierarchy<TValue>(Type type, object obj, string name, BindingFlags bindingFlags) + { + bool isStatic = bindingFlags.HasFlag(BindingFlags.Static); + FieldInfo field = this.GetCached<FieldInfo>($"field::{isStatic}::{type.FullName}::{name}", () => + { + FieldInfo fieldInfo = null; + for (; type != null && fieldInfo == null; type = type.BaseType) + fieldInfo = type.GetField(name, bindingFlags); + return fieldInfo; + }); + + return field != null + ? new PrivateField<TValue>(type, obj, field, isStatic) + : null; + } + + /// <summary>Get a property from the type hierarchy.</summary> + /// <typeparam name="TValue">The expected property type.</typeparam> + /// <param name="type">The type which has the property.</param> + /// <param name="obj">The object which has the property.</param> + /// <param name="name">The property name.</param> + /// <param name="bindingFlags">The reflection binding which flags which indicates what type of property to find.</param> + private IPrivateProperty<TValue> GetPropertyFromHierarchy<TValue>(Type type, object obj, string name, BindingFlags bindingFlags) + { + bool isStatic = bindingFlags.HasFlag(BindingFlags.Static); + PropertyInfo property = this.GetCached<PropertyInfo>($"property::{isStatic}::{type.FullName}::{name}", () => + { + PropertyInfo propertyInfo = null; + for (; type != null && propertyInfo == null; type = type.BaseType) + propertyInfo = type.GetProperty(name, bindingFlags); + return propertyInfo; + }); + + return property != null + ? new PrivateProperty<TValue>(type, obj, property, isStatic) + : null; + } + + /// <summary>Get a method from the type hierarchy.</summary> + /// <param name="type">The type which has the method.</param> + /// <param name="obj">The object which has the method.</param> + /// <param name="name">The method name.</param> + /// <param name="bindingFlags">The reflection binding which flags which indicates what type of method to find.</param> + private IPrivateMethod GetMethodFromHierarchy(Type type, object obj, string name, BindingFlags bindingFlags) + { + bool isStatic = bindingFlags.HasFlag(BindingFlags.Static); + MethodInfo method = this.GetCached($"method::{isStatic}::{type.FullName}::{name}", () => + { + MethodInfo methodInfo = null; + for (; type != null && methodInfo == null; type = type.BaseType) + methodInfo = type.GetMethod(name, bindingFlags); + return methodInfo; + }); + + return method != null + ? new PrivateMethod(type, obj, method, isStatic: bindingFlags.HasFlag(BindingFlags.Static)) + : null; + } + + /// <summary>Get a method from the type hierarchy.</summary> + /// <param name="type">The type which has the method.</param> + /// <param name="obj">The object which has the method.</param> + /// <param name="name">The method name.</param> + /// <param name="bindingFlags">The reflection binding which flags which indicates what type of method to find.</param> + /// <param name="argumentTypes">The argument types of the method signature to find.</param> + private PrivateMethod GetMethodFromHierarchy(Type type, object obj, string name, BindingFlags bindingFlags, Type[] argumentTypes) + { + bool isStatic = bindingFlags.HasFlag(BindingFlags.Static); + MethodInfo method = this.GetCached($"method::{isStatic}::{type.FullName}::{name}({string.Join(",", argumentTypes.Select(p => p.FullName))})", () => + { + MethodInfo methodInfo = null; + for (; type != null && methodInfo == null; type = type.BaseType) + methodInfo = type.GetMethod(name, bindingFlags, null, argumentTypes, null); + return methodInfo; + }); + return method != null + ? new PrivateMethod(type, obj, method, isStatic) + : null; + } + + /// <summary>Get a method or field through the cache.</summary> + /// <typeparam name="TMemberInfo">The expected <see cref="MemberInfo"/> type.</typeparam> + /// <param name="key">The cache key.</param> + /// <param name="fetch">Fetches a new value to cache.</param> + private TMemberInfo GetCached<TMemberInfo>(string key, Func<TMemberInfo> fetch) where TMemberInfo : MemberInfo + { + // get from cache + if (this.Cache.Contains(key)) + { + CacheEntry entry = (CacheEntry)this.Cache[key]; + return entry.IsValid + ? (TMemberInfo)entry.MemberInfo + : default(TMemberInfo); + } + + // fetch & cache new value + TMemberInfo result = fetch(); + CacheEntry cacheEntry = new CacheEntry(result != null, result); + this.Cache.Add(key, cacheEntry, new CacheItemPolicy { SlidingExpiration = this.SlidingCacheExpiry }); + return result; + } + } +} diff --git a/src/StardewModdingAPI/Framework/RequestExitDelegate.cs b/src/StardewModdingAPI/Framework/RequestExitDelegate.cs new file mode 100644 index 00000000..12d0ea0c --- /dev/null +++ b/src/StardewModdingAPI/Framework/RequestExitDelegate.cs @@ -0,0 +1,7 @@ +namespace StardewModdingAPI.Framework +{ + /// <summary>A delegate which requests that SMAPI immediately exit the game. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</summary> + /// <param name="module">The module which requested an immediate exit.</param> + /// <param name="reason">The reason provided for the shutdown.</param> + internal delegate void RequestExitDelegate(string module, string reason); +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Framework/SContentManager.cs b/src/StardewModdingAPI/Framework/SContentManager.cs new file mode 100644 index 00000000..43de6e96 --- /dev/null +++ b/src/StardewModdingAPI/Framework/SContentManager.cs @@ -0,0 +1,531 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; +using StardewModdingAPI.Framework.Content; +using StardewModdingAPI.Framework.ModLoading; +using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Framework.Utilities; +using StardewModdingAPI.Metadata; +using StardewValley; + +namespace StardewModdingAPI.Framework +{ + /// <summary>SMAPI's implementation of the game's content manager which lets it raise content events.</summary> + internal class SContentManager : LocalizedContentManager + { + /********* + ** Properties + *********/ + /// <summary>The possible directory separator characters in an asset key.</summary> + private static readonly char[] PossiblePathSeparators = new[] { '/', '\\', Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }.Distinct().ToArray(); + + /// <summary>The preferred directory separator chaeacter in an asset key.</summary> + private static readonly string PreferredPathSeparator = Path.DirectorySeparatorChar.ToString(); + + /// <summary>Encapsulates monitoring and logging.</summary> + private readonly IMonitor Monitor; + + /// <summary>The underlying content manager's asset cache.</summary> + private readonly IDictionary<string, object> Cache; + + /// <summary>Applies platform-specific asset key normalisation so it's consistent with the underlying cache.</summary> + private readonly Func<string, string> NormaliseAssetNameForPlatform; + + /// <summary>The private <see cref="LocalizedContentManager"/> method which generates the locale portion of an asset name.</summary> + private readonly IPrivateMethod GetKeyLocale; + + /// <summary>The language codes used in asset keys.</summary> + private readonly IDictionary<string, LanguageCode> KeyLocales; + + /// <summary>Provides metadata for core game assets.</summary> + private readonly CoreAssets CoreAssets; + + /// <summary>The assets currently being intercepted by <see cref="IAssetLoader"/> instances. This is used to prevent infinite loops when a loader loads a new asset.</summary> + private readonly ContextHash<string> AssetsBeingLoaded = new ContextHash<string>(); + + /// <summary>A lookup of the content managers which loaded each asset.</summary> + private readonly IDictionary<string, HashSet<ContentManager>> AssetLoaders = new Dictionary<string, HashSet<ContentManager>>(); + + + /********* + ** Accessors + *********/ + /// <summary>Interceptors which provide the initial versions of matching assets.</summary> + internal IDictionary<IModMetadata, IList<IAssetLoader>> Loaders { get; } = new Dictionary<IModMetadata, IList<IAssetLoader>>(); + + /// <summary>Interceptors which edit matching assets after they're loaded.</summary> + internal IDictionary<IModMetadata, IList<IAssetEditor>> Editors { get; } = new Dictionary<IModMetadata, IList<IAssetEditor>>(); + + /// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary> + public string FullRootDirectory => Path.Combine(Constants.ExecutionPath, this.RootDirectory); + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="serviceProvider">The service provider to use to locate services.</param> + /// <param name="rootDirectory">The root directory to search for content.</param> + /// <param name="currentCulture">The current culture for which to localise content.</param> + /// <param name="languageCodeOverride">The current language code for which to localise content.</param> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + public SContentManager(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, string languageCodeOverride, IMonitor monitor) + : base(serviceProvider, rootDirectory, currentCulture, languageCodeOverride) + { + // validate + if (monitor == null) + throw new ArgumentNullException(nameof(monitor)); + + // initialise + var reflection = new Reflector(); + this.Monitor = monitor; + + // get underlying fields for interception + this.Cache = reflection.GetPrivateField<Dictionary<string, object>>(this, "loadedAssets").GetValue(); + this.GetKeyLocale = reflection.GetPrivateMethod(this, "languageCode"); + + // get asset key normalisation logic + if (Constants.TargetPlatform == Platform.Windows) + { + IPrivateMethod method = reflection.GetPrivateMethod(typeof(TitleContainer), "GetCleanPath"); + this.NormaliseAssetNameForPlatform = path => method.Invoke<string>(path); + } + else + this.NormaliseAssetNameForPlatform = key => key.Replace('\\', '/'); // based on MonoGame's ContentManager.Load<T> logic + + // get asset data + this.CoreAssets = new CoreAssets(this.NormaliseAssetName); + this.KeyLocales = this.GetKeyLocales(reflection); + } + + /// <summary>Normalise path separators in a file path. For asset keys, see <see cref="NormaliseAssetName"/> instead.</summary> + /// <param name="path">The file path to normalise.</param> + public string NormalisePathSeparators(string path) + { + string[] parts = path.Split(SContentManager.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries); + string normalised = string.Join(SContentManager.PreferredPathSeparator, parts); + if (path.StartsWith(SContentManager.PreferredPathSeparator)) + normalised = SContentManager.PreferredPathSeparator + normalised; // keep root slash + return normalised; + } + + /// <summary>Normalise an asset name so it's consistent with the underlying cache.</summary> + /// <param name="assetName">The asset key.</param> + public string NormaliseAssetName(string assetName) + { + assetName = this.NormalisePathSeparators(assetName); + if (assetName.EndsWith(".xnb", StringComparison.InvariantCultureIgnoreCase)) + return assetName.Substring(0, assetName.Length - 4); + return this.NormaliseAssetNameForPlatform(assetName); + } + + /// <summary>Get whether the content manager has already loaded and cached the given asset.</summary> + /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> + public bool IsLoaded(string assetName) + { + lock (this.Cache) + { + assetName = this.NormaliseAssetName(assetName); + return this.IsNormalisedKeyLoaded(assetName); + } + } + + /// <summary>Load an asset that has been processed by the content pipeline.</summary> + /// <typeparam name="T">The type of asset to load.</typeparam> + /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> + public override T Load<T>(string assetName) + { + return this.LoadFor<T>(assetName, this); + } + + /// <summary>Load an asset that has been processed by the content pipeline.</summary> + /// <typeparam name="T">The type of asset to load.</typeparam> + /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> + /// <param name="instance">The content manager instance for which to load the asset.</param> + public T LoadFor<T>(string assetName, ContentManager instance) + { + lock (this.Cache) + { + assetName = this.NormaliseAssetName(assetName); + + // skip if already loaded + if (this.IsNormalisedKeyLoaded(assetName)) + { + this.TrackAssetLoader(assetName, instance); + return base.Load<T>(assetName); + } + + // load asset + T data; + if (this.AssetsBeingLoaded.Contains(assetName)) + { + this.Monitor.Log($"Broke loop while loading asset '{assetName}'.", LogLevel.Warn); + this.Monitor.Log($"Bypassing mod loaders for this asset. Stack trace:\n{Environment.StackTrace}", LogLevel.Trace); + data = base.Load<T>(assetName); + } + else + { + data = this.AssetsBeingLoaded.Track(assetName, () => + { + IAssetInfo info = new AssetInfo(this.GetLocale(), assetName, typeof(T), this.NormaliseAssetName); + IAssetData asset = this.ApplyLoader<T>(info) ?? new AssetDataForObject(info, base.Load<T>(assetName), this.NormaliseAssetName); + asset = this.ApplyEditors<T>(info, asset); + return (T)asset.Data; + }); + } + + // update cache & return data + this.Cache[assetName] = data; + this.TrackAssetLoader(assetName, instance); + return data; + } + } + + /// <summary>Inject an asset into the cache.</summary> + /// <typeparam name="T">The type of asset to inject.</typeparam> + /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> + /// <param name="value">The asset value.</param> + public void Inject<T>(string assetName, T value) + { + lock (this.Cache) + { + assetName = this.NormaliseAssetName(assetName); + this.Cache[assetName] = value; + this.TrackAssetLoader(assetName, this); + } + } + + /// <summary>Get the current content locale.</summary> + public string GetLocale() + { + return this.GetKeyLocale.Invoke<string>(); + } + + /// <summary>Get the cached asset keys.</summary> + public IEnumerable<string> GetAssetKeys() + { + lock (this.Cache) + { + IEnumerable<string> GetAllAssetKeys() + { + foreach (string cacheKey in this.Cache.Keys) + { + this.ParseCacheKey(cacheKey, out string assetKey, out string _); + yield return assetKey; + } + } + + return GetAllAssetKeys().Distinct(); + } + } + + /// <summary>Purge assets from the cache that match one of the interceptors.</summary> + /// <param name="editors">The asset editors for which to purge matching assets.</param> + /// <param name="loaders">The asset loaders for which to purge matching assets.</param> + /// <returns>Returns whether any cache entries were invalidated.</returns> + public bool InvalidateCacheFor(IAssetEditor[] editors, IAssetLoader[] loaders) + { + if (!editors.Any() && !loaders.Any()) + return false; + + // get CanEdit/Load methods + MethodInfo canEdit = typeof(IAssetEditor).GetMethod(nameof(IAssetEditor.CanEdit)); + MethodInfo canLoad = typeof(IAssetLoader).GetMethod(nameof(IAssetLoader.CanLoad)); + + // invalidate matching keys + return this.InvalidateCache((assetName, assetType) => + { + // get asset metadata + IAssetInfo info = new AssetInfo(this.GetLocale(), assetName, assetType, this.NormaliseAssetName); + + // check loaders + MethodInfo canLoadGeneric = canLoad.MakeGenericMethod(assetType); + if (loaders.Any(loader => (bool)canLoadGeneric.Invoke(loader, new object[] { info }))) + return true; + + // check editors + MethodInfo canEditGeneric = canEdit.MakeGenericMethod(assetType); + return editors.Any(editor => (bool)canEditGeneric.Invoke(editor, new object[] { info })); + }); + } + + /// <summary>Purge matched assets from the cache.</summary> + /// <param name="predicate">Matches the asset keys to invalidate.</param> + /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param> + /// <returns>Returns whether any cache entries were invalidated.</returns> + public bool InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false) + { + lock (this.Cache) + { + // find matching asset keys + HashSet<string> purgeCacheKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); + HashSet<string> purgeAssetKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); + foreach (string cacheKey in this.Cache.Keys) + { + this.ParseCacheKey(cacheKey, out string assetKey, out _); + Type type = this.Cache[cacheKey].GetType(); + if (predicate(assetKey, type)) + { + purgeAssetKeys.Add(assetKey); + purgeCacheKeys.Add(cacheKey); + } + } + + // purge assets + foreach (string key in purgeCacheKeys) + { + if (dispose && this.Cache[key] is IDisposable disposable) + disposable.Dispose(); + this.Cache.Remove(key); + this.AssetLoaders.Remove(key); + } + + // reload core game assets + int reloaded = 0; + foreach (string key in purgeAssetKeys) + { + if (this.CoreAssets.ReloadForKey(this, key)) + reloaded++; + } + + // report result + if (purgeCacheKeys.Any()) + { + this.Monitor.Log($"Invalidated {purgeCacheKeys.Count} cache entries for {purgeAssetKeys.Count} asset keys: {string.Join(", ", purgeCacheKeys.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase))}. Reloaded {reloaded} core assets.", LogLevel.Trace); + return true; + } + this.Monitor.Log("Invalidated 0 cache entries.", LogLevel.Trace); + return false; + } + } + + /// <summary>Dispose assets for the given content manager shim.</summary> + /// <param name="shim">The content manager whose assets to dispose.</param> + internal void DisposeFor(ContentManagerShim shim) + { + this.Monitor.Log($"Content manager '{shim.Name}' disposed, disposing assets that aren't needed by any other asset loader.", LogLevel.Trace); + + foreach (var entry in this.AssetLoaders) + entry.Value.Remove(shim); + this.InvalidateCache((key, type) => !this.AssetLoaders[key].Any(), dispose: true); + } + + + /********* + ** Private methods + *********/ + /// <summary>Get whether an asset has already been loaded.</summary> + /// <param name="normalisedAssetName">The normalised asset name.</param> + private bool IsNormalisedKeyLoaded(string normalisedAssetName) + { + return this.Cache.ContainsKey(normalisedAssetName) + || this.Cache.ContainsKey($"{normalisedAssetName}.{this.GetKeyLocale.Invoke<string>()}"); // translated asset + } + + /// <summary>Track that a content manager loaded an asset.</summary> + /// <param name="key">The asset key that was loaded.</param> + /// <param name="manager">The content manager that loaded the asset.</param> + private void TrackAssetLoader(string key, ContentManager manager) + { + if (!this.AssetLoaders.TryGetValue(key, out HashSet<ContentManager> hash)) + hash = this.AssetLoaders[key] = new HashSet<ContentManager>(); + hash.Add(manager); + } + + /// <summary>Get the locale codes (like <c>ja-JP</c>) used in asset keys.</summary> + /// <param name="reflection">Simplifies access to private game code.</param> + private IDictionary<string, LanguageCode> GetKeyLocales(Reflector reflection) + { + // get the private code field directly to avoid changed-code logic + IPrivateField<LanguageCode> codeField = reflection.GetPrivateField<LanguageCode>(typeof(LocalizedContentManager), "_currentLangCode"); + + // remember previous settings + LanguageCode previousCode = codeField.GetValue(); + string previousOverride = this.LanguageCodeOverride; + + // create locale => code map + IDictionary<string, LanguageCode> map = new Dictionary<string, LanguageCode>(StringComparer.InvariantCultureIgnoreCase); + this.LanguageCodeOverride = null; + foreach (LanguageCode code in Enum.GetValues(typeof(LanguageCode))) + { + codeField.SetValue(code); + map[this.GetKeyLocale.Invoke<string>()] = code; + } + + // restore previous settings + codeField.SetValue(previousCode); + this.LanguageCodeOverride = previousOverride; + + return map; + } + + /// <summary>Parse a cache key into its component parts.</summary> + /// <param name="cacheKey">The input cache key.</param> + /// <param name="assetKey">The original asset key.</param> + /// <param name="localeCode">The asset locale code (or <c>null</c> if not localised).</param> + private void ParseCacheKey(string cacheKey, out string assetKey, out string localeCode) + { + // handle localised key + if (!string.IsNullOrWhiteSpace(cacheKey)) + { + int lastSepIndex = cacheKey.LastIndexOf(".", StringComparison.InvariantCulture); + if (lastSepIndex >= 0) + { + string suffix = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1); + if (this.KeyLocales.ContainsKey(suffix)) + { + assetKey = cacheKey.Substring(0, lastSepIndex); + localeCode = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1); + return; + } + } + } + + // handle simple key + assetKey = cacheKey; + localeCode = null; + } + + /// <summary>Load the initial asset from the registered <see cref="Loaders"/>.</summary> + /// <param name="info">The basic asset metadata.</param> + /// <returns>Returns the loaded asset metadata, or <c>null</c> if no loader matched.</returns> + private IAssetData ApplyLoader<T>(IAssetInfo info) + { + // find matching loaders + var loaders = this.GetInterceptors(this.Loaders) + .Where(entry => + { + try + { + return entry.Value.CanLoad<T>(info); + } + catch (Exception ex) + { + this.Monitor.Log($"{entry.Key.DisplayName} crashed when checking whether it could load asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + return false; + } + }) + .ToArray(); + + // validate loaders + if (!loaders.Any()) + return null; + if (loaders.Length > 1) + { + string[] loaderNames = loaders.Select(p => p.Key.DisplayName).ToArray(); + this.Monitor.Log($"Multiple mods want to provide the '{info.AssetName}' asset ({string.Join(", ", loaderNames)}), but an asset can't be loaded multiple times. SMAPI will use the default asset instead; uninstall one of the mods to fix this. (Message for modders: you should usually use {typeof(IAssetEditor)} instead to avoid conflicts.)", LogLevel.Warn); + return null; + } + + // fetch asset from loader + IModMetadata mod = loaders[0].Key; + IAssetLoader loader = loaders[0].Value; + T data; + try + { + data = loader.Load<T>(info); + this.Monitor.Log($"{mod.DisplayName} loaded asset '{info.AssetName}'.", LogLevel.Trace); + } + catch (Exception ex) + { + this.Monitor.Log($"{mod.DisplayName} crashed when loading asset '{info.AssetName}'. SMAPI will use the default asset instead. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + return null; + } + + // validate asset + if (data == null) + { + this.Monitor.Log($"{mod.DisplayName} incorrectly set asset '{info.AssetName}' to a null value; ignoring override.", LogLevel.Error); + return null; + } + + // return matched asset + return new AssetDataForObject(info, data, this.NormaliseAssetName); + } + + /// <summary>Apply any <see cref="Editors"/> to a loaded asset.</summary> + /// <typeparam name="T">The asset type.</typeparam> + /// <param name="info">The basic asset metadata.</param> + /// <param name="asset">The loaded asset.</param> + private IAssetData ApplyEditors<T>(IAssetInfo info, IAssetData asset) + { + IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.NormaliseAssetName); + + // edit asset + foreach (var entry in this.GetInterceptors(this.Editors)) + { + // check for match + IModMetadata mod = entry.Key; + IAssetEditor editor = entry.Value; + try + { + if (!editor.CanEdit<T>(info)) + continue; + } + catch (Exception ex) + { + this.Monitor.Log($"{mod.DisplayName} crashed when checking whether it could edit asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + continue; + } + + // try edit + object prevAsset = asset.Data; + try + { + editor.Edit<T>(asset); + this.Monitor.Log($"{mod.DisplayName} intercepted {info.AssetName}.", LogLevel.Trace); + } + catch (Exception ex) + { + this.Monitor.Log($"{mod.DisplayName} crashed when editing asset '{info.AssetName}', which may cause errors in-game. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + } + + // validate edit + if (asset.Data == null) + { + this.Monitor.Log($"{mod.DisplayName} incorrectly set asset '{info.AssetName}' to a null value; ignoring override.", LogLevel.Warn); + asset = GetNewData(prevAsset); + } + else if (!(asset.Data is T)) + { + this.Monitor.Log($"{mod.DisplayName} incorrectly set asset '{asset.AssetName}' to incompatible type '{asset.Data.GetType()}', expected '{typeof(T)}'; ignoring override.", LogLevel.Warn); + asset = GetNewData(prevAsset); + } + } + + // return result + return asset; + } + + /// <summary>Get all registered interceptors from a list.</summary> + private IEnumerable<KeyValuePair<IModMetadata, T>> GetInterceptors<T>(IDictionary<IModMetadata, IList<T>> entries) + { + foreach (var entry in entries) + { + IModMetadata metadata = entry.Key; + IList<T> interceptors = entry.Value; + + // special case if mod is an interceptor + if (metadata.Mod is T modAsInterceptor) + yield return new KeyValuePair<IModMetadata, T>(metadata, modAsInterceptor); + + // registered editors + foreach (T interceptor in interceptors) + yield return new KeyValuePair<IModMetadata, T>(metadata, interceptor); + } + } + + /// <summary>Dispose held resources.</summary> + /// <param name="disposing">Whether the content manager is disposing (rather than finalising).</param> + protected override void Dispose(bool disposing) + { + this.Monitor.Log("Disposing SMAPI's main content manager. It will no longer be usable after this point.", LogLevel.Trace); + base.Dispose(disposing); + } + } +} diff --git a/src/StardewModdingAPI/Framework/SGame.cs b/src/StardewModdingAPI/Framework/SGame.cs new file mode 100644 index 00000000..7287cab7 --- /dev/null +++ b/src/StardewModdingAPI/Framework/SGame.cs @@ -0,0 +1,1403 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using StardewModdingAPI.Events; +using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Framework.Utilities; +using StardewModdingAPI.Utilities; +using StardewValley; +using StardewValley.BellsAndWhistles; +using StardewValley.Locations; +using StardewValley.Menus; +using StardewValley.Tools; +using xTile.Dimensions; +using xTile.Layers; + +namespace StardewModdingAPI.Framework +{ + /// <summary>SMAPI's extension of the game's core <see cref="Game1"/>, used to inject events.</summary> + internal class SGame : Game1 + { + /********* + ** Properties + *********/ + /**** + ** SMAPI state + ****/ + /// <summary>Encapsulates monitoring and logging.</summary> + private readonly IMonitor Monitor; + + /// <summary>The maximum number of consecutive attempts SMAPI should make to recover from a draw error.</summary> + private readonly Countdown DrawCrashTimer = new Countdown(60); // 60 ticks = roughly one second + + /// <summary>The maximum number of consecutive attempts SMAPI should make to recover from an update error.</summary> + private readonly Countdown UpdateCrashTimer = new Countdown(60); // 60 ticks = roughly one second + + /// <summary>The number of ticks until SMAPI should notify mods that the game has loaded.</summary> + /// <remarks>Skipping a few frames ensures the game finishes initialising the world before mods try to change it.</remarks> + private int AfterLoadTimer = 5; + + /// <summary>Whether the game is returning to the menu.</summary> + private bool IsExitingToTitle; + + /// <summary>Whether the game is saving and SMAPI has already raised <see cref="SaveEvents.BeforeSave"/>.</summary> + private bool IsBetweenSaveEvents; + + /**** + ** Game state + ****/ + /// <summary>A record of the buttons pressed as of the previous tick.</summary> + private SButton[] PreviousPressedButtons = new SButton[0]; + + /// <summary>A record of the keyboard state (i.e. the up/down state for each button) as of the previous tick.</summary> + private KeyboardState PreviousKeyState; + + /// <summary>A record of the controller state (i.e. the up/down state for each button) as of the previous tick.</summary> + private GamePadState PreviousControllerState; + + /// <summary>A record of the mouse state (i.e. the cursor position, scroll amount, and the up/down state for each button) as of the previous tick.</summary> + private MouseState PreviousMouseState; + + /// <summary>The previous mouse position on the screen adjusted for the zoom level.</summary> + private Point PreviousMousePosition; + + /// <summary>The window size value at last check.</summary> + private Point PreviousWindowSize; + + /// <summary>The save ID at last check.</summary> + private ulong PreviousSaveID; + + /// <summary>A hash of <see cref="Game1.locations"/> at last check.</summary> + private int PreviousGameLocations; + + /// <summary>A hash of the current location's <see cref="GameLocation.objects"/> at last check.</summary> + private int PreviousLocationObjects; + + /// <summary>The player's inventory at last check.</summary> + private IDictionary<Item, int> PreviousItems; + + /// <summary>The player's combat skill level at last check.</summary> + private int PreviousCombatLevel; + + /// <summary>The player's farming skill level at last check.</summary> + private int PreviousFarmingLevel; + + /// <summary>The player's fishing skill level at last check.</summary> + private int PreviousFishingLevel; + + /// <summary>The player's foraging skill level at last check.</summary> + private int PreviousForagingLevel; + + /// <summary>The player's mining skill level at last check.</summary> + private int PreviousMiningLevel; + + /// <summary>The player's luck skill level at last check.</summary> + private int PreviousLuckLevel; + + /// <summary>The player's location at last check.</summary> + private GameLocation PreviousGameLocation; + + /// <summary>The active game menu at last check.</summary> + private IClickableMenu PreviousActiveMenu; + + /// <summary>The mine level at last check.</summary> + private int PreviousMineLevel; + + /// <summary>The time of day (in 24-hour military format) at last check.</summary> + private int PreviousTime; + + /// <summary>The previous content locale.</summary> + private LocalizedContentManager.LanguageCode? PreviousLocale; + + /// <summary>An index incremented on every tick and reset every 60th tick (0–59).</summary> + private int CurrentUpdateTick; + + /// <summary>Whether this is the very first update tick since the game started.</summary> + private bool FirstUpdate; + + /// <summary>The current game instance.</summary> + private static SGame Instance; + + /**** + ** Private wrappers + ****/ + /// <summary>Simplifies access to private game code.</summary> + private static Reflector Reflection; + + // ReSharper disable ArrangeStaticMemberQualifier, ArrangeThisQualifier, InconsistentNaming + /// <summary>Used to access private fields and methods.</summary> + private static List<float> _fpsList => SGame.Reflection.GetPrivateField<List<float>>(typeof(Game1), nameof(_fpsList)).GetValue(); + private static Stopwatch _fpsStopwatch => SGame.Reflection.GetPrivateField<Stopwatch>(typeof(Game1), nameof(SGame._fpsStopwatch)).GetValue(); + private static float _fps + { + set => SGame.Reflection.GetPrivateField<float>(typeof(Game1), nameof(_fps)).SetValue(value); + } + private static Task _newDayTask => SGame.Reflection.GetPrivateField<Task>(typeof(Game1), nameof(_newDayTask)).GetValue(); + private Color bgColor => SGame.Reflection.GetPrivateField<Color>(this, nameof(bgColor)).GetValue(); + public RenderTarget2D screenWrapper => SGame.Reflection.GetPrivateProperty<RenderTarget2D>(this, "screen").GetValue(); // deliberately renamed to avoid an infinite loop + public BlendState lightingBlend => SGame.Reflection.GetPrivateField<BlendState>(this, nameof(lightingBlend)).GetValue(); + private readonly Action drawFarmBuildings = () => SGame.Reflection.GetPrivateMethod(SGame.Instance, nameof(drawFarmBuildings)).Invoke(); + private readonly Action drawHUD = () => SGame.Reflection.GetPrivateMethod(SGame.Instance, nameof(drawHUD)).Invoke(); + private readonly Action drawDialogueBox = () => SGame.Reflection.GetPrivateMethod(SGame.Instance, nameof(drawDialogueBox)).Invoke(); + private readonly Action renderScreenBuffer = () => SGame.Reflection.GetPrivateMethod(SGame.Instance, nameof(renderScreenBuffer)).Invoke(); + // ReSharper restore ArrangeStaticMemberQualifier, ArrangeThisQualifier, InconsistentNaming + + + /********* + ** Accessors + *********/ + /// <summary>SMAPI's content manager.</summary> + public SContentManager SContentManager { get; } + + /// <summary>Whether SMAPI should log more information about the game context.</summary> + public bool VerboseLogging { get; set; } + + + /********* + ** Protected methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="reflection">Simplifies access to private game code.</param> + internal SGame(IMonitor monitor, Reflector reflection) + { + // initialise + this.Monitor = monitor; + this.FirstUpdate = true; + SGame.Instance = this; + SGame.Reflection = reflection; + + // set XNA option required by Stardew Valley + Game1.graphics.GraphicsProfile = GraphicsProfile.HiDef; + + // override content manager + this.Monitor?.Log("Overriding content manager...", LogLevel.Trace); + this.SContentManager = new SContentManager(this.Content.ServiceProvider, this.Content.RootDirectory, Thread.CurrentThread.CurrentUICulture, null, this.Monitor); + this.Content = new ContentManagerShim(this.SContentManager, "SGame.Content"); + Game1.content = new ContentManagerShim(this.SContentManager, "Game1.content"); + reflection.GetPrivateField<LocalizedContentManager>(typeof(Game1), "_temporaryContent").SetValue(new ContentManagerShim(this.SContentManager, "Game1._temporaryContent")); // regenerate value with new content manager + } + + /**** + ** Intercepted methods & events + ****/ + /// <summary>Constructor a content manager to read XNB files.</summary> + /// <param name="serviceProvider">The service provider to use to locate services.</param> + /// <param name="rootDirectory">The root directory to search for content.</param> + protected override LocalizedContentManager CreateContentManager(IServiceProvider serviceProvider, string rootDirectory) + { + // return default if SMAPI's content manager isn't initialised yet + if (this.SContentManager == null) + { + this.Monitor?.Log("SMAPI's content manager isn't initialised; skipping content manager interception.", LogLevel.Trace); + return base.CreateContentManager(serviceProvider, rootDirectory); + } + + // return single instance if valid + if (serviceProvider != this.Content.ServiceProvider) + throw new InvalidOperationException("SMAPI uses a single content manager internally. You can't get a new content manager with a different service provider."); + if (rootDirectory != this.Content.RootDirectory) + throw new InvalidOperationException($"SMAPI uses a single content manager internally. You can't get a new content manager with a different root directory (current is {this.Content.RootDirectory}, requested {rootDirectory})."); + return new ContentManagerShim(this.SContentManager, "(generated instance)"); + } + + /// <summary>The method called when the game is updating its state. This happens roughly 60 times per second.</summary> + /// <param name="gameTime">A snapshot of the game timing state.</param> + protected override void Update(GameTime gameTime) + { + try + { + /********* + ** Skip conditions + *********/ + // SMAPI exiting, stop processing game updates + if (this.Monitor.IsExiting) + { + this.Monitor.Log("SMAPI shutting down: aborting update.", LogLevel.Trace); + return; + } + + // While a background new-day task is in progress, the game skips its own update logic + // and defers to the XNA Update method. Running mod code in parallel to the background + // update is risky, because data changes can conflict (e.g. collection changed during + // enumeration errors) and data may change unexpectedly from one mod instruction to the + // next. + // + // Therefore we can just run Game1.Update here without raising any SMAPI events. There's + // a small chance that the task will finish after we defer but before the game checks, + // which means technically events should be raised, but the effects of missing one + // update tick are neglible and not worth the complications of bypassing Game1.Update. + if (SGame._newDayTask != null) + { + base.Update(gameTime); + return; + } + + // While the game is writing to the save file in the background, mods can unexpectedly + // fail since they don't have exclusive access to resources (e.g. collection changed + // during enumeration errors). To avoid problems, events are not invoked while a save + // is in progress. It's safe to raise SaveEvents.BeforeSave as soon as the menu is + // opened (since the save hasn't started yet), but all other events should be suppressed. + if (Context.IsSaving) + { + // raise before-save + if (!this.IsBetweenSaveEvents) + { + this.IsBetweenSaveEvents = true; + this.Monitor.Log("Context: before save.", LogLevel.Trace); + SaveEvents.InvokeBeforeSave(this.Monitor); + } + + // suppress non-save events + base.Update(gameTime); + return; + } + if (this.IsBetweenSaveEvents) + { + // raise after-save + this.IsBetweenSaveEvents = false; + this.Monitor.Log($"Context: after save, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.", LogLevel.Trace); + SaveEvents.InvokeAfterSave(this.Monitor); + TimeEvents.InvokeAfterDayStarted(this.Monitor); + } + + /********* + ** Game loaded events + *********/ + if (this.FirstUpdate) + { + GameEvents.InvokeInitialize(this.Monitor); + } + + /********* + ** Locale changed events + *********/ + if (this.PreviousLocale != LocalizedContentManager.CurrentLanguageCode) + { + var oldValue = this.PreviousLocale; + var newValue = LocalizedContentManager.CurrentLanguageCode; + + this.Monitor.Log($"Context: locale set to {newValue}.", LogLevel.Trace); + + if (oldValue != null) + ContentEvents.InvokeAfterLocaleChanged(this.Monitor, oldValue.ToString(), newValue.ToString()); + this.PreviousLocale = newValue; + } + + /********* + ** After load events + *********/ + if (Context.IsSaveLoaded && !SaveGame.IsProcessing /*still loading save*/ && this.AfterLoadTimer >= 0) + { + if (Game1.dayOfMonth != 0) // wait until new-game intro finishes (world not fully initialised yet) + this.AfterLoadTimer--; + + if (this.AfterLoadTimer == 0) + { + this.Monitor.Log($"Context: loaded saved game '{Constants.SaveFolderName}', starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.", LogLevel.Trace); + Context.IsWorldReady = true; + + SaveEvents.InvokeAfterLoad(this.Monitor); + TimeEvents.InvokeAfterDayStarted(this.Monitor); + } + } + + /********* + ** Exit to title events + *********/ + // before exit to title + if (Game1.exitToTitle) + this.IsExitingToTitle = true; + + // after exit to title + if (Context.IsWorldReady && this.IsExitingToTitle && Game1.activeClickableMenu is TitleMenu) + { + this.Monitor.Log("Context: returned to title", LogLevel.Trace); + + this.IsExitingToTitle = false; + this.CleanupAfterReturnToTitle(); + SaveEvents.InvokeAfterReturnToTitle(this.Monitor); + } + + /********* + ** Window events + *********/ + // Here we depend on the game's viewport instead of listening to the Window.Resize + // event because we need to notify mods after the game handles the resize, so the + // game's metadata (like Game1.viewport) are updated. That's a bit complicated + // since the game adds & removes its own handler on the fly. + if (Game1.viewport.Width != this.PreviousWindowSize.X || Game1.viewport.Height != this.PreviousWindowSize.Y) + { + Point size = new Point(Game1.viewport.Width, Game1.viewport.Height); + GraphicsEvents.InvokeResize(this.Monitor); + this.PreviousWindowSize = size; + } + + /********* + ** Input events (if window has focus) + *********/ + if (Game1.game1.IsActive) + { + // get latest state + KeyboardState keyState; + GamePadState controllerState; + MouseState mouseState; + Point mousePosition; + try + { + keyState = Keyboard.GetState(); + controllerState = GamePad.GetState(PlayerIndex.One); + mouseState = Mouse.GetState(); + mousePosition = new Point(Game1.getMouseX(), Game1.getMouseY()); + } + catch (InvalidOperationException) // GetState() may crash for some players if window doesn't have focus but game1.IsActive == true + { + keyState = this.PreviousKeyState; + controllerState = this.PreviousControllerState; + mouseState = this.PreviousMouseState; + mousePosition = this.PreviousMousePosition; + } + + // analyse state + SButton[] currentlyPressedKeys = this.GetPressedButtons(keyState, mouseState, controllerState).ToArray(); + SButton[] previousPressedKeys = this.PreviousPressedButtons; + SButton[] framePressedKeys = currentlyPressedKeys.Except(previousPressedKeys).ToArray(); + SButton[] frameReleasedKeys = previousPressedKeys.Except(currentlyPressedKeys).ToArray(); + bool isClick = framePressedKeys.Contains(SButton.MouseLeft) || (framePressedKeys.Contains(SButton.ControllerA) && !currentlyPressedKeys.Contains(SButton.ControllerX)); + + // get cursor position + ICursorPosition cursor; + { + // cursor position + Vector2 screenPixels = new Vector2(Game1.getMouseX(), Game1.getMouseY()); + Vector2 tile = new Vector2((Game1.viewport.X + screenPixels.X) / Game1.tileSize, (Game1.viewport.Y + screenPixels.Y) / Game1.tileSize); + Vector2 grabTile = (Game1.mouseCursorTransparency > 0 && Utility.tileWithinRadiusOfPlayer((int)tile.X, (int)tile.Y, 1, Game1.player)) // derived from Game1.pressActionButton + ? tile + : Game1.player.GetGrabTile(); + cursor = new CursorPosition(screenPixels, tile, grabTile); + } + + // raise button pressed + foreach (SButton button in framePressedKeys) + { + InputEvents.InvokeButtonPressed(this.Monitor, button, cursor, isClick); + + // legacy events + if (button.TryGetKeyboard(out Keys key)) + { + if (key != Keys.None) + ControlEvents.InvokeKeyPressed(this.Monitor, key); + } + else if (button.TryGetController(out Buttons controllerButton)) + { + if (controllerButton == Buttons.LeftTrigger || controllerButton == Buttons.RightTrigger) + ControlEvents.InvokeTriggerPressed(this.Monitor, controllerButton, controllerButton == Buttons.LeftTrigger ? controllerState.Triggers.Left : controllerState.Triggers.Right); + else + ControlEvents.InvokeButtonPressed(this.Monitor, controllerButton); + } + } + + // raise button released + foreach (SButton button in frameReleasedKeys) + { + bool wasClick = + (button == SButton.MouseLeft && previousPressedKeys.Contains(SButton.MouseLeft)) // released left click + || (button == SButton.ControllerA && previousPressedKeys.Contains(SButton.ControllerA) && !previousPressedKeys.Contains(SButton.ControllerX)); + InputEvents.InvokeButtonReleased(this.Monitor, button, cursor, wasClick); + + // legacy events + if (button.TryGetKeyboard(out Keys key)) + { + if (key != Keys.None) + ControlEvents.InvokeKeyReleased(this.Monitor, key); + } + else if (button.TryGetController(out Buttons controllerButton)) + { + if (controllerButton == Buttons.LeftTrigger || controllerButton == Buttons.RightTrigger) + ControlEvents.InvokeTriggerReleased(this.Monitor, controllerButton, controllerButton == Buttons.LeftTrigger ? controllerState.Triggers.Left : controllerState.Triggers.Right); + else + ControlEvents.InvokeButtonReleased(this.Monitor, controllerButton); + } + } + + // raise legacy state-changed events + if (keyState != this.PreviousKeyState) + ControlEvents.InvokeKeyboardChanged(this.Monitor, this.PreviousKeyState, keyState); + if (mouseState != this.PreviousMouseState) + ControlEvents.InvokeMouseChanged(this.Monitor, this.PreviousMouseState, mouseState, this.PreviousMousePosition, mousePosition); + + // track state + this.PreviousMouseState = mouseState; + this.PreviousMousePosition = mousePosition; + this.PreviousKeyState = keyState; + this.PreviousControllerState = controllerState; + this.PreviousPressedButtons = currentlyPressedKeys; + } + + /********* + ** Menu events + *********/ + if (Game1.activeClickableMenu != this.PreviousActiveMenu) + { + IClickableMenu previousMenu = this.PreviousActiveMenu; + IClickableMenu newMenu = Game1.activeClickableMenu; + + // log context + if (this.VerboseLogging) + { + if (previousMenu == null) + this.Monitor.Log($"Context: opened menu {newMenu?.GetType().FullName ?? "(none)"}.", LogLevel.Trace); + else if (newMenu == null) + this.Monitor.Log($"Context: closed menu {previousMenu.GetType().FullName}.", LogLevel.Trace); + else + this.Monitor.Log($"Context: changed menu from {previousMenu.GetType().FullName} to {newMenu.GetType().FullName}.", LogLevel.Trace); + } + + // raise menu events + if (newMenu != null) + MenuEvents.InvokeMenuChanged(this.Monitor, previousMenu, newMenu); + else + MenuEvents.InvokeMenuClosed(this.Monitor, previousMenu); + + // update previous menu + // (if the menu was changed in one of the handlers, deliberately defer detection until the next update so mods can be notified of the new menu change) + this.PreviousActiveMenu = newMenu; + } + + /********* + ** World & player events + *********/ + if (Context.IsWorldReady) + { + // raise current location changed + if (Game1.currentLocation != this.PreviousGameLocation) + { + if (this.VerboseLogging) + this.Monitor.Log($"Context: set location to {Game1.currentLocation?.Name ?? "(none)"}.", LogLevel.Trace); + LocationEvents.InvokeCurrentLocationChanged(this.Monitor, this.PreviousGameLocation, Game1.currentLocation); + } + + // raise location list changed + if (this.GetHash(Game1.locations) != this.PreviousGameLocations) + LocationEvents.InvokeLocationsChanged(this.Monitor, Game1.locations); + + // raise events that shouldn't be triggered on initial load + if (Game1.uniqueIDForThisGame == this.PreviousSaveID) + { + // raise player leveled up a skill + if (Game1.player.combatLevel != this.PreviousCombatLevel) + PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Combat, Game1.player.combatLevel); + if (Game1.player.farmingLevel != this.PreviousFarmingLevel) + PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Farming, Game1.player.farmingLevel); + if (Game1.player.fishingLevel != this.PreviousFishingLevel) + PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Fishing, Game1.player.fishingLevel); + if (Game1.player.foragingLevel != this.PreviousForagingLevel) + PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Foraging, Game1.player.foragingLevel); + if (Game1.player.miningLevel != this.PreviousMiningLevel) + PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Mining, Game1.player.miningLevel); + if (Game1.player.luckLevel != this.PreviousLuckLevel) + PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Luck, Game1.player.luckLevel); + + // raise player inventory changed + ItemStackChange[] changedItems = this.GetInventoryChanges(Game1.player.items, this.PreviousItems).ToArray(); + if (changedItems.Any()) + PlayerEvents.InvokeInventoryChanged(this.Monitor, Game1.player.items, changedItems); + + // raise current location's object list changed + if (this.GetHash(Game1.currentLocation.objects) != this.PreviousLocationObjects) + LocationEvents.InvokeOnNewLocationObject(this.Monitor, Game1.currentLocation.objects); + + // raise time changed + if (Game1.timeOfDay != this.PreviousTime) + TimeEvents.InvokeTimeOfDayChanged(this.Monitor, this.PreviousTime, Game1.timeOfDay); + + // raise mine level changed + if (Game1.mine != null && Game1.mine.mineLevel != this.PreviousMineLevel) + MineEvents.InvokeMineLevelChanged(this.Monitor, this.PreviousMineLevel, Game1.mine.mineLevel); + } + + // update state + this.PreviousGameLocations = this.GetHash(Game1.locations); + this.PreviousGameLocation = Game1.currentLocation; + this.PreviousCombatLevel = Game1.player.combatLevel; + this.PreviousFarmingLevel = Game1.player.farmingLevel; + this.PreviousFishingLevel = Game1.player.fishingLevel; + this.PreviousForagingLevel = Game1.player.foragingLevel; + this.PreviousMiningLevel = Game1.player.miningLevel; + this.PreviousLuckLevel = Game1.player.luckLevel; + this.PreviousItems = Game1.player.items.Where(n => n != null).ToDictionary(n => n, n => n.Stack); + this.PreviousLocationObjects = this.GetHash(Game1.currentLocation.objects); + this.PreviousTime = Game1.timeOfDay; + this.PreviousMineLevel = Game1.mine?.mineLevel ?? 0; + this.PreviousSaveID = Game1.uniqueIDForThisGame; + } + + /********* + ** Game update + *********/ + try + { + base.Update(gameTime); + } + catch (Exception ex) + { + this.Monitor.Log($"An error occured in the base update loop: {ex.GetLogSummary()}", LogLevel.Error); + } + + /********* + ** Update events + *********/ + GameEvents.InvokeUpdateTick(this.Monitor); + if (this.FirstUpdate) + this.FirstUpdate = false; + if (this.CurrentUpdateTick % 2 == 0) + GameEvents.InvokeSecondUpdateTick(this.Monitor); + if (this.CurrentUpdateTick % 4 == 0) + GameEvents.InvokeFourthUpdateTick(this.Monitor); + if (this.CurrentUpdateTick % 8 == 0) + GameEvents.InvokeEighthUpdateTick(this.Monitor); + if (this.CurrentUpdateTick % 15 == 0) + GameEvents.InvokeQuarterSecondTick(this.Monitor); + if (this.CurrentUpdateTick % 30 == 0) + GameEvents.InvokeHalfSecondTick(this.Monitor); + if (this.CurrentUpdateTick % 60 == 0) + GameEvents.InvokeOneSecondTick(this.Monitor); + this.CurrentUpdateTick += 1; + if (this.CurrentUpdateTick >= 60) + this.CurrentUpdateTick = 0; + + this.UpdateCrashTimer.Reset(); + } + catch (Exception ex) + { + // log error + this.Monitor.Log($"An error occured in the overridden update loop: {ex.GetLogSummary()}", LogLevel.Error); + + // exit if irrecoverable + if (!this.UpdateCrashTimer.Decrement()) + this.Monitor.ExitGameImmediately("the game crashed when updating, and SMAPI was unable to recover the game."); + } + } + + /// <summary>The method called to draw everything to the screen.</summary> + /// <param name="gameTime">A snapshot of the game timing state.</param> + protected override void Draw(GameTime gameTime) + { + Context.IsInDrawLoop = true; + try + { + this.DrawImpl(gameTime); + this.DrawCrashTimer.Reset(); + } + catch (Exception ex) + { + // log error + this.Monitor.Log($"An error occured in the overridden draw loop: {ex.GetLogSummary()}", LogLevel.Error); + + // exit if irrecoverable + if (!this.DrawCrashTimer.Decrement()) + { + this.Monitor.ExitGameImmediately("the game crashed when drawing, and SMAPI was unable to recover the game."); + return; + } + + // recover sprite batch + try + { + if (Game1.spriteBatch.IsOpen(SGame.Reflection)) + { + this.Monitor.Log("Recovering sprite batch from error...", LogLevel.Trace); + Game1.spriteBatch.End(); + } + } + catch (Exception innerEx) + { + this.Monitor.Log($"Could not recover sprite batch state: {innerEx.GetLogSummary()}", LogLevel.Error); + } + } + Context.IsInDrawLoop = false; + } + + /// <summary>Replicate the game's draw logic with some changes for SMAPI.</summary> + /// <param name="gameTime">A snapshot of the game timing state.</param> + /// <remarks>This implementation is identical to <see cref="Game1.Draw"/>, except for try..catch around menu draw code, private field references replaced by wrappers, and added events.</remarks> + [SuppressMessage("ReSharper", "CompareOfFloatsByEqualityOperator", Justification = "copied from game code as-is")] + [SuppressMessage("ReSharper", "LocalVariableHidesMember", Justification = "copied from game code as-is")] + [SuppressMessage("ReSharper", "PossibleLossOfFraction", Justification = "copied from game code as-is")] + [SuppressMessage("ReSharper", "RedundantArgumentDefaultValue", Justification = "copied from game code as-is")] + [SuppressMessage("ReSharper", "RedundantCast", Justification = "copied from game code as-is")] + [SuppressMessage("ReSharper", "RedundantExplicitNullableCreation", Justification = "copied from game code as-is")] + [SuppressMessage("ReSharper", "RedundantTypeArgumentsOfMethod", Justification = "copied from game code as-is")] + private void DrawImpl(GameTime gameTime) + { + if (Game1.debugMode) + { + if (SGame._fpsStopwatch.IsRunning) + { + float totalSeconds = (float)SGame._fpsStopwatch.Elapsed.TotalSeconds; + SGame._fpsList.Add(totalSeconds); + while (SGame._fpsList.Count >= 120) + SGame._fpsList.RemoveAt(0); + float num = 0.0f; + foreach (float fps in SGame._fpsList) + num += fps; + SGame._fps = (float)(1.0 / ((double)num / (double)SGame._fpsList.Count)); + } + SGame._fpsStopwatch.Restart(); + } + else + { + if (SGame._fpsStopwatch.IsRunning) + SGame._fpsStopwatch.Reset(); + SGame._fps = 0.0f; + SGame._fpsList.Clear(); + } + if (SGame._newDayTask != null) + { + this.GraphicsDevice.Clear(this.bgColor); + //base.Draw(gameTime); + } + else + { + if ((double)Game1.options.zoomLevel != 1.0) + this.GraphicsDevice.SetRenderTarget(this.screenWrapper); + if (this.IsSaving) + { + this.GraphicsDevice.Clear(this.bgColor); + IClickableMenu activeClickableMenu = Game1.activeClickableMenu; + if (activeClickableMenu != null) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + try + { + GraphicsEvents.InvokeOnPreRenderGuiEvent(this.Monitor); + activeClickableMenu.draw(Game1.spriteBatch); + GraphicsEvents.InvokeOnPostRenderGuiEvent(this.Monitor); + } + catch (Exception ex) + { + this.Monitor.Log($"The {activeClickableMenu.GetType().FullName} menu crashed while drawing itself during save. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); + activeClickableMenu.exitThisMenu(); + } + Game1.spriteBatch.End(); + } + //base.Draw(gameTime); + this.renderScreenBuffer(); + } + else + { + this.GraphicsDevice.Clear(this.bgColor); + if (Game1.activeClickableMenu != null && Game1.options.showMenuBackground && Game1.activeClickableMenu.showWithoutTransparencyIfOptionIsSet()) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + try + { + Game1.activeClickableMenu.drawBackground(Game1.spriteBatch); + GraphicsEvents.InvokeOnPreRenderGuiEvent(this.Monitor); + Game1.activeClickableMenu.draw(Game1.spriteBatch); + GraphicsEvents.InvokeOnPostRenderGuiEvent(this.Monitor); + } + catch (Exception ex) + { + this.Monitor.Log($"The {Game1.activeClickableMenu.GetType().FullName} menu crashed while drawing itself. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); + Game1.activeClickableMenu.exitThisMenu(); + } + Game1.spriteBatch.End(); + if ((double)Game1.options.zoomLevel != 1.0) + { + this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); + this.GraphicsDevice.Clear(this.bgColor); + Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); + Game1.spriteBatch.Draw((Texture2D)this.screenWrapper, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screenWrapper.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.End(); + } + if (Game1.overlayMenu == null) + return; + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.overlayMenu.draw(Game1.spriteBatch); + Game1.spriteBatch.End(); + } + else if ((int)Game1.gameMode == 11) + { + Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3685"), new Vector2(16f, 16f), Color.HotPink); + Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3686"), new Vector2(16f, 32f), new Color(0, (int)byte.MaxValue, 0)); + Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.parseText(Game1.errorMessage, Game1.dialogueFont, Game1.graphics.GraphicsDevice.Viewport.Width), new Vector2(16f, 48f), Color.White); + Game1.spriteBatch.End(); + } + else if (Game1.currentMinigame != null) + { + Game1.currentMinigame.draw(Game1.spriteBatch); + if (Game1.globalFade && !Game1.menuUp && (!Game1.nameSelectUp || Game1.messagePause)) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * ((int)Game1.gameMode == 0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha)); + Game1.spriteBatch.End(); + } + if ((double)Game1.options.zoomLevel != 1.0) + { + this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); + this.GraphicsDevice.Clear(this.bgColor); + Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); + Game1.spriteBatch.Draw((Texture2D)this.screenWrapper, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screenWrapper.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.End(); + } + if (Game1.overlayMenu == null) + return; + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.overlayMenu.draw(Game1.spriteBatch); + Game1.spriteBatch.End(); + } + else if (Game1.showingEndOfNightStuff) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + if (Game1.activeClickableMenu != null) + { + try + { + GraphicsEvents.InvokeOnPreRenderGuiEvent(this.Monitor); + Game1.activeClickableMenu.draw(Game1.spriteBatch); + GraphicsEvents.InvokeOnPostRenderGuiEvent(this.Monitor); + } + catch (Exception ex) + { + this.Monitor.Log($"The {Game1.activeClickableMenu.GetType().FullName} menu crashed while drawing itself during end-of-night-stuff. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); + Game1.activeClickableMenu.exitThisMenu(); + } + } + Game1.spriteBatch.End(); + if ((double)Game1.options.zoomLevel != 1.0) + { + this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); + this.GraphicsDevice.Clear(this.bgColor); + Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); + Game1.spriteBatch.Draw((Texture2D)this.screenWrapper, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screenWrapper.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.End(); + } + if (Game1.overlayMenu == null) + return; + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.overlayMenu.draw(Game1.spriteBatch); + Game1.spriteBatch.End(); + } + else if ((int)Game1.gameMode == 6) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + string str1 = ""; + for (int index = 0; (double)index < gameTime.TotalGameTime.TotalMilliseconds % 999.0 / 333.0; ++index) + str1 += "."; + string str2 = Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3688"); + string str3 = str1; + string s = str2 + str3; + string str4 = "..."; + string str5 = str2 + str4; + int widthOfString = SpriteText.getWidthOfString(str5); + int height = 64; + int x = 64; + int y = Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Bottom - height; + SpriteText.drawString(Game1.spriteBatch, s, x, y, 999999, widthOfString, height, 1f, 0.88f, false, 0, str5, -1); + Game1.spriteBatch.End(); + if ((double)Game1.options.zoomLevel != 1.0) + { + this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); + this.GraphicsDevice.Clear(this.bgColor); + Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); + Game1.spriteBatch.Draw((Texture2D)this.screenWrapper, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screenWrapper.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.End(); + } + if (Game1.overlayMenu == null) + return; + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.overlayMenu.draw(Game1.spriteBatch); + Game1.spriteBatch.End(); + } + else + { + Microsoft.Xna.Framework.Rectangle rectangle; + if ((int)Game1.gameMode == 0) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + } + else + { + if (Game1.drawLighting) + { + this.GraphicsDevice.SetRenderTarget(Game1.lightmap); + this.GraphicsDevice.Clear(Color.White * 0.0f); + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.spriteBatch.Draw(Game1.staminaRect, Game1.lightmap.Bounds, Game1.currentLocation.name.Equals("UndergroundMine") ? Game1.mine.getLightingColor(gameTime) : (Game1.ambientLight.Equals(Color.White) || Game1.isRaining && Game1.currentLocation.isOutdoors ? Game1.outdoorLight : Game1.ambientLight)); + for (int index = 0; index < Game1.currentLightSources.Count; ++index) + { + if (Utility.isOnScreen(Game1.currentLightSources.ElementAt<LightSource>(index).position, (int)((double)Game1.currentLightSources.ElementAt<LightSource>(index).radius * (double)Game1.tileSize * 4.0))) + Game1.spriteBatch.Draw(Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture, Game1.GlobalToLocal(Game1.viewport, Game1.currentLightSources.ElementAt<LightSource>(index).position) / (float)(Game1.options.lightingQuality / 2), new Microsoft.Xna.Framework.Rectangle?(Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds), Game1.currentLightSources.ElementAt<LightSource>(index).color, 0.0f, new Vector2((float)Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds.Center.X, (float)Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds.Center.Y), Game1.currentLightSources.ElementAt<LightSource>(index).radius / (float)(Game1.options.lightingQuality / 2), SpriteEffects.None, 0.9f); + } + Game1.spriteBatch.End(); + this.GraphicsDevice.SetRenderTarget((double)Game1.options.zoomLevel == 1.0 ? (RenderTarget2D)null : this.screenWrapper); + } + if (Game1.bloomDay && Game1.bloom != null) + Game1.bloom.BeginDraw(); + this.GraphicsDevice.Clear(this.bgColor); + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + GraphicsEvents.InvokeOnPreRenderEvent(this.Monitor); + if (Game1.background != null) + Game1.background.draw(Game1.spriteBatch); + Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch); + Game1.currentLocation.Map.GetLayer("Back").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, Game1.pixelZoom); + Game1.currentLocation.drawWater(Game1.spriteBatch); + if (Game1.CurrentEvent == null) + { + foreach (NPC character in Game1.currentLocation.characters) + { + if (!character.swimming && !character.hideShadow && (!character.isInvisible && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(character.getTileLocation()))) + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, character.position + new Vector2((float)(character.sprite.spriteWidth * Game1.pixelZoom) / 2f, (float)(character.GetBoundingBox().Height + (character.IsMonster ? 0 : Game1.pixelZoom * 3)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), ((float)Game1.pixelZoom + (float)character.yJumpOffset / 40f) * character.scale, SpriteEffects.None, Math.Max(0.0f, (float)character.getStandingY() / 10000f) - 1E-06f); + } + } + else + { + foreach (NPC actor in Game1.CurrentEvent.actors) + { + if (!actor.swimming && !actor.hideShadow && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(actor.getTileLocation())) + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, actor.position + new Vector2((float)(actor.sprite.spriteWidth * Game1.pixelZoom) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : (actor.sprite.spriteHeight <= 16 ? -Game1.pixelZoom : Game1.pixelZoom * 3))))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), ((float)Game1.pixelZoom + (float)actor.yJumpOffset / 40f) * actor.scale, SpriteEffects.None, Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 1E-06f); + } + } + Microsoft.Xna.Framework.Rectangle bounds; + if (Game1.displayFarmer && !Game1.player.swimming && (!Game1.player.isRidingHorse() && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(Game1.player.getTileLocation()))) + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D shadowTexture = Game1.shadowTexture; + Vector2 local = Game1.GlobalToLocal(Game1.player.position + new Vector2(32f, 24f)); + Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds); + Color white = Color.White; + double num1 = 0.0; + double x = (double)Game1.shadowTexture.Bounds.Center.X; + bounds = Game1.shadowTexture.Bounds; + double y = (double)bounds.Center.Y; + Vector2 origin = new Vector2((float)x, (float)y); + double num2 = 4.0 - (!Game1.player.running && !Game1.player.usingTool || Game1.player.FarmerSprite.indexInCurrentAnimation <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[Game1.player.FarmerSprite.CurrentFrame]) * 0.5); + int num3 = 0; + double num4 = 0.0; + spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, (float)num1, origin, (float)num2, (SpriteEffects)num3, (float)num4); + } + Game1.currentLocation.Map.GetLayer("Buildings").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, Game1.pixelZoom); + Game1.mapDisplayDevice.EndScene(); + Game1.spriteBatch.End(); + Game1.spriteBatch.Begin(SpriteSortMode.FrontToBack, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + if (Game1.CurrentEvent == null) + { + foreach (NPC character in Game1.currentLocation.characters) + { + if (!character.swimming && !character.hideShadow && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(character.getTileLocation())) + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D shadowTexture = Game1.shadowTexture; + Vector2 local = Game1.GlobalToLocal(Game1.viewport, character.position + new Vector2((float)(character.sprite.spriteWidth * Game1.pixelZoom) / 2f, (float)(character.GetBoundingBox().Height + (character.IsMonster ? 0 : Game1.pixelZoom * 3)))); + Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds); + Color white = Color.White; + double num1 = 0.0; + bounds = Game1.shadowTexture.Bounds; + double x = (double)bounds.Center.X; + bounds = Game1.shadowTexture.Bounds; + double y = (double)bounds.Center.Y; + Vector2 origin = new Vector2((float)x, (float)y); + double num2 = ((double)Game1.pixelZoom + (double)character.yJumpOffset / 40.0) * (double)character.scale; + int num3 = 0; + double num4 = (double)Math.Max(0.0f, (float)character.getStandingY() / 10000f) - 9.99999997475243E-07; + spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, (float)num1, origin, (float)num2, (SpriteEffects)num3, (float)num4); + } + } + } + else + { + foreach (NPC actor in Game1.CurrentEvent.actors) + { + if (!actor.swimming && !actor.hideShadow && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(actor.getTileLocation())) + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D shadowTexture = Game1.shadowTexture; + Vector2 local = Game1.GlobalToLocal(Game1.viewport, actor.position + new Vector2((float)(actor.sprite.spriteWidth * Game1.pixelZoom) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : Game1.pixelZoom * 3)))); + Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds); + Color white = Color.White; + double num1 = 0.0; + bounds = Game1.shadowTexture.Bounds; + double x = (double)bounds.Center.X; + bounds = Game1.shadowTexture.Bounds; + double y = (double)bounds.Center.Y; + Vector2 origin = new Vector2((float)x, (float)y); + double num2 = ((double)Game1.pixelZoom + (double)actor.yJumpOffset / 40.0) * (double)actor.scale; + int num3 = 0; + double num4 = (double)Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 9.99999997475243E-07; + spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, (float)num1, origin, (float)num2, (SpriteEffects)num3, (float)num4); + } + } + } + if (Game1.displayFarmer && !Game1.player.swimming && (!Game1.player.isRidingHorse() && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(Game1.player.getTileLocation()))) + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D shadowTexture = Game1.shadowTexture; + Vector2 local = Game1.GlobalToLocal(Game1.player.position + new Vector2(32f, 24f)); + Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds); + Color white = Color.White; + double num1 = 0.0; + double x = (double)Game1.shadowTexture.Bounds.Center.X; + rectangle = Game1.shadowTexture.Bounds; + double y = (double)rectangle.Center.Y; + Vector2 origin = new Vector2((float)x, (float)y); + double num2 = 4.0 - (!Game1.player.running && !Game1.player.usingTool || Game1.player.FarmerSprite.indexInCurrentAnimation <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[Game1.player.FarmerSprite.CurrentFrame]) * 0.5); + int num3 = 0; + double num4 = (double)Math.Max(0.0001f, (float)((double)Game1.player.getStandingY() / 10000.0 + 0.000110000000859145)) - 9.99999974737875E-05; + spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, (float)num1, origin, (float)num2, (SpriteEffects)num3, (float)num4); + } + if (Game1.displayFarmer) + Game1.player.draw(Game1.spriteBatch); + if ((Game1.eventUp || Game1.killScreen) && (!Game1.killScreen && Game1.currentLocation.currentEvent != null)) + Game1.currentLocation.currentEvent.draw(Game1.spriteBatch); + if (Game1.player.currentUpgrade != null && Game1.player.currentUpgrade.daysLeftTillUpgradeDone <= 3 && Game1.currentLocation.Name.Equals("Farm")) + Game1.spriteBatch.Draw(Game1.player.currentUpgrade.workerTexture, Game1.GlobalToLocal(Game1.viewport, Game1.player.currentUpgrade.positionOfCarpenter), new Microsoft.Xna.Framework.Rectangle?(Game1.player.currentUpgrade.getSourceRectangle()), Color.White, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, (float)(((double)Game1.player.currentUpgrade.positionOfCarpenter.Y + (double)(Game1.tileSize * 3 / 4)) / 10000.0)); + Game1.currentLocation.draw(Game1.spriteBatch); + if (Game1.eventUp && Game1.currentLocation.currentEvent != null) + { + string messageToScreen = Game1.currentLocation.currentEvent.messageToScreen; + } + if (Game1.player.ActiveObject == null && (Game1.player.UsingTool || Game1.pickingTool) && (Game1.player.CurrentTool != null && (!Game1.player.CurrentTool.Name.Equals("Seeds") || Game1.pickingTool))) + Game1.drawTool(Game1.player); + if (Game1.currentLocation.Name.Equals("Farm")) + this.drawFarmBuildings(); + if (Game1.tvStation >= 0) + Game1.spriteBatch.Draw(Game1.tvStationTexture, Game1.GlobalToLocal(Game1.viewport, new Vector2((float)(6 * Game1.tileSize + Game1.tileSize / 4), (float)(2 * Game1.tileSize + Game1.tileSize / 2))), new Microsoft.Xna.Framework.Rectangle?(new Microsoft.Xna.Framework.Rectangle(Game1.tvStation * 24, 0, 24, 15)), Color.White, 0.0f, Vector2.Zero, 4f, SpriteEffects.None, 1E-08f); + if (Game1.panMode) + { + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle((int)Math.Floor((double)(Game1.getOldMouseX() + Game1.viewport.X) / (double)Game1.tileSize) * Game1.tileSize - Game1.viewport.X, (int)Math.Floor((double)(Game1.getOldMouseY() + Game1.viewport.Y) / (double)Game1.tileSize) * Game1.tileSize - Game1.viewport.Y, Game1.tileSize, Game1.tileSize), Color.Lime * 0.75f); + foreach (Warp warp in Game1.currentLocation.warps) + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle(warp.X * Game1.tileSize - Game1.viewport.X, warp.Y * Game1.tileSize - Game1.viewport.Y, Game1.tileSize, Game1.tileSize), Color.Red * 0.75f); + } + Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch); + Game1.currentLocation.Map.GetLayer("Front").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, Game1.pixelZoom); + Game1.mapDisplayDevice.EndScene(); + Game1.currentLocation.drawAboveFrontLayer(Game1.spriteBatch); + Game1.spriteBatch.End(); + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + if (Game1.currentLocation.Name.Equals("Farm") && Game1.stats.SeedsSown >= 200U) + { + Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2((float)(3 * Game1.tileSize + Game1.tileSize / 4), (float)(Game1.tileSize + Game1.tileSize / 3))), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16, -1, -1)), Color.White); + Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2((float)(4 * Game1.tileSize + Game1.tileSize), (float)(2 * Game1.tileSize + Game1.tileSize))), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16, -1, -1)), Color.White); + Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2((float)(5 * Game1.tileSize), (float)(2 * Game1.tileSize))), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16, -1, -1)), Color.White); + Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2((float)(3 * Game1.tileSize + Game1.tileSize / 2), (float)(3 * Game1.tileSize))), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16, -1, -1)), Color.White); + Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2((float)(5 * Game1.tileSize - Game1.tileSize / 4), (float)Game1.tileSize)), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16, -1, -1)), Color.White); + Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2((float)(4 * Game1.tileSize), (float)(3 * Game1.tileSize + Game1.tileSize / 6))), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16, -1, -1)), Color.White); + Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2((float)(4 * Game1.tileSize + Game1.tileSize / 5), (float)(2 * Game1.tileSize + Game1.tileSize / 3))), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16, -1, -1)), Color.White); + } + if (Game1.displayFarmer && Game1.player.ActiveObject != null && (Game1.player.ActiveObject.bigCraftable && this.checkBigCraftableBoundariesForFrontLayer()) && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), Game1.player.getStandingY()), Game1.viewport.Size) == null) + Game1.drawPlayerHeldObject(Game1.player); + else if (Game1.displayFarmer && Game1.player.ActiveObject != null) + { + if (Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location((int)Game1.player.position.X, (int)Game1.player.position.Y - Game1.tileSize * 3 / 5), Game1.viewport.Size) == null || Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location((int)Game1.player.position.X, (int)Game1.player.position.Y - Game1.tileSize * 3 / 5), Game1.viewport.Size).TileIndexProperties.ContainsKey("FrontAlways")) + { + Layer layer1 = Game1.currentLocation.Map.GetLayer("Front"); + rectangle = Game1.player.GetBoundingBox(); + Location mapDisplayLocation1 = new Location(rectangle.Right, (int)Game1.player.position.Y - Game1.tileSize * 3 / 5); + Size size1 = Game1.viewport.Size; + if (layer1.PickTile(mapDisplayLocation1, size1) != null) + { + Layer layer2 = Game1.currentLocation.Map.GetLayer("Front"); + rectangle = Game1.player.GetBoundingBox(); + Location mapDisplayLocation2 = new Location(rectangle.Right, (int)Game1.player.position.Y - Game1.tileSize * 3 / 5); + Size size2 = Game1.viewport.Size; + if (layer2.PickTile(mapDisplayLocation2, size2).TileIndexProperties.ContainsKey("FrontAlways")) + goto label_127; + } + else + goto label_127; + } + Game1.drawPlayerHeldObject(Game1.player); + } + label_127: + if ((Game1.player.UsingTool || Game1.pickingTool) && Game1.player.CurrentTool != null && ((!Game1.player.CurrentTool.Name.Equals("Seeds") || Game1.pickingTool) && (Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), (int)Game1.player.position.Y - Game1.tileSize * 3 / 5), Game1.viewport.Size) != null && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), Game1.player.getStandingY()), Game1.viewport.Size) == null))) + Game1.drawTool(Game1.player); + if (Game1.currentLocation.Map.GetLayer("AlwaysFront") != null) + { + Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch); + Game1.currentLocation.Map.GetLayer("AlwaysFront").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, Game1.pixelZoom); + Game1.mapDisplayDevice.EndScene(); + } + if ((double)Game1.toolHold > 400.0 && Game1.player.CurrentTool.UpgradeLevel >= 1 && Game1.player.canReleaseTool) + { + Color color = Color.White; + switch ((int)((double)Game1.toolHold / 600.0) + 2) + { + case 1: + color = Tool.copperColor; + break; + case 2: + color = Tool.steelColor; + break; + case 3: + color = Tool.goldColor; + break; + case 4: + color = Tool.iridiumColor; + break; + } + Game1.spriteBatch.Draw(Game1.littleEffect, new Microsoft.Xna.Framework.Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X - 2, (int)Game1.player.getLocalPosition(Game1.viewport).Y - (Game1.player.CurrentTool.Name.Equals("Watering Can") ? 0 : Game1.tileSize) - 2, (int)((double)Game1.toolHold % 600.0 * 0.0799999982118607) + 4, Game1.tileSize / 8 + 4), Color.Black); + Game1.spriteBatch.Draw(Game1.littleEffect, new Microsoft.Xna.Framework.Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X, (int)Game1.player.getLocalPosition(Game1.viewport).Y - (Game1.player.CurrentTool.Name.Equals("Watering Can") ? 0 : Game1.tileSize), (int)((double)Game1.toolHold % 600.0 * 0.0799999982118607), Game1.tileSize / 8), color); + } + if (Game1.isDebrisWeather && Game1.currentLocation.IsOutdoors && (!Game1.currentLocation.ignoreDebrisWeather && !Game1.currentLocation.Name.Equals("Desert")) && Game1.viewport.X > -10) + { + foreach (WeatherDebris weatherDebris in Game1.debrisWeather) + weatherDebris.draw(Game1.spriteBatch); + } + if (Game1.farmEvent != null) + Game1.farmEvent.draw(Game1.spriteBatch); + if ((double)Game1.currentLocation.LightLevel > 0.0 && Game1.timeOfDay < 2000) + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * Game1.currentLocation.LightLevel); + if (Game1.screenGlow) + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Game1.screenGlowColor * Game1.screenGlowAlpha); + Game1.currentLocation.drawAboveAlwaysFrontLayer(Game1.spriteBatch); + if (Game1.player.CurrentTool != null && Game1.player.CurrentTool is FishingRod && ((Game1.player.CurrentTool as FishingRod).isTimingCast || (double)(Game1.player.CurrentTool as FishingRod).castingChosenCountdown > 0.0 || ((Game1.player.CurrentTool as FishingRod).fishCaught || (Game1.player.CurrentTool as FishingRod).showingTreasure))) + Game1.player.CurrentTool.draw(Game1.spriteBatch); + if (Game1.isRaining && Game1.currentLocation.IsOutdoors && (!Game1.currentLocation.Name.Equals("Desert") && !(Game1.currentLocation is Summit)) && (!Game1.eventUp || Game1.currentLocation.isTileOnMap(new Vector2((float)(Game1.viewport.X / Game1.tileSize), (float)(Game1.viewport.Y / Game1.tileSize))))) + { + for (int index = 0; index < Game1.rainDrops.Length; ++index) + Game1.spriteBatch.Draw(Game1.rainTexture, Game1.rainDrops[index].position, new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.rainTexture, Game1.rainDrops[index].frame, -1, -1)), Color.White); + } + Game1.spriteBatch.End(); + //base.Draw(gameTime); + Game1.spriteBatch.Begin(SpriteSortMode.FrontToBack, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + if (Game1.eventUp && Game1.currentLocation.currentEvent != null) + { + foreach (NPC actor in Game1.currentLocation.currentEvent.actors) + { + if (actor.isEmoting) + { + Vector2 localPosition = actor.getLocalPosition(Game1.viewport); + localPosition.Y -= (float)(Game1.tileSize * 2 + Game1.pixelZoom * 3); + if (actor.age == 2) + localPosition.Y += (float)(Game1.tileSize / 2); + else if (actor.gender == 1) + localPosition.Y += (float)(Game1.tileSize / 6); + Game1.spriteBatch.Draw(Game1.emoteSpriteSheet, localPosition, new Microsoft.Xna.Framework.Rectangle?(new Microsoft.Xna.Framework.Rectangle(actor.CurrentEmoteIndex * (Game1.tileSize / 4) % Game1.emoteSpriteSheet.Width, actor.CurrentEmoteIndex * (Game1.tileSize / 4) / Game1.emoteSpriteSheet.Width * (Game1.tileSize / 4), Game1.tileSize / 4, Game1.tileSize / 4)), Color.White, 0.0f, Vector2.Zero, 4f, SpriteEffects.None, (float)actor.getStandingY() / 10000f); + } + } + } + Game1.spriteBatch.End(); + if (Game1.drawLighting) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, this.lightingBlend, SamplerState.LinearClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.spriteBatch.Draw((Texture2D)Game1.lightmap, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(Game1.lightmap.Bounds), Color.White, 0.0f, Vector2.Zero, (float)(Game1.options.lightingQuality / 2), SpriteEffects.None, 1f); + if (Game1.isRaining && Game1.currentLocation.isOutdoors && !(Game1.currentLocation is Desert)) + Game1.spriteBatch.Draw(Game1.staminaRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.OrangeRed * 0.45f); + Game1.spriteBatch.End(); + } + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + if (Game1.drawGrid) + { + int x1 = -Game1.viewport.X % Game1.tileSize; + float num1 = (float)(-Game1.viewport.Y % Game1.tileSize); + int x2 = x1; + while (x2 < Game1.graphics.GraphicsDevice.Viewport.Width) + { + Game1.spriteBatch.Draw(Game1.staminaRect, new Microsoft.Xna.Framework.Rectangle(x2, (int)num1, 1, Game1.graphics.GraphicsDevice.Viewport.Height), Color.Red * 0.5f); + x2 += Game1.tileSize; + } + float num2 = num1; + while ((double)num2 < (double)Game1.graphics.GraphicsDevice.Viewport.Height) + { + Game1.spriteBatch.Draw(Game1.staminaRect, new Microsoft.Xna.Framework.Rectangle(x1, (int)num2, Game1.graphics.GraphicsDevice.Viewport.Width, 1), Color.Red * 0.5f); + num2 += (float)Game1.tileSize; + } + } + if (Game1.currentBillboard != 0) + this.drawBillboard(); + if ((Game1.displayHUD || Game1.eventUp) && (Game1.currentBillboard == 0 && (int)Game1.gameMode == 3) && (!Game1.freezeControls && !Game1.panMode)) + { + GraphicsEvents.InvokeOnPreRenderHudEvent(this.Monitor); + this.drawHUD(); + GraphicsEvents.InvokeOnPostRenderHudEvent(this.Monitor); + } + else if (Game1.activeClickableMenu == null && Game1.farmEvent == null) + Game1.spriteBatch.Draw(Game1.mouseCursors, new Vector2((float)Game1.getOldMouseX(), (float)Game1.getOldMouseY()), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.mouseCursors, 0, 16, 16)), Color.White, 0.0f, Vector2.Zero, (float)(4.0 + (double)Game1.dialogueButtonScale / 150.0), SpriteEffects.None, 1f); + if (Game1.hudMessages.Count > 0 && (!Game1.eventUp || Game1.isFestival())) + { + for (int i = Game1.hudMessages.Count - 1; i >= 0; --i) + Game1.hudMessages[i].draw(Game1.spriteBatch, i); + } + } + if (Game1.farmEvent != null) + Game1.farmEvent.draw(Game1.spriteBatch); + if (Game1.dialogueUp && !Game1.nameSelectUp && !Game1.messagePause && (Game1.activeClickableMenu == null || !(Game1.activeClickableMenu is DialogueBox))) + this.drawDialogueBox(); + Viewport viewport; + if (Game1.progressBar) + { + SpriteBatch spriteBatch1 = Game1.spriteBatch; + Texture2D fadeToBlackRect = Game1.fadeToBlackRect; + int x1 = (Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Width - Game1.dialogueWidth) / 2; + rectangle = Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea; + int y1 = rectangle.Bottom - Game1.tileSize * 2; + int dialogueWidth = Game1.dialogueWidth; + int height1 = Game1.tileSize / 2; + Microsoft.Xna.Framework.Rectangle destinationRectangle1 = new Microsoft.Xna.Framework.Rectangle(x1, y1, dialogueWidth, height1); + Color lightGray = Color.LightGray; + spriteBatch1.Draw(fadeToBlackRect, destinationRectangle1, lightGray); + SpriteBatch spriteBatch2 = Game1.spriteBatch; + Texture2D staminaRect = Game1.staminaRect; + viewport = Game1.graphics.GraphicsDevice.Viewport; + int x2 = (viewport.TitleSafeArea.Width - Game1.dialogueWidth) / 2; + viewport = Game1.graphics.GraphicsDevice.Viewport; + rectangle = viewport.TitleSafeArea; + int y2 = rectangle.Bottom - Game1.tileSize * 2; + int width = (int)((double)Game1.pauseAccumulator / (double)Game1.pauseTime * (double)Game1.dialogueWidth); + int height2 = Game1.tileSize / 2; + Microsoft.Xna.Framework.Rectangle destinationRectangle2 = new Microsoft.Xna.Framework.Rectangle(x2, y2, width, height2); + Color dimGray = Color.DimGray; + spriteBatch2.Draw(staminaRect, destinationRectangle2, dimGray); + } + if (Game1.eventUp && Game1.currentLocation != null && Game1.currentLocation.currentEvent != null) + Game1.currentLocation.currentEvent.drawAfterMap(Game1.spriteBatch); + if (Game1.isRaining && Game1.currentLocation != null && (Game1.currentLocation.isOutdoors && !(Game1.currentLocation is Desert))) + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D staminaRect = Game1.staminaRect; + viewport = Game1.graphics.GraphicsDevice.Viewport; + Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; + Color color = Color.Blue * 0.2f; + spriteBatch.Draw(staminaRect, bounds, color); + } + if ((Game1.fadeToBlack || Game1.globalFade) && !Game1.menuUp && (!Game1.nameSelectUp || Game1.messagePause)) + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D fadeToBlackRect = Game1.fadeToBlackRect; + viewport = Game1.graphics.GraphicsDevice.Viewport; + Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; + Color color = Color.Black * ((int)Game1.gameMode == 0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha); + spriteBatch.Draw(fadeToBlackRect, bounds, color); + } + else if ((double)Game1.flashAlpha > 0.0) + { + if (Game1.options.screenFlash) + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D fadeToBlackRect = Game1.fadeToBlackRect; + viewport = Game1.graphics.GraphicsDevice.Viewport; + Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; + Color color = Color.White * Math.Min(1f, Game1.flashAlpha); + spriteBatch.Draw(fadeToBlackRect, bounds, color); + } + Game1.flashAlpha -= 0.1f; + } + if ((Game1.messagePause || Game1.globalFade) && Game1.dialogueUp) + this.drawDialogueBox(); + foreach (TemporaryAnimatedSprite overlayTempSprite in Game1.screenOverlayTempSprites) + overlayTempSprite.draw(Game1.spriteBatch, true, 0, 0); + if (Game1.debugMode) + { + SpriteBatch spriteBatch = Game1.spriteBatch; + SpriteFont smallFont = Game1.smallFont; + object[] objArray = new object[10]; + int index1 = 0; + string str1; + if (!Game1.panMode) + str1 = "player: " + (object)(Game1.player.getStandingX() / Game1.tileSize) + ", " + (object)(Game1.player.getStandingY() / Game1.tileSize); + else + str1 = ((Game1.getOldMouseX() + Game1.viewport.X) / Game1.tileSize).ToString() + "," + (object)((Game1.getOldMouseY() + Game1.viewport.Y) / Game1.tileSize); + objArray[index1] = (object)str1; + int index2 = 1; + string str2 = " mouseTransparency: "; + objArray[index2] = (object)str2; + int index3 = 2; + float cursorTransparency = Game1.mouseCursorTransparency; + objArray[index3] = (object)cursorTransparency; + int index4 = 3; + string str3 = " mousePosition: "; + objArray[index4] = (object)str3; + int index5 = 4; + int mouseX = Game1.getMouseX(); + objArray[index5] = (object)mouseX; + int index6 = 5; + string str4 = ","; + objArray[index6] = (object)str4; + int index7 = 6; + int mouseY = Game1.getMouseY(); + objArray[index7] = (object)mouseY; + int index8 = 7; + string newLine = Environment.NewLine; + objArray[index8] = (object)newLine; + int index9 = 8; + string str5 = "debugOutput: "; + objArray[index9] = (object)str5; + int index10 = 9; + string debugOutput = Game1.debugOutput; + objArray[index10] = (object)debugOutput; + string text = string.Concat(objArray); + Vector2 position = new Vector2((float)this.GraphicsDevice.Viewport.TitleSafeArea.X, (float)this.GraphicsDevice.Viewport.TitleSafeArea.Y); + Color red = Color.Red; + double num1 = 0.0; + Vector2 zero = Vector2.Zero; + double num2 = 1.0; + int num3 = 0; + double num4 = 0.99999988079071; + spriteBatch.DrawString(smallFont, text, position, red, (float)num1, zero, (float)num2, (SpriteEffects)num3, (float)num4); + } + if (Game1.showKeyHelp) + Game1.spriteBatch.DrawString(Game1.smallFont, Game1.keyHelpString, new Vector2((float)Game1.tileSize, (float)(Game1.viewport.Height - Game1.tileSize - (Game1.dialogueUp ? Game1.tileSize * 3 + (Game1.isQuestion ? Game1.questionChoices.Count * Game1.tileSize : 0) : 0)) - Game1.smallFont.MeasureString(Game1.keyHelpString).Y), Color.LightGray, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f); + if (Game1.activeClickableMenu != null) + { + try + { + GraphicsEvents.InvokeOnPreRenderGuiEvent(this.Monitor); + Game1.activeClickableMenu.draw(Game1.spriteBatch); + GraphicsEvents.InvokeOnPostRenderGuiEvent(this.Monitor); + } + catch (Exception ex) + { + this.Monitor.Log($"The {Game1.activeClickableMenu.GetType().FullName} menu crashed while drawing itself. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); + Game1.activeClickableMenu.exitThisMenu(); + } + } + else if (Game1.farmEvent != null) + Game1.farmEvent.drawAboveEverything(Game1.spriteBatch); + Game1.spriteBatch.End(); + if (Game1.overlayMenu != null) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.overlayMenu.draw(Game1.spriteBatch); + Game1.spriteBatch.End(); + } + + if (GraphicsEvents.HasPostRenderListeners()) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + GraphicsEvents.InvokeOnPostRenderEvent(this.Monitor); + Game1.spriteBatch.End(); + } + + this.renderScreenBuffer(); + } + } + } + } + + /**** + ** Methods + ****/ + /// <summary>Perform any cleanup needed when the player unloads a save and returns to the title screen.</summary> + private void CleanupAfterReturnToTitle() + { + Context.IsWorldReady = false; + this.AfterLoadTimer = 5; + this.PreviousSaveID = 0; + } + + /// <summary>Get the buttons pressed in the given stats.</summary> + /// <param name="keyboard">The keyboard state.</param> + /// <param name="mouse">The mouse state.</param> + /// <param name="controller">The controller state.</param> + private IEnumerable<SButton> GetPressedButtons(KeyboardState keyboard, MouseState mouse, GamePadState controller) + { + // keyboard + foreach (Keys key in keyboard.GetPressedKeys()) + yield return key.ToSButton(); + + // mouse + if (mouse.LeftButton == ButtonState.Pressed) + yield return SButton.MouseLeft; + if (mouse.RightButton == ButtonState.Pressed) + yield return SButton.MouseRight; + if (mouse.MiddleButton == ButtonState.Pressed) + yield return SButton.MouseMiddle; + if (mouse.XButton1 == ButtonState.Pressed) + yield return SButton.MouseX1; + if (mouse.XButton2 == ButtonState.Pressed) + yield return SButton.MouseX2; + + // controller + if (controller.IsConnected) + { + if (controller.Buttons.A == ButtonState.Pressed) + yield return SButton.ControllerA; + if (controller.Buttons.B == ButtonState.Pressed) + yield return SButton.ControllerB; + if (controller.Buttons.Back == ButtonState.Pressed) + yield return SButton.ControllerBack; + if (controller.Buttons.BigButton == ButtonState.Pressed) + yield return SButton.BigButton; + if (controller.Buttons.LeftShoulder == ButtonState.Pressed) + yield return SButton.LeftShoulder; + if (controller.Buttons.LeftStick == ButtonState.Pressed) + yield return SButton.LeftStick; + if (controller.Buttons.RightShoulder == ButtonState.Pressed) + yield return SButton.RightShoulder; + if (controller.Buttons.RightStick == ButtonState.Pressed) + yield return SButton.RightStick; + if (controller.Buttons.Start == ButtonState.Pressed) + yield return SButton.ControllerStart; + if (controller.Buttons.X == ButtonState.Pressed) + yield return SButton.ControllerX; + if (controller.Buttons.Y == ButtonState.Pressed) + yield return SButton.ControllerY; + if (controller.DPad.Up == ButtonState.Pressed) + yield return SButton.DPadUp; + if (controller.DPad.Down == ButtonState.Pressed) + yield return SButton.DPadDown; + if (controller.DPad.Left == ButtonState.Pressed) + yield return SButton.DPadLeft; + if (controller.DPad.Right == ButtonState.Pressed) + yield return SButton.DPadRight; + if (controller.Triggers.Left > 0.2f) + yield return SButton.LeftTrigger; + if (controller.Triggers.Right > 0.2f) + yield return SButton.RightTrigger; + } + } + + /// <summary>Get the player inventory changes between two states.</summary> + /// <param name="current">The player's current inventory.</param> + /// <param name="previous">The player's previous inventory.</param> + private IEnumerable<ItemStackChange> GetInventoryChanges(IEnumerable<Item> current, IDictionary<Item, int> previous) + { + current = current.Where(n => n != null).ToArray(); + foreach (Item item in current) + { + // stack size changed + if (previous != null && previous.ContainsKey(item)) + { + if (previous[item] != item.Stack) + yield return new ItemStackChange { Item = item, StackChange = item.Stack - previous[item], ChangeType = ChangeType.StackChange }; + } + + // new item + else + yield return new ItemStackChange { Item = item, StackChange = item.Stack, ChangeType = ChangeType.Added }; + } + + // removed items + if (previous != null) + { + foreach (var entry in previous) + { + if (current.Any(i => i == entry.Key)) + continue; + + yield return new ItemStackChange { Item = entry.Key, StackChange = -entry.Key.Stack, ChangeType = ChangeType.Removed }; + } + } + } + + /// <summary>Get a hash value for an enumeration.</summary> + /// <param name="enumerable">The enumeration of items to hash.</param> + private int GetHash(IEnumerable enumerable) + { + int hash = 0; + foreach (object v in enumerable) + hash ^= v.GetHashCode(); + return hash; + } + } +} diff --git a/src/StardewModdingAPI/Framework/Serialisation/JsonHelper.cs b/src/StardewModdingAPI/Framework/Serialisation/JsonHelper.cs new file mode 100644 index 00000000..3193aa3c --- /dev/null +++ b/src/StardewModdingAPI/Framework/Serialisation/JsonHelper.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Microsoft.Xna.Framework.Input; +using Newtonsoft.Json; +using StardewModdingAPI.Utilities; + +namespace StardewModdingAPI.Framework.Serialisation +{ + /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> + internal class JsonHelper + { + /********* + ** Accessors + *********/ + /// <summary>The JSON settings to use when serialising and deserialising files.</summary> + private readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings + { + Formatting = Formatting.Indented, + ObjectCreationHandling = ObjectCreationHandling.Replace, // avoid issue where default ICollection<T> values are duplicated each time the config is loaded + Converters = new List<JsonConverter> + { + new SelectiveStringEnumConverter(typeof(Buttons), typeof(Keys), typeof(SButton)) + } + }; + + + /********* + ** Public methods + *********/ + /// <summary>Read a JSON file.</summary> + /// <typeparam name="TModel">The model type.</typeparam> + /// <param name="fullPath">The absolete file path.</param> + /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + /// <exception cref="InvalidOperationException">The given path is empty or invalid.</exception> + public TModel ReadJsonFile<TModel>(string fullPath) + where TModel : class + { + // validate + if (string.IsNullOrWhiteSpace(fullPath)) + throw new ArgumentException("The file path is empty or invalid.", nameof(fullPath)); + + // read file + string json; + try + { + json = File.ReadAllText(fullPath); + } + catch (Exception ex) when (ex is DirectoryNotFoundException || ex is FileNotFoundException) + { + return null; + } + + // deserialise model + try + { + return JsonConvert.DeserializeObject<TModel>(json, this.JsonSettings); + } + catch (JsonReaderException ex) + { + string message = $"The file at {fullPath} doesn't seem to be valid JSON."; + + string text = File.ReadAllText(fullPath); + if (text.Contains("“") || text.Contains("”")) + message += " Found curly quotes in the text; note that only straight quotes are allowed in JSON."; + + message += $"\nTechnical details: {ex.Message}"; + throw new JsonReaderException(message); + } + } + + /// <summary>Save to a JSON file.</summary> + /// <typeparam name="TModel">The model type.</typeparam> + /// <param name="fullPath">The absolete file path.</param> + /// <param name="model">The model to save.</param> + /// <exception cref="InvalidOperationException">The given path is empty or invalid.</exception> + public void WriteJsonFile<TModel>(string fullPath, TModel model) + where TModel : class + { + // validate + if (string.IsNullOrWhiteSpace(fullPath)) + throw new ArgumentException("The file path is empty or invalid.", nameof(fullPath)); + + // create directory if needed + string dir = Path.GetDirectoryName(fullPath); + if (dir == null) + throw new ArgumentException("The file path is invalid.", nameof(fullPath)); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + // write file + string json = JsonConvert.SerializeObject(model, this.JsonSettings); + File.WriteAllText(fullPath, json); + } + } +} diff --git a/src/StardewModdingAPI/Framework/Serialisation/SFieldConverter.cs b/src/StardewModdingAPI/Framework/Serialisation/SFieldConverter.cs new file mode 100644 index 00000000..917c950d --- /dev/null +++ b/src/StardewModdingAPI/Framework/Serialisation/SFieldConverter.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using StardewModdingAPI.Framework.Exceptions; +using StardewModdingAPI.Framework.Models; + +namespace StardewModdingAPI.Framework.Serialisation +{ + /// <summary>Overrides how SMAPI reads and writes <see cref="ISemanticVersion"/> and <see cref="IManifestDependency"/> fields.</summary> + internal class SFieldConverter : JsonConverter + { + /********* + ** Accessors + *********/ + /// <summary>Whether this converter can write JSON.</summary> + public override bool CanWrite => false; + + + /********* + ** Public methods + *********/ + /// <summary>Get whether this instance can convert the specified object type.</summary> + /// <param name="objectType">The object type.</param> + public override bool CanConvert(Type objectType) + { + return + objectType == typeof(ISemanticVersion) + || objectType == typeof(IManifestDependency[]) + || objectType == typeof(ModDataID) + || objectType == typeof(ModCompatibility[]); + } + + /// <summary>Reads the JSON representation of the object.</summary> + /// <param name="reader">The JSON reader.</param> + /// <param name="objectType">The object type.</param> + /// <param name="existingValue">The object being read.</param> + /// <param name="serializer">The calling serializer.</param> + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + // semantic version + if (objectType == typeof(ISemanticVersion)) + { + JToken token = JToken.Load(reader); + switch (token.Type) + { + case JTokenType.Object: + { + JObject obj = (JObject)token; + int major = obj.Value<int>(nameof(ISemanticVersion.MajorVersion)); + int minor = obj.Value<int>(nameof(ISemanticVersion.MinorVersion)); + int patch = obj.Value<int>(nameof(ISemanticVersion.PatchVersion)); + string build = obj.Value<string>(nameof(ISemanticVersion.Build)); + return new SemanticVersion(major, minor, patch, build); + } + + case JTokenType.String: + { + string str = token.Value<string>(); + if (string.IsNullOrWhiteSpace(str)) + return null; + if (!SemanticVersion.TryParse(str, out ISemanticVersion version)) + throw new SParseException($"Can't parse semantic version from invalid value '{str}', should be formatted like 1.2, 1.2.30, or 1.2.30-beta."); + return version; + } + + default: + throw new SParseException($"Can't parse semantic version from {token.Type}, must be an object or string."); + } + } + + // manifest dependencies + if (objectType == typeof(IManifestDependency[])) + { + List<IManifestDependency> result = new List<IManifestDependency>(); + foreach (JObject obj in JArray.Load(reader).Children<JObject>()) + { + string uniqueID = obj.Value<string>(nameof(IManifestDependency.UniqueID)); + string minVersion = obj.Value<string>(nameof(IManifestDependency.MinimumVersion)); + bool required = obj.Value<bool?>(nameof(IManifestDependency.IsRequired)) ?? true; + result.Add(new ManifestDependency(uniqueID, minVersion, required)); + } + return result.ToArray(); + } + + // mod data ID + if (objectType == typeof(ModDataID)) + { + JToken token = JToken.Load(reader); + return new ModDataID(token.Value<string>()); + } + + // mod compatibility records + if (objectType == typeof(ModCompatibility[])) + { + List<ModCompatibility> result = new List<ModCompatibility>(); + foreach (JProperty property in JObject.Load(reader).Properties()) + { + string range = property.Name; + ModStatus status = (ModStatus)Enum.Parse(typeof(ModStatus), property.Value.Value<string>(nameof(ModCompatibility.Status))); + string reasonPhrase = property.Value.Value<string>(nameof(ModCompatibility.ReasonPhrase)); + + result.Add(new ModCompatibility(range, status, reasonPhrase)); + } + return result.ToArray(); + } + + // unknown + throw new NotSupportedException($"Unknown type '{objectType?.FullName}'."); + } + + /// <summary>Writes the JSON representation of the object.</summary> + /// <param name="writer">The JSON writer.</param> + /// <param name="value">The value.</param> + /// <param name="serializer">The calling serializer.</param> + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new InvalidOperationException("This converter does not write JSON."); + } + } +} diff --git a/src/StardewModdingAPI/Framework/Serialisation/SelectiveStringEnumConverter.cs b/src/StardewModdingAPI/Framework/Serialisation/SelectiveStringEnumConverter.cs new file mode 100644 index 00000000..37108556 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Serialisation/SelectiveStringEnumConverter.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Converters; + +namespace StardewModdingAPI.Framework.Serialisation +{ + /// <summary>A variant of <see cref="StringEnumConverter"/> which only converts certain enums.</summary> + internal class SelectiveStringEnumConverter : StringEnumConverter + { + /********* + ** Properties + *********/ + /// <summary>The enum type names to convert.</summary> + private readonly HashSet<string> Types; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="types">The enum types to convert.</param> + public SelectiveStringEnumConverter(params Type[] types) + { + this.Types = new HashSet<string>(types.Select(p => p.FullName)); + } + + /// <summary>Get whether this instance can convert the specified object type.</summary> + /// <param name="type">The object type.</param> + public override bool CanConvert(Type type) + { + return + base.CanConvert(type) + && this.Types.Contains((Nullable.GetUnderlyingType(type) ?? type).FullName); + } + } +} diff --git a/src/StardewModdingAPI/Framework/Utilities/ContextHash.cs b/src/StardewModdingAPI/Framework/Utilities/ContextHash.cs new file mode 100644 index 00000000..6c0fdc90 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Utilities/ContextHash.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; + +namespace StardewModdingAPI.Framework.Utilities +{ + /// <summary>A <see cref="HashSet{T}"/> wrapper meant for tracking recursive contexts.</summary> + /// <typeparam name="T">The key type.</typeparam> + internal class ContextHash<T> : HashSet<T> + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public ContextHash() { } + + /// <summary>Construct an instance.</summary> + /// <param name="comparer">The <see cref="IEqualityComparer{T}"/> implementation to use when comparing values in the set, or <c>null</c> to use the default comparer for the set type.</param> + public ContextHash(IEqualityComparer<T> comparer) + : base(comparer) { } + + /// <summary>Add a key while an action is in progress, and remove it when it completes.</summary> + /// <param name="key">The key to add.</param> + /// <param name="action">The action to perform.</param> + /// <exception cref="InvalidOperationException">The specified key is already added.</exception> + public void Track(T key, Action action) + { + if (this.Contains(key)) + throw new InvalidOperationException($"Can't track context for key {key} because it's already added."); + + this.Add(key); + try + { + action(); + } + finally + { + this.Remove(key); + } + } + + /// <summary>Add a key while an action is in progress, and remove it when it completes.</summary> + /// <typeparam name="TResult">The value type returned by the method.</typeparam> + /// <param name="key">The key to add.</param> + /// <param name="action">The action to perform.</param> + public TResult Track<TResult>(T key, Func<TResult> action) + { + if (this.Contains(key)) + throw new InvalidOperationException($"Can't track context for key {key} because it's already added."); + + this.Add(key); + try + { + return action(); + } + finally + { + this.Remove(key); + } + } + } +} diff --git a/src/StardewModdingAPI/Framework/Utilities/Countdown.cs b/src/StardewModdingAPI/Framework/Utilities/Countdown.cs new file mode 100644 index 00000000..921a35ce --- /dev/null +++ b/src/StardewModdingAPI/Framework/Utilities/Countdown.cs @@ -0,0 +1,44 @@ +namespace StardewModdingAPI.Framework.Utilities +{ + /// <summary>Counts down from a baseline value.</summary> + internal class Countdown + { + /********* + ** Accessors + *********/ + /// <summary>The initial value from which to count down.</summary> + public int Initial { get; } + + /// <summary>The current value.</summary> + public int Current { get; private set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="initial">The initial value from which to count down.</param> + public Countdown(int initial) + { + this.Initial = initial; + this.Current = initial; + } + + /// <summary>Reduce the current value by one.</summary> + /// <returns>Returns whether the value was decremented (i.e. wasn't already zero).</returns> + public bool Decrement() + { + if (this.Current <= 0) + return false; + + this.Current--; + return true; + } + + /// <summary>Restart the countdown.</summary> + public void Reset() + { + this.Current = this.Initial; + } + } +} diff --git a/src/StardewModdingAPI/Framework/WebApiClient.cs b/src/StardewModdingAPI/Framework/WebApiClient.cs new file mode 100644 index 00000000..f3c7de28 --- /dev/null +++ b/src/StardewModdingAPI/Framework/WebApiClient.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Net; +using Newtonsoft.Json; +using StardewModdingAPI.Models; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Provides methods for interacting with the SMAPI web API.</summary> + internal class WebApiClient + { + /********* + ** Properties + *********/ + /// <summary>The base URL for the web API.</summary> + private readonly Uri BaseUrl; + + /// <summary>The API version number.</summary> + private readonly ISemanticVersion Version; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="baseUrl">The base URL for the web API.</param> + /// <param name="version">The web API version.</param> + public WebApiClient(string baseUrl, ISemanticVersion version) + { +#if !SMAPI_FOR_WINDOWS + baseUrl = baseUrl.Replace("https://", "http://"); // workaround for OpenSSL issues with the game's bundled Mono on Linux/Mac +#endif + this.BaseUrl = new Uri(baseUrl); + this.Version = version; + } + + /// <summary>Get the latest SMAPI version.</summary> + /// <param name="modKeys">The mod keys for which to fetch the latest version.</param> + public IDictionary<string, ModInfoModel> GetModInfo(params string[] modKeys) + { + return this.Post<ModSearchModel, Dictionary<string, ModInfoModel>>( + $"v{this.Version}/mods", + new ModSearchModel(modKeys) + ); + } + + + /********* + ** Private methods + *********/ + /// <summary>Fetch the response from the backend API.</summary> + /// <typeparam name="TBody">The body content type.</typeparam> + /// <typeparam name="TResult">The expected response type.</typeparam> + /// <param name="url">The request URL, optionally excluding the base URL.</param> + /// <param name="content">The body content to post.</param> + private TResult Post<TBody, TResult>(string url, TBody content) + { + /*** + ** Note: avoid HttpClient for Mac compatibility. + ***/ + using (WebClient client = new WebClient()) + { + Uri fullUrl = new Uri(this.BaseUrl, url); + string data = JsonConvert.SerializeObject(content); + + client.Headers["Content-Type"] = "application/json"; + client.Headers["User-Agent"] = $"SMAPI/{this.Version}"; + string response = client.UploadString(fullUrl, data); + return JsonConvert.DeserializeObject<TResult>(response); + } + } + } +} diff --git a/src/StardewModdingAPI/IAssetData.cs b/src/StardewModdingAPI/IAssetData.cs new file mode 100644 index 00000000..c3021144 --- /dev/null +++ b/src/StardewModdingAPI/IAssetData.cs @@ -0,0 +1,47 @@ +using System; + +namespace StardewModdingAPI +{ + /// <summary>Generic metadata and methods for a content asset being loaded.</summary> + /// <typeparam name="TValue">The expected data type.</typeparam> + public interface IAssetData<TValue> : IAssetInfo + { + /********* + ** Accessors + *********/ + /// <summary>The content data being read.</summary> + TValue Data { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Replace the entire content value with the given value. This is generally not recommended, since it may break compatibility with other mods or different versions of the game.</summary> + /// <param name="value">The new content value.</param> + /// <exception cref="ArgumentNullException">The <paramref name="value"/> is null.</exception> + /// <exception cref="InvalidCastException">The <paramref name="value"/>'s type is not compatible with the loaded asset's type.</exception> + void ReplaceWith(TValue value); + } + + /// <summary>Generic metadata and methods for a content asset being loaded.</summary> + public interface IAssetData : IAssetData<object> + { + /********* + ** Public methods + *********/ + /// <summary>Get a helper to manipulate the data as a dictionary.</summary> + /// <typeparam name="TKey">The expected dictionary key.</typeparam> + /// <typeparam name="TValue">The expected dictionary value.</typeparam> + /// <exception cref="InvalidOperationException">The content being read isn't a dictionary.</exception> + IAssetDataForDictionary<TKey, TValue> AsDictionary<TKey, TValue>(); + + /// <summary>Get a helper to manipulate the data as an image.</summary> + /// <exception cref="InvalidOperationException">The content being read isn't an image.</exception> + IAssetDataForImage AsImage(); + + /// <summary>Get the data as a given type.</summary> + /// <typeparam name="TData">The expected data type.</typeparam> + /// <exception cref="InvalidCastException">The data can't be converted to <typeparamref name="TData"/>.</exception> + TData GetData<TData>(); + } +} diff --git a/src/StardewModdingAPI/IAssetDataForDictionary.cs b/src/StardewModdingAPI/IAssetDataForDictionary.cs new file mode 100644 index 00000000..53c24346 --- /dev/null +++ b/src/StardewModdingAPI/IAssetDataForDictionary.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; + +namespace StardewModdingAPI +{ + /// <summary>Encapsulates access and changes to dictionary content being read from a data file.</summary> + public interface IAssetDataForDictionary<TKey, TValue> : IAssetData<IDictionary<TKey, TValue>> + { + /********* + ** Public methods + *********/ + /// <summary>Add or replace an entry in the dictionary.</summary> + /// <param name="key">The entry key.</param> + /// <param name="value">The entry value.</param> + void Set(TKey key, TValue value); + + /// <summary>Add or replace an entry in the dictionary.</summary> + /// <param name="key">The entry key.</param> + /// <param name="value">A callback which accepts the current value and returns the new value.</param> + void Set(TKey key, Func<TValue, TValue> value); + + /// <summary>Dynamically replace values in the dictionary.</summary> + /// <param name="replacer">A lambda which takes the current key and value for an entry, and returns the new value.</param> + void Set(Func<TKey, TValue, TValue> replacer); + } +} diff --git a/src/StardewModdingAPI/IAssetDataForImage.cs b/src/StardewModdingAPI/IAssetDataForImage.cs new file mode 100644 index 00000000..4584a20e --- /dev/null +++ b/src/StardewModdingAPI/IAssetDataForImage.cs @@ -0,0 +1,23 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace StardewModdingAPI +{ + /// <summary>Encapsulates access and changes to dictionary content being read from a data file.</summary> + public interface IAssetDataForImage : IAssetData<Texture2D> + { + /********* + ** Public methods + *********/ + /// <summary>Overwrite part of the image.</summary> + /// <param name="source">The image to patch into the content.</param> + /// <param name="sourceArea">The part of the <paramref name="source"/> to copy (or <c>null</c> to take the whole texture). This must be within the bounds of the <paramref name="source"/> texture.</param> + /// <param name="targetArea">The part of the content to patch (or <c>null</c> to patch the whole texture). The original content within this area will be erased. This must be within the bounds of the existing spritesheet.</param> + /// <param name="patchMode">Indicates how an image should be patched.</param> + /// <exception cref="ArgumentNullException">One of the arguments is null.</exception> + /// <exception cref="ArgumentOutOfRangeException">The <paramref name="targetArea"/> is outside the bounds of the spritesheet.</exception> + /// <exception cref="InvalidOperationException">The content being read isn't an image.</exception> + void PatchImage(Texture2D source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace); + } +} diff --git a/src/StardewModdingAPI/IAssetEditor.cs b/src/StardewModdingAPI/IAssetEditor.cs new file mode 100644 index 00000000..d2c6f295 --- /dev/null +++ b/src/StardewModdingAPI/IAssetEditor.cs @@ -0,0 +1,17 @@ +namespace StardewModdingAPI +{ + /// <summary>Edits matching content assets.</summary> + public interface IAssetEditor + { + /********* + ** Public methods + *********/ + /// <summary>Get whether this instance can edit the given asset.</summary> + /// <param name="asset">Basic metadata about the asset being loaded.</param> + bool CanEdit<T>(IAssetInfo asset); + + /// <summary>Edit a matched asset.</summary> + /// <param name="asset">A helper which encapsulates metadata about an asset and enables changes to it.</param> + void Edit<T>(IAssetData asset); + } +} diff --git a/src/StardewModdingAPI/IAssetInfo.cs b/src/StardewModdingAPI/IAssetInfo.cs new file mode 100644 index 00000000..5dd58e2e --- /dev/null +++ b/src/StardewModdingAPI/IAssetInfo.cs @@ -0,0 +1,28 @@ +using System; + +namespace StardewModdingAPI +{ + /// <summary>Basic metadata for a content asset.</summary> + public interface IAssetInfo + { + /********* + ** Accessors + *********/ + /// <summary>The content's locale code, if the content is localised.</summary> + string Locale { get; } + + /// <summary>The normalised asset name being read. The format may change between platforms; see <see cref="AssetNameEquals"/> to compare with a known path.</summary> + string AssetName { get; } + + /// <summary>The content data type.</summary> + Type DataType { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Get whether the asset name being loaded matches a given name after normalisation.</summary> + /// <param name="path">The expected asset path, relative to the game's content folder and without the .xnb extension or locale suffix (like 'Data\ObjectInformation').</param> + bool AssetNameEquals(string path); + } +} diff --git a/src/StardewModdingAPI/IAssetLoader.cs b/src/StardewModdingAPI/IAssetLoader.cs new file mode 100644 index 00000000..ad97b941 --- /dev/null +++ b/src/StardewModdingAPI/IAssetLoader.cs @@ -0,0 +1,17 @@ +namespace StardewModdingAPI +{ + /// <summary>Provides the initial version for matching assets loaded by the game. SMAPI will raise an error if two mods try to load the same asset; in most cases you should use <see cref="IAssetEditor"/> instead.</summary> + public interface IAssetLoader + { + /********* + ** Public methods + *********/ + /// <summary>Get whether this instance can load the initial version of the given asset.</summary> + /// <param name="asset">Basic metadata about the asset being loaded.</param> + bool CanLoad<T>(IAssetInfo asset); + + /// <summary>Load a matched asset.</summary> + /// <param name="asset">Basic metadata about the asset being loaded.</param> + T Load<T>(IAssetInfo asset); + } +} diff --git a/src/StardewModdingAPI/ICommandHelper.cs b/src/StardewModdingAPI/ICommandHelper.cs new file mode 100644 index 00000000..fb562e32 --- /dev/null +++ b/src/StardewModdingAPI/ICommandHelper.cs @@ -0,0 +1,26 @@ +using System; + +namespace StardewModdingAPI +{ + /// <summary>Provides an API for managing console commands.</summary> + public interface ICommandHelper : IModLinked + { + /********* + ** Public methods + *********/ + /// <summary>Add a console command.</summary> + /// <param name="name">The command name, which the user must type to trigger it.</param> + /// <param name="documentation">The human-readable documentation shown when the player runs the built-in 'help' command.</param> + /// <param name="callback">The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user.</param> + /// <exception cref="ArgumentNullException">The <paramref name="name"/> or <paramref name="callback"/> is null or empty.</exception> + /// <exception cref="FormatException">The <paramref name="name"/> is not a valid format.</exception> + /// <exception cref="ArgumentException">There's already a command with that name.</exception> + ICommandHelper Add(string name, string documentation, Action<string, string[]> callback); + + /// <summary>Trigger a command.</summary> + /// <param name="name">The command name.</param> + /// <param name="arguments">The command arguments.</param> + /// <returns>Returns whether a matching command was triggered.</returns> + bool Trigger(string name, string[] arguments); + } +} diff --git a/src/StardewModdingAPI/IContentHelper.cs b/src/StardewModdingAPI/IContentHelper.cs new file mode 100644 index 00000000..b78b165b --- /dev/null +++ b/src/StardewModdingAPI/IContentHelper.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace StardewModdingAPI +{ + /// <summary>Provides an API for loading content assets.</summary> + public interface IContentHelper : IModLinked + { + /********* + ** Accessors + *********/ + /// <summary>Interceptors which provide the initial versions of matching content assets.</summary> + IList<IAssetLoader> AssetLoaders { get; } + + /// <summary>Interceptors which edit matching content assets after they're loaded.</summary> + IList<IAssetEditor> AssetEditors { get; } + + /// <summary>The game's current locale code (like <c>pt-BR</c>).</summary> + string CurrentLocale { get; } + + /// <summary>The game's current locale as an enum value.</summary> + LocalizedContentManager.LanguageCode CurrentLocaleConstant { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Load content from the game folder or mod folder (if not already cached), and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary> + /// <typeparam name="T">The expected data type. The main supported types are <see cref="Texture2D"/> and dictionaries; other types may be supported by the game's content pipeline.</typeparam> + /// <param name="key">The asset key to fetch (if the <paramref name="source"/> is <see cref="ContentSource.GameContent"/>), or the local path to a content file relative to the mod folder.</param> + /// <param name="source">Where to search for a matching content asset.</param> + /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> + /// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception> + T Load<T>(string key, ContentSource source = ContentSource.ModFolder); + + /// <summary>Get the underlying key in the game's content cache for an asset. This can be used to load custom map tilesheets, but should be avoided when you can use the content API instead. This does not validate whether the asset exists.</summary> + /// <param name="key">The asset key to fetch (if the <paramref name="source"/> is <see cref="ContentSource.GameContent"/>), or the local path to a content file relative to the mod folder.</param> + /// <param name="source">Where to search for a matching content asset.</param> + /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> + string GetActualAssetKey(string key, ContentSource source = ContentSource.ModFolder); + + /// <summary>Remove an asset from the content cache so it's reloaded on the next request. This will reload core game assets if needed, but references to the former asset will still show the previous content.</summary> + /// <param name="key">The asset key to invalidate in the content folder.</param> + /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> + /// <returns>Returns whether the given asset key was cached.</returns> + bool InvalidateCache(string key); + + /// <summary>Remove all assets of the given type from the cache so they're reloaded on the next request. <b>This can be a very expensive operation and should only be used in very specific cases.</b> This will reload core game assets if needed, but references to the former assets will still show the previous content.</summary> + /// <typeparam name="T">The asset type to remove from the cache.</typeparam> + /// <returns>Returns whether any assets were invalidated.</returns> + bool InvalidateCache<T>(); + } +} diff --git a/src/StardewModdingAPI/ICursorPosition.cs b/src/StardewModdingAPI/ICursorPosition.cs new file mode 100644 index 00000000..ddb8eb49 --- /dev/null +++ b/src/StardewModdingAPI/ICursorPosition.cs @@ -0,0 +1,17 @@ +using Microsoft.Xna.Framework; + +namespace StardewModdingAPI +{ + /// <summary>Represents a cursor position in the different coordinate systems.</summary> + public interface ICursorPosition + { + /// <summary>The pixel position relative to the top-left corner of the visible screen.</summary> + Vector2 ScreenPixels { get; } + + /// <summary>The tile position under the cursor relative to the top-left corner of the map.</summary> + Vector2 Tile { get; } + + /// <summary>The tile position that the game considers under the cursor for purposes of clicking actions. This may be different than <see cref="Tile"/> if that's too far from the player.</summary> + Vector2 GrabTile { get; } + } +} diff --git a/src/StardewModdingAPI/IManifest.cs b/src/StardewModdingAPI/IManifest.cs new file mode 100644 index 00000000..9db1d538 --- /dev/null +++ b/src/StardewModdingAPI/IManifest.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; + +namespace StardewModdingAPI +{ + /// <summary>A manifest which describes a mod for SMAPI.</summary> + public interface IManifest + { + /********* + ** Accessors + *********/ + /// <summary>The mod name.</summary> + string Name { get; } + + /// <summary>A brief description of the mod.</summary> + string Description { get; } + + /// <summary>The mod author's name.</summary> + string Author { get; } + + /// <summary>The mod version.</summary> + ISemanticVersion Version { get; } + + /// <summary>The minimum SMAPI version required by this mod, if any.</summary> + ISemanticVersion MinimumApiVersion { get; } + + /// <summary>The unique mod ID.</summary> + string UniqueID { get; } + + /// <summary>The name of the DLL in the directory that has the <see cref="IMod.Entry"/> method.</summary> + string EntryDll { get; } + + /// <summary>The other mods that must be loaded before this mod.</summary> + IManifestDependency[] Dependencies { get; } + + /// <summary>The namespaced mod IDs to query for updates (like <c>Nexus:541</c>).</summary> + string[] UpdateKeys { get; set; } + + /// <summary>Any manifest fields which didn't match a valid field.</summary> + IDictionary<string, object> ExtraFields { get; } + } +} diff --git a/src/StardewModdingAPI/IManifestDependency.cs b/src/StardewModdingAPI/IManifestDependency.cs new file mode 100644 index 00000000..e86cd1f4 --- /dev/null +++ b/src/StardewModdingAPI/IManifestDependency.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI +{ + /// <summary>A mod dependency listed in a mod manifest.</summary> + public interface IManifestDependency + { + /********* + ** Accessors + *********/ + /// <summary>The unique mod ID to require.</summary> + string UniqueID { get; } + + /// <summary>The minimum required version (if any).</summary> + ISemanticVersion MinimumVersion { get; } + + /// <summary>Whether the dependency must be installed to use the mod.</summary> + bool IsRequired { get; } + } +} diff --git a/src/StardewModdingAPI/IMod.cs b/src/StardewModdingAPI/IMod.cs new file mode 100644 index 00000000..35ac7c0f --- /dev/null +++ b/src/StardewModdingAPI/IMod.cs @@ -0,0 +1,26 @@ +namespace StardewModdingAPI +{ + /// <summary>The implementation for a Stardew Valley mod.</summary> + public interface IMod + { + /********* + ** Accessors + *********/ + /// <summary>Provides simplified APIs for writing mods.</summary> + IModHelper Helper { get; } + + /// <summary>Writes messages to the console and log file.</summary> + IMonitor Monitor { get; } + + /// <summary>The mod's manifest.</summary> + IManifest ModManifest { get; } + + + /********* + ** Public methods + *********/ + /// <summary>The mod entry point, called after the mod is first loaded.</summary> + /// <param name="helper">Provides simplified APIs for writing mods.</param> + void Entry(IModHelper helper); + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/IModHelper.cs b/src/StardewModdingAPI/IModHelper.cs new file mode 100644 index 00000000..116e8508 --- /dev/null +++ b/src/StardewModdingAPI/IModHelper.cs @@ -0,0 +1,58 @@ +namespace StardewModdingAPI +{ + /// <summary>Provides simplified APIs for writing mods.</summary> + public interface IModHelper + { + /********* + ** Accessors + *********/ + /// <summary>The full path to the mod's folder.</summary> + string DirectoryPath { get; } + + /// <summary>An API for loading content assets.</summary> + IContentHelper Content { get; } + + /// <summary>Simplifies access to private game code.</summary> + IReflectionHelper Reflection { get; } + + /// <summary>Metadata about loaded mods.</summary> + IModRegistry ModRegistry { get; } + + /// <summary>An API for managing console commands.</summary> + ICommandHelper ConsoleCommands { get; } + + /// <summary>Provides translations stored in the mod's <c>i18n</c> folder, with one file per locale (like <c>en.json</c>) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like <c>pt-BR.json</c> < <c>pt.json</c> < <c>default.json</c>).</summary> + ITranslationHelper Translation { get; } + + + /********* + ** Public methods + *********/ + /**** + ** Mod config file + ****/ + /// <summary>Read the mod's configuration file (and create it if needed).</summary> + /// <typeparam name="TConfig">The config class type. This should be a plain class that has public properties for the settings you want. These can be complex types.</typeparam> + TConfig ReadConfig<TConfig>() where TConfig : class, new(); + + /// <summary>Save to the mod's configuration file.</summary> + /// <typeparam name="TConfig">The config class type.</typeparam> + /// <param name="config">The config settings to save.</param> + void WriteConfig<TConfig>(TConfig config) where TConfig : class, new(); + + /**** + ** Generic JSON files + ****/ + /// <summary>Read a JSON file.</summary> + /// <typeparam name="TModel">The model type.</typeparam> + /// <param name="path">The file path relative to the mod directory.</param> + /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + TModel ReadJsonFile<TModel>(string path) where TModel : class; + + /// <summary>Save to a JSON file.</summary> + /// <typeparam name="TModel">The model type.</typeparam> + /// <param name="path">The file path relative to the mod directory.</param> + /// <param name="model">The model to save.</param> + void WriteJsonFile<TModel>(string path, TModel model) where TModel : class; + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/IModLinked.cs b/src/StardewModdingAPI/IModLinked.cs new file mode 100644 index 00000000..172ee30c --- /dev/null +++ b/src/StardewModdingAPI/IModLinked.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI +{ + /// <summary>An instance linked to a mod.</summary> + public interface IModLinked + { + /********* + ** Accessors + *********/ + /// <summary>The unique ID of the mod for which the instance was created.</summary> + string ModID { get; } + } +} diff --git a/src/StardewModdingAPI/IModRegistry.cs b/src/StardewModdingAPI/IModRegistry.cs new file mode 100644 index 00000000..5ef3fd65 --- /dev/null +++ b/src/StardewModdingAPI/IModRegistry.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace StardewModdingAPI +{ + /// <summary>Provides an API for fetching metadata about loaded mods.</summary> + public interface IModRegistry : IModLinked + { + /// <summary>Get metadata for all loaded mods.</summary> + IEnumerable<IManifest> GetAll(); + + /// <summary>Get metadata for a loaded mod.</summary> + /// <param name="uniqueID">The mod's unique ID.</param> + /// <returns>Returns the matching mod's metadata, or <c>null</c> if not found.</returns> + IManifest Get(string uniqueID); + + /// <summary>Get whether a mod has been loaded.</summary> + /// <param name="uniqueID">The mod's unique ID.</param> + bool IsLoaded(string uniqueID); + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/IMonitor.cs b/src/StardewModdingAPI/IMonitor.cs new file mode 100644 index 00000000..62c479bc --- /dev/null +++ b/src/StardewModdingAPI/IMonitor.cs @@ -0,0 +1,25 @@ +namespace StardewModdingAPI +{ + /// <summary>Encapsulates monitoring and logging for a given module.</summary> + public interface IMonitor + { + /********* + ** Accessors + *********/ + /// <summary>Whether SMAPI is aborting. Mods don't need to worry about this unless they have background tasks.</summary> + bool IsExiting { get; } + + + /********* + ** Methods + *********/ + /// <summary>Log a message for the player or developer.</summary> + /// <param name="message">The message to log.</param> + /// <param name="level">The log severity level.</param> + void Log(string message, LogLevel level = LogLevel.Debug); + + /// <summary>Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</summary> + /// <param name="reason">The reason for the shutdown.</param> + void ExitGameImmediately(string reason); + } +} diff --git a/src/StardewModdingAPI/IPrivateField.cs b/src/StardewModdingAPI/IPrivateField.cs new file mode 100644 index 00000000..3e681c12 --- /dev/null +++ b/src/StardewModdingAPI/IPrivateField.cs @@ -0,0 +1,26 @@ +using System.Reflection; + +namespace StardewModdingAPI +{ + /// <summary>A private field obtained through reflection.</summary> + /// <typeparam name="TValue">The field value type.</typeparam> + public interface IPrivateField<TValue> + { + /********* + ** Accessors + *********/ + /// <summary>The reflection metadata.</summary> + FieldInfo FieldInfo { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Get the field value.</summary> + TValue GetValue(); + + /// <summary>Set the field value.</summary> + //// <param name="value">The value to set.</param> + void SetValue(TValue value); + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/IPrivateMethod.cs b/src/StardewModdingAPI/IPrivateMethod.cs new file mode 100644 index 00000000..67fc8b3c --- /dev/null +++ b/src/StardewModdingAPI/IPrivateMethod.cs @@ -0,0 +1,27 @@ +using System.Reflection; + +namespace StardewModdingAPI +{ + /// <summary>A private method obtained through reflection.</summary> + public interface IPrivateMethod + { + /********* + ** Accessors + *********/ + /// <summary>The reflection metadata.</summary> + MethodInfo MethodInfo { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Invoke the method.</summary> + /// <typeparam name="TValue">The return type.</typeparam> + /// <param name="arguments">The method arguments to pass in.</param> + TValue Invoke<TValue>(params object[] arguments); + + /// <summary>Invoke the method.</summary> + /// <param name="arguments">The method arguments to pass in.</param> + void Invoke(params object[] arguments); + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/IPrivateProperty.cs b/src/StardewModdingAPI/IPrivateProperty.cs new file mode 100644 index 00000000..8d67fa7a --- /dev/null +++ b/src/StardewModdingAPI/IPrivateProperty.cs @@ -0,0 +1,26 @@ +using System.Reflection; + +namespace StardewModdingAPI +{ + /// <summary>A private property obtained through reflection.</summary> + /// <typeparam name="TValue">The property value type.</typeparam> + public interface IPrivateProperty<TValue> + { + /********* + ** Accessors + *********/ + /// <summary>The reflection metadata.</summary> + PropertyInfo PropertyInfo { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Get the property value.</summary> + TValue GetValue(); + + /// <summary>Set the property value.</summary> + //// <param name="value">The value to set.</param> + void SetValue(TValue value); + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/IReflectionHelper.cs b/src/StardewModdingAPI/IReflectionHelper.cs new file mode 100644 index 00000000..fb2c7861 --- /dev/null +++ b/src/StardewModdingAPI/IReflectionHelper.cs @@ -0,0 +1,67 @@ +using System; + +namespace StardewModdingAPI +{ + /// <summary>Provides an API for accessing private game code.</summary> + public interface IReflectionHelper : IModLinked + { + /********* + ** Public methods + *********/ + /// <summary>Get a private instance field.</summary> + /// <typeparam name="TValue">The field type.</typeparam> + /// <param name="obj">The object which has the field.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + IPrivateField<TValue> GetPrivateField<TValue>(object obj, string name, bool required = true); + + /// <summary>Get a private static field.</summary> + /// <typeparam name="TValue">The field type.</typeparam> + /// <param name="type">The type which has the field.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + IPrivateField<TValue> GetPrivateField<TValue>(Type type, string name, bool required = true); + + /// <summary>Get a private instance property.</summary> + /// <typeparam name="TValue">The property type.</typeparam> + /// <param name="obj">The object which has the property.</param> + /// <param name="name">The property name.</param> + /// <param name="required">Whether to throw an exception if the private property is not found.</param> + IPrivateProperty<TValue> GetPrivateProperty<TValue>(object obj, string name, bool required = true); + + /// <summary>Get a private static property.</summary> + /// <typeparam name="TValue">The property type.</typeparam> + /// <param name="type">The type which has the property.</param> + /// <param name="name">The property name.</param> + /// <param name="required">Whether to throw an exception if the private property is not found.</param> + IPrivateProperty<TValue> GetPrivateProperty<TValue>(Type type, string name, bool required = true); + + /// <summary>Get the value of a private instance field.</summary> + /// <typeparam name="TValue">The field type.</typeparam> + /// <param name="obj">The object which has the field.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + /// <remarks>This is a shortcut for <see cref="GetPrivateField{TValue}(object,string,bool)"/> followed by <see cref="IPrivateField{TValue}.GetValue"/>.</remarks> + TValue GetPrivateValue<TValue>(object obj, string name, bool required = true); + + /// <summary>Get the value of a private static field.</summary> + /// <typeparam name="TValue">The field type.</typeparam> + /// <param name="type">The type which has the field.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + /// <remarks>This is a shortcut for <see cref="GetPrivateField{TValue}(Type,string,bool)"/> followed by <see cref="IPrivateField{TValue}.GetValue"/>.</remarks> + TValue GetPrivateValue<TValue>(Type type, string name, bool required = true); + + /// <summary>Get a private instance method.</summary> + /// <param name="obj">The object which has the method.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + IPrivateMethod GetPrivateMethod(object obj, string name, bool required = true); + + /// <summary>Get a private static method.</summary> + /// <param name="type">The type which has the method.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + IPrivateMethod GetPrivateMethod(Type type, string name, bool required = true); + } +} diff --git a/src/StardewModdingAPI/ISemanticVersion.cs b/src/StardewModdingAPI/ISemanticVersion.cs new file mode 100644 index 00000000..0483c97b --- /dev/null +++ b/src/StardewModdingAPI/ISemanticVersion.cs @@ -0,0 +1,59 @@ +using System; + +namespace StardewModdingAPI +{ + /// <summary>A semantic version with an optional release tag.</summary> + public interface ISemanticVersion : IComparable<ISemanticVersion>, IEquatable<ISemanticVersion> + { + /********* + ** Accessors + *********/ + /// <summary>The major version incremented for major API changes.</summary> + int MajorVersion { get; } + + /// <summary>The minor version incremented for backwards-compatible changes.</summary> + int MinorVersion { get; } + + /// <summary>The patch version for backwards-compatible bug fixes.</summary> + int PatchVersion { get; } + + /// <summary>An optional build tag.</summary> + string Build { get; } + + + /********* + ** Accessors + *********/ + /// <summary>Get whether this version is older than the specified version.</summary> + /// <param name="other">The version to compare with this instance.</param> + bool IsOlderThan(ISemanticVersion other); + + /// <summary>Get whether this version is older than the specified version.</summary> + /// <param name="other">The version to compare with this instance.</param> + /// <exception cref="FormatException">The specified version is not a valid semantic version.</exception> + bool IsOlderThan(string other); + + /// <summary>Get whether this version is newer than the specified version.</summary> + /// <param name="other">The version to compare with this instance.</param> + bool IsNewerThan(ISemanticVersion other); + + /// <summary>Get whether this version is newer than the specified version.</summary> + /// <param name="other">The version to compare with this instance.</param> + /// <exception cref="FormatException">The specified version is not a valid semantic version.</exception> + bool IsNewerThan(string other); + + /// <summary>Get whether this version is between two specified versions (inclusively).</summary> + /// <param name="min">The minimum version.</param> + /// <param name="max">The maximum version.</param> + bool IsBetween(ISemanticVersion min, ISemanticVersion max); + + /// <summary>Get whether this version is between two specified versions (inclusively).</summary> + /// <param name="min">The minimum version.</param> + /// <param name="max">The maximum version.</param> + /// <exception cref="FormatException">One of the specified versions is not a valid semantic version.</exception> + bool IsBetween(string min, string max); + + /// <summary>Get a string representation of the version.</summary> + string ToString(); + } +} diff --git a/src/StardewModdingAPI/ITranslationHelper.cs b/src/StardewModdingAPI/ITranslationHelper.cs new file mode 100644 index 00000000..c4b72444 --- /dev/null +++ b/src/StardewModdingAPI/ITranslationHelper.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using StardewValley; + +namespace StardewModdingAPI +{ + /// <summary>Provides translations stored in the mod's <c>i18n</c> folder, with one file per locale (like <c>en.json</c>) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like <c>pt-BR.json</c> < <c>pt.json</c> < <c>default.json</c>).</summary> + public interface ITranslationHelper : IModLinked + { + /********* + ** Accessors + *********/ + /// <summary>The current locale.</summary> + string Locale { get; } + + /// <summary>The game's current language code.</summary> + LocalizedContentManager.LanguageCode LocaleEnum { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Get all translations for the current locale.</summary> + IEnumerable<Translation> GetTranslations(); + + /// <summary>Get a translation for the current locale.</summary> + /// <param name="key">The translation key.</param> + Translation Get(string key); + + /// <summary>Get a translation for the current locale.</summary> + /// <param name="key">The translation key.</param> + /// <param name="tokens">An object containing token key/value pairs. This can be an anonymous object (like <c>new { value = 42, name = "Cranberries" }</c>), a dictionary, or a class instance.</param> + Translation Get(string key, object tokens); + } +} diff --git a/src/StardewModdingAPI/LogLevel.cs b/src/StardewModdingAPI/LogLevel.cs new file mode 100644 index 00000000..89647876 --- /dev/null +++ b/src/StardewModdingAPI/LogLevel.cs @@ -0,0 +1,24 @@ +namespace StardewModdingAPI +{ + /// <summary>The log severity levels.</summary> + public enum LogLevel + { + /// <summary>Tracing info intended for developers.</summary> + Trace, + + /// <summary>Troubleshooting info that may be relevant to the player.</summary> + Debug, + + /// <summary>Info relevant to the player. This should be used judiciously.</summary> + Info, + + /// <summary>An issue the player should be aware of. This should be used rarely.</summary> + Warn, + + /// <summary>A message indicating something went wrong.</summary> + Error, + + /// <summary>Important information to highlight for the player when player action is needed (e.g. new version available). This should be used rarely to avoid alert fatigue.</summary> + Alert + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Metadata/CoreAssets.cs b/src/StardewModdingAPI/Metadata/CoreAssets.cs new file mode 100644 index 00000000..24f23af7 --- /dev/null +++ b/src/StardewModdingAPI/Metadata/CoreAssets.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework; +using StardewValley; +using StardewValley.BellsAndWhistles; +using StardewValley.Buildings; +using StardewValley.Locations; +using StardewValley.Objects; +using StardewValley.Projectiles; +using StardewValley.TerrainFeatures; + +namespace StardewModdingAPI.Metadata +{ + /// <summary>Provides metadata about core assets in the game.</summary> + internal class CoreAssets + { + /********* + ** Properties + *********/ + /// <summary>Normalises an asset key to match the cache key.</summary> + protected readonly Func<string, string> GetNormalisedPath; + + /// <summary>Setters which update static or singleton texture fields indexed by normalised asset key.</summary> + private readonly IDictionary<string, Action<SContentManager, string>> SingletonSetters; + + + /********* + ** Public methods + *********/ + /// <summary>Initialise the core asset data.</summary> + /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + public CoreAssets(Func<string, string> getNormalisedPath) + { + this.GetNormalisedPath = getNormalisedPath; + this.SingletonSetters = + new Dictionary<string, Action<SContentManager, string>> + { + // from Game1.loadContent + ["LooseSprites\\daybg"] = (content, key) => Game1.daybg = content.Load<Texture2D>(key), + ["LooseSprites\\nightbg"] = (content, key) => Game1.nightbg = content.Load<Texture2D>(key), + ["Maps\\MenuTiles"] = (content, key) => Game1.menuTexture = content.Load<Texture2D>(key), + ["LooseSprites\\Lighting\\lantern"] = (content, key) => Game1.lantern = content.Load<Texture2D>(key), + ["LooseSprites\\Lighting\\windowLight"] = (content, key) => Game1.windowLight = content.Load<Texture2D>(key), + ["LooseSprites\\Lighting\\sconceLight"] = (content, key) => Game1.sconceLight = content.Load<Texture2D>(key), + ["LooseSprites\\Lighting\\greenLight"] = (content, key) => Game1.cauldronLight = content.Load<Texture2D>(key), + ["LooseSprites\\Lighting\\indoorWindowLight"] = (content, key) => Game1.indoorWindowLight = content.Load<Texture2D>(key), + ["LooseSprites\\shadow"] = (content, key) => Game1.shadowTexture = content.Load<Texture2D>(key), + ["LooseSprites\\Cursors"] = (content, key) => Game1.mouseCursors = content.Load<Texture2D>(key), + ["LooseSprites\\ControllerMaps"] = (content, key) => Game1.controllerMaps = content.Load<Texture2D>(key), + ["TileSheets\\animations"] = (content, key) => Game1.animations = content.Load<Texture2D>(key), + ["Data\\Achievements"] = (content, key) => Game1.achievements = content.Load<Dictionary<int, string>>(key), + ["Data\\NPCGiftTastes"] = (content, key) => Game1.NPCGiftTastes = content.Load<Dictionary<string, string>>(key), + ["Fonts\\SpriteFont1"] = (content, key) => Game1.dialogueFont = content.Load<SpriteFont>(key), + ["Fonts\\SmallFont"] = (content, key) => Game1.smallFont = content.Load<SpriteFont>(key), + ["Fonts\\tinyFont"] = (content, key) => Game1.tinyFont = content.Load<SpriteFont>(key), + ["Fonts\\tinyFontBorder"] = (content, key) => Game1.tinyFontBorder = content.Load<SpriteFont>(key), + ["Maps\\springobjects"] = (content, key) => Game1.objectSpriteSheet = content.Load<Texture2D>(key), + ["TileSheets\\crops"] = (content, key) => Game1.cropSpriteSheet = content.Load<Texture2D>(key), + ["TileSheets\\emotes"] = (content, key) => Game1.emoteSpriteSheet = content.Load<Texture2D>(key), + ["TileSheets\\debris"] = (content, key) => Game1.debrisSpriteSheet = content.Load<Texture2D>(key), + ["TileSheets\\Craftables"] = (content, key) => Game1.bigCraftableSpriteSheet = content.Load<Texture2D>(key), + ["TileSheets\\rain"] = (content, key) => Game1.rainTexture = content.Load<Texture2D>(key), + ["TileSheets\\BuffsIcons"] = (content, key) => Game1.buffsIcons = content.Load<Texture2D>(key), + ["Data\\ObjectInformation"] = (content, key) => Game1.objectInformation = content.Load<Dictionary<int, string>>(key), + ["Data\\BigCraftablesInformation"] = (content, key) => Game1.bigCraftablesInformation = content.Load<Dictionary<int, string>>(key), + ["Characters\\Farmer\\hairstyles"] = (content, key) => FarmerRenderer.hairStylesTexture = content.Load<Texture2D>(key), + ["Characters\\Farmer\\shirts"] = (content, key) => FarmerRenderer.shirtsTexture = content.Load<Texture2D>(key), + ["Characters\\Farmer\\hats"] = (content, key) => FarmerRenderer.hatsTexture = content.Load<Texture2D>(key), + ["Characters\\Farmer\\accessories"] = (content, key) => FarmerRenderer.accessoriesTexture = content.Load<Texture2D>(key), + ["TileSheets\\furniture"] = (content, key) => Furniture.furnitureTexture = content.Load<Texture2D>(key), + ["LooseSprites\\font_bold"] = (content, key) => SpriteText.spriteTexture = content.Load<Texture2D>(key), + ["LooseSprites\\font_colored"] = (content, key) => SpriteText.coloredTexture = content.Load<Texture2D>(key), + ["TileSheets\\weapons"] = (content, key) => Tool.weaponsTexture = content.Load<Texture2D>(key), + ["TileSheets\\Projectiles"] = (content, key) => Projectile.projectileSheet = content.Load<Texture2D>(key), + + // from Game1.ResetToolSpriteSheet + ["TileSheets\\tools"] = (content, key) => Game1.ResetToolSpriteSheet(), + + // from Bush + ["TileSheets\\bushes"] = (content, key) => Bush.texture = content.Load<Texture2D>(key), + + // from Critter + ["TileSheets\\critters"] = (content, key) => Critter.critterTexture = content.Load<Texture2D>(key), + + // from Farm + ["Buildings\\houses"] = (content, key) => + { + Farm farm = Game1.getFarm(); + if (farm != null) + farm.houseTextures = content.Load<Texture2D>(key); + }, + + // from Farmer + ["Characters\\Farmer\\farmer_base"] = (content, key) => + { + if (Game1.player != null && Game1.player.isMale) + Game1.player.FarmerRenderer = new FarmerRenderer(content.Load<Texture2D>(key)); + }, + ["Characters\\Farmer\\farmer_girl_base"] = (content, key) => + { + if (Game1.player != null && !Game1.player.isMale) + Game1.player.FarmerRenderer = new FarmerRenderer(content.Load<Texture2D>(key)); + }, + + // from Flooring + ["TerrainFeatures\\Flooring"] = (content, key) => Flooring.floorsTexture = content.Load<Texture2D>(key), + + // from FruitTree + ["TileSheets\\fruitTrees"] = (content, key) => FruitTree.texture = content.Load<Texture2D>(key), + + // from HoeDirt + ["TerrainFeatures\\hoeDirt"] = (content, key) => HoeDirt.lightTexture = content.Load<Texture2D>(key), + ["TerrainFeatures\\hoeDirtDark"] = (content, key) => HoeDirt.darkTexture = content.Load<Texture2D>(key), + ["TerrainFeatures\\hoeDirtSnow"] = (content, key) => HoeDirt.snowTexture = content.Load<Texture2D>(key), + + // from Wallpaper + ["Maps\\walls_and_floors"] = (content, key) => Wallpaper.wallpaperTexture = content.Load<Texture2D>(key) + } + .ToDictionary(p => getNormalisedPath(p.Key), p => p.Value); + } + + /// <summary>Reload one of the game's core assets (if applicable).</summary> + /// <param name="content">The content manager through which to reload the asset.</param> + /// <param name="key">The asset key to reload.</param> + /// <returns>Returns whether an asset was reloaded.</returns> + public bool ReloadForKey(SContentManager content, string key) + { + // static assets + if (this.SingletonSetters.TryGetValue(key, out Action<SContentManager, string> reload)) + { + reload(content, key); + return true; + } + + // building textures + if (key.StartsWith(this.GetNormalisedPath("Buildings\\"))) + { + Building[] buildings = this.GetAllBuildings().Where(p => key == this.GetNormalisedPath($"Buildings\\{p.buildingType}")).ToArray(); + if (buildings.Any()) + { + Texture2D texture = content.Load<Texture2D>(key); + foreach (Building building in buildings) + building.texture = texture; + return true; + } + return false; + } + + return false; + } + + + /********* + ** Private methods + *********/ + /// <summary>Get all player-constructed buildings in the world.</summary> + private IEnumerable<Building> GetAllBuildings() + { + return Game1.locations + .OfType<BuildableGameLocation>() + .SelectMany(p => p.buildings); + } + } +} diff --git a/src/StardewModdingAPI/Metadata/InstructionMetadata.cs b/src/StardewModdingAPI/Metadata/InstructionMetadata.cs new file mode 100644 index 00000000..3346f1ac --- /dev/null +++ b/src/StardewModdingAPI/Metadata/InstructionMetadata.cs @@ -0,0 +1,101 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.AssemblyRewriters; +using StardewModdingAPI.Events; +using StardewModdingAPI.Framework.ModLoading; +using StardewModdingAPI.Framework.ModLoading.Finders; +using StardewModdingAPI.Framework.ModLoading.Rewriters; +using StardewValley; + +namespace StardewModdingAPI.Metadata +{ + /// <summary>Provides CIL instruction handlers which rewrite mods for compatibility and throw exceptions for incompatible code.</summary> + internal class InstructionMetadata + { + /********* + ** Public methods + *********/ + /// <summary>Get rewriters which detect or fix incompatible CIL instructions in mod assemblies.</summary> + public IEnumerable<IInstructionHandler> GetHandlers() + { + return new IInstructionHandler[] + { + /**** + ** throw exception for incompatible code + ****/ + // changes in Stardew Valley 1.2 (with no rewriters) + new FieldFinder("StardewValley.Item", "set_Name", InstructionHandleResult.NotCompatible), + + // APIs removed in SMAPI 1.9 + new TypeFinder("StardewModdingAPI.Advanced.ConfigFile", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Advanced.IConfigFile", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Entities.SPlayer", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Extensions", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Inheritance.SGame", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Inheritance.SObject", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.LogWriter", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Manifest", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Version", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.GraphicsEvents", "DrawDebug", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.GraphicsEvents", "DrawTick", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.GraphicsEvents", "OnPostRenderHudEventNoCheck", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.GraphicsEvents", "OnPostRenderGuiEventNoCheck", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.GraphicsEvents", "OnPreRenderHudEventNoCheck", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.GraphicsEvents", "OnPreRenderGuiEventNoCheck", InstructionHandleResult.NotCompatible), + + // APIs removed in SMAPI 2.0 + new TypeFinder("StardewModdingAPI.Command", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Config", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Log", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.GameEvents", "Initialize", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.GameEvents", "LoadContent", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.GameEvents", "GameLoaded", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.GameEvents", "FirstUpdateTick", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.PlayerEvents", "LoadedGame", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.PlayerEvents", "FarmerChanged", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.TimeEvents", "DayOfMonthChanged", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.TimeEvents", "YearOfGameChanged", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.TimeEvents", "SeasonOfYearChanged", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.TimeEvents", "OnNewDay", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Events.EventArgsCommand", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Events.EventArgsFarmerChanged", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Events.EventArgsLoadedGameChanged", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Events.EventArgsNewDay", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Events.EventArgsStringChanged", InstructionHandleResult.NotCompatible), + new PropertyFinder("StardewModdingAPI.Mod", "PathOnDisk", InstructionHandleResult.NotCompatible), + new PropertyFinder("StardewModdingAPI.Mod", "BaseConfigPath", InstructionHandleResult.NotCompatible), + new PropertyFinder("StardewModdingAPI.Mod", "PerSaveConfigFolder", InstructionHandleResult.NotCompatible), + new PropertyFinder("StardewModdingAPI.Mod", "PerSaveConfigPath", InstructionHandleResult.NotCompatible), + + /**** + ** detect code which may impact game stability + ****/ + new TypeFinder("Harmony.HarmonyInstance", InstructionHandleResult.DetectedGamePatch), + new TypeFinder("System.Runtime.CompilerServices.CallSite", InstructionHandleResult.DetectedDynamic), + new FieldFinder(typeof(SaveGame).FullName, nameof(SaveGame.serializer), InstructionHandleResult.DetectedSaveSerialiser), + new FieldFinder(typeof(SaveGame).FullName, nameof(SaveGame.farmerSerializer), InstructionHandleResult.DetectedSaveSerialiser), + new FieldFinder(typeof(SaveGame).FullName, nameof(SaveGame.locationSerializer), InstructionHandleResult.DetectedSaveSerialiser), + + /**** + ** rewrite CIL to fix incompatible code + ****/ + // crossplatform + new MethodParentRewriter(typeof(SpriteBatch), typeof(SpriteBatchMethods), onlyIfPlatformChanged: true), + + // Stardew Valley 1.2 + new FieldToPropertyRewriter(typeof(Game1), nameof(Game1.activeClickableMenu)), + new FieldToPropertyRewriter(typeof(Game1), nameof(Game1.currentMinigame)), + new FieldToPropertyRewriter(typeof(Game1), nameof(Game1.gameMode)), + new FieldToPropertyRewriter(typeof(Game1), nameof(Game1.player)), + new FieldReplaceRewriter(typeof(Game1), "borderFont", nameof(Game1.smallFont)), + new FieldReplaceRewriter(typeof(Game1), "smoothFont", nameof(Game1.smallFont)), + + // SMAPI 1.9 + new TypeReferenceRewriter("StardewModdingAPI.Inheritance.ItemStackChange", typeof(ItemStackChange)), + + // SMAPI 2.0 + new VirtualEntryCallRemover() // Mod.Entry changed from virtual to abstract in SMAPI 2.0, which breaks the few mods which called base.Entry() + }; + } + } +} diff --git a/src/StardewModdingAPI/Mod.cs b/src/StardewModdingAPI/Mod.cs new file mode 100644 index 00000000..ee75ba54 --- /dev/null +++ b/src/StardewModdingAPI/Mod.cs @@ -0,0 +1,50 @@ +using System; + +namespace StardewModdingAPI +{ + /// <summary>The base class for a mod.</summary> + public abstract class Mod : IMod, IDisposable + { + /********* + ** Accessors + *********/ + /// <summary>Provides simplified APIs for writing mods.</summary> + public IModHelper Helper { get; internal set; } + + /// <summary>Writes messages to the console and log file.</summary> + public IMonitor Monitor { get; internal set; } + + /// <summary>The mod's manifest.</summary> + public IManifest ModManifest { get; internal set; } + + + /********* + ** Public methods + *********/ + /// <summary>The mod entry point, called after the mod is first loaded.</summary> + /// <param name="helper">Provides simplified APIs for writing mods.</param> + public abstract void Entry(IModHelper helper); + + /// <summary>Release or reset unmanaged resources.</summary> + public void Dispose() + { + (this.Helper as IDisposable)?.Dispose(); // deliberate do this outside overridable dispose method so mods don't accidentally suppress it + this.Dispose(true); + GC.SuppressFinalize(this); + } + + + /********* + ** Private methods + *********/ + /// <summary>Release or reset unmanaged resources when the game exits. There's no guarantee this will be called on every exit.</summary> + /// <param name="disposing">Whether the instance is being disposed explicitly rather than finalised. If this is false, the instance shouldn't dispose other objects since they may already be finalised.</param> + protected virtual void Dispose(bool disposing) { } + + /// <summary>Destruct the instance.</summary> + ~Mod() + { + this.Dispose(false); + } + } +} diff --git a/src/StardewModdingAPI/PatchMode.cs b/src/StardewModdingAPI/PatchMode.cs new file mode 100644 index 00000000..b4286a89 --- /dev/null +++ b/src/StardewModdingAPI/PatchMode.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI +{ + /// <summary>Indicates how an image should be patched.</summary> + public enum PatchMode + { + /// <summary>Erase the original content within the area before drawing the new content.</summary> + Replace, + + /// <summary>Draw the new content over the original content, so the original content shows through any transparent pixels.</summary> + Overlay + } +} diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs new file mode 100644 index 00000000..7dfdc745 --- /dev/null +++ b/src/StardewModdingAPI/Program.cs @@ -0,0 +1,966 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Security; +using System.Threading; +#if SMAPI_FOR_WINDOWS +using System.Management; +using System.Windows.Forms; +#endif +using Newtonsoft.Json; +using StardewModdingAPI.Events; +using StardewModdingAPI.Framework; +using StardewModdingAPI.Framework.Exceptions; +using StardewModdingAPI.Framework.Logging; +using StardewModdingAPI.Framework.Models; +using StardewModdingAPI.Framework.ModHelpers; +using StardewModdingAPI.Framework.ModLoading; +using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Framework.Serialisation; +using StardewModdingAPI.Models; +using StardewValley; +using Monitor = StardewModdingAPI.Framework.Monitor; +using SObject = StardewValley.Object; + +namespace StardewModdingAPI +{ + /// <summary>The main entry point for SMAPI, responsible for hooking into and launching the game.</summary> + internal class Program : IDisposable + { + /********* + ** Properties + *********/ + /// <summary>The log file to which to write messages.</summary> + private readonly LogFileManager LogFile; + + /// <summary>Manages console output interception.</summary> + private readonly ConsoleInterceptionManager ConsoleManager = new ConsoleInterceptionManager(); + + /// <summary>The core logger and monitor for SMAPI.</summary> + private readonly Monitor Monitor; + + /// <summary>Tracks whether the game should exit immediately and any pending initialisation should be cancelled.</summary> + private readonly CancellationTokenSource CancellationTokenSource = new CancellationTokenSource(); + + /// <summary>Simplifies access to private game code.</summary> + private readonly Reflector Reflection = new Reflector(); + + /// <summary>The underlying game instance.</summary> + private SGame GameInstance; + + /// <summary>The underlying content manager.</summary> + private SContentManager ContentManager => this.GameInstance.SContentManager; + + /// <summary>The SMAPI configuration settings.</summary> + /// <remarks>This is initialised after the game starts.</remarks> + private SConfig Settings; + + /// <summary>Tracks the installed mods.</summary> + /// <remarks>This is initialised after the game starts.</remarks> + private ModRegistry ModRegistry; + + /// <summary>Manages deprecation warnings.</summary> + /// <remarks>This is initialised after the game starts.</remarks> + private DeprecationManager DeprecationManager; + + /// <summary>Manages console commands.</summary> + /// <remarks>This is initialised after the game starts.</remarks> + private CommandManager CommandManager; + + /// <summary>Whether the game is currently running.</summary> + private bool IsGameRunning; + + /// <summary>Whether the program has been disposed.</summary> + private bool IsDisposed; + + + /********* + ** Public methods + *********/ + /// <summary>The main entry point which hooks into and launches the game.</summary> + /// <param name="args">The command-line arguments.</param> + public static void Main(string[] args) + { + Program.AssertMinimumCompatibility(); + + // get flags from arguments + bool writeToConsole = !args.Contains("--no-terminal"); + + // get log path from arguments + string logPath = null; + { + int pathIndex = Array.LastIndexOf(args, "--log-path") + 1; + if (pathIndex >= 1 && args.Length >= pathIndex) + { + logPath = args[pathIndex]; + if (!Path.IsPathRooted(logPath)) + logPath = Path.Combine(Constants.LogDir, logPath); + } + } + if (string.IsNullOrWhiteSpace(logPath)) + logPath = Constants.DefaultLogPath; + + // load SMAPI + using (Program program = new Program(writeToConsole, logPath)) + program.RunInteractively(); + } + + /// <summary>Construct an instance.</summary> + /// <param name="writeToConsole">Whether to output log messages to the console.</param> + /// <param name="logPath">The full file path to which to write log messages.</param> + public Program(bool writeToConsole, string logPath) + { + this.LogFile = new LogFileManager(logPath); + this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.CancellationTokenSource) { WriteToConsole = writeToConsole }; + } + + /// <summary>Launch SMAPI.</summary> + [HandleProcessCorruptedStateExceptions, SecurityCritical] // let try..catch handle corrupted state exceptions + public void RunInteractively() + { + // initialise SMAPI + try + { + // init logging + this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} on {this.GetFriendlyPlatformName()}", LogLevel.Info); + this.Monitor.Log($"Mods go here: {Constants.ModPath}"); + this.Monitor.Log($"Log started at {DateTime.UtcNow:s} UTC", LogLevel.Trace); + + // validate paths + this.VerifyPath(Constants.ModPath); + this.VerifyPath(Constants.LogDir); + + // validate game version + if (Constants.GameVersion.IsOlderThan(Constants.MinimumGameVersion)) + { + this.Monitor.Log($"Oops! You're running Stardew Valley {Constants.GameVersion}, but the oldest supported version is {Constants.MinimumGameVersion}. Please update your game before using SMAPI.", LogLevel.Error); + this.PressAnyKeyToExit(); + return; + } + if (Constants.MaximumGameVersion != null && Constants.GameVersion.IsNewerThan(Constants.MaximumGameVersion)) + { + this.Monitor.Log($"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.", LogLevel.Error); + this.PressAnyKeyToExit(); + return; + } + + // add error handlers +#if SMAPI_FOR_WINDOWS + Application.ThreadException += (sender, e) => this.Monitor.Log($"Critical thread exception: {e.Exception.GetLogSummary()}", LogLevel.Error); + Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); +#endif + AppDomain.CurrentDomain.UnhandledException += (sender, e) => this.Monitor.Log($"Critical app domain exception: {e.ExceptionObject}", LogLevel.Error); + + // override game + this.GameInstance = new SGame(this.Monitor, this.Reflection); + StardewValley.Program.gamePtr = this.GameInstance; + + // add exit handler + new Thread(() => + { + this.CancellationTokenSource.Token.WaitHandle.WaitOne(); + if (this.IsGameRunning) + { + try + { + File.WriteAllText(Constants.FatalCrashMarker, string.Empty); + File.Copy(this.LogFile.Path, Constants.FatalCrashLog, overwrite: true); + } + catch (Exception ex) + { + this.Monitor.Log($"SMAPI failed trying to track the crash details: {ex.GetLogSummary()}"); + } + + this.GameInstance.Exit(); + } + }).Start(); + + // hook into game events +#if SMAPI_FOR_WINDOWS + ((Form)Control.FromHandle(this.GameInstance.Window.Handle)).FormClosing += (sender, args) => this.Dispose(); +#endif + this.GameInstance.Exiting += (sender, e) => this.Dispose(); + GameEvents.InitializeInternal += (sender, e) => this.InitialiseAfterGameStart(); + ContentEvents.AfterLocaleChanged += (sender, e) => this.OnLocaleChanged(); + + // set window titles + this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion}"; + Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion}"; + } + catch (Exception ex) + { + this.Monitor.Log($"SMAPI failed to initialise: {ex.GetLogSummary()}", LogLevel.Error); + this.PressAnyKeyToExit(); + return; + } + + // show details if game crashed during last session + if (File.Exists(Constants.FatalCrashMarker)) + { + this.Monitor.Log("The game crashed last time you played. That can be due to bugs in the game, but if it happens repeatedly you can ask for help here: http://community.playstarbound.com/threads/108375/.", LogLevel.Error); + this.Monitor.Log($"If you ask for help, make sure to attach this file: {Constants.FatalCrashLog}", LogLevel.Error); + this.Monitor.Log("Press any key to delete the crash data and continue playing.", LogLevel.Info); + Console.ReadKey(); + File.Delete(Constants.FatalCrashLog); + File.Delete(Constants.FatalCrashMarker); + } + + // start game + this.Monitor.Log("Starting game...", LogLevel.Trace); + try + { + this.IsGameRunning = true; + this.GameInstance.Run(); + } + catch (Exception ex) + { + this.Monitor.Log($"The game failed unexpectedly: {ex.GetLogSummary()}", LogLevel.Error); + this.PressAnyKeyToExit(); + } + finally + { + this.Dispose(); + } + } + + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + public void Dispose() + { + this.Monitor.Log("Disposing...", LogLevel.Trace); + + // skip if already disposed + if (this.IsDisposed) + return; + this.IsDisposed = true; + + // dispose mod data + foreach (IModMetadata mod in this.ModRegistry.GetMods()) + { + try + { + (mod.Mod as IDisposable)?.Dispose(); + } + catch (Exception ex) + { + this.Monitor.Log($"The {mod.DisplayName} mod failed during disposal: {ex.GetLogSummary()}.", LogLevel.Warn); + } + } + + // dispose core components + this.IsGameRunning = false; + this.LogFile?.Dispose(); + this.ConsoleManager?.Dispose(); + this.CancellationTokenSource?.Dispose(); + this.GameInstance?.Dispose(); + } + + + /********* + ** Private methods + *********/ + /// <summary>Assert that the minimum conditions are present to initialise SMAPI without type load exceptions.</summary> + private static void AssertMinimumCompatibility() + { + void PrintErrorAndExit(string message) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine(message); + Console.ResetColor(); + Program.PressAnyKeyToExit(showMessage: true); + } + + // get game assembly name + const string gameAssemblyName = +#if SMAPI_FOR_WINDOWS + "Stardew Valley"; +#else + "StardewValley"; +#endif + + // game not present + if (Type.GetType($"StardewValley.Game1, {gameAssemblyName}", throwOnError: false) == null) + { + PrintErrorAndExit( + "Oops! SMAPI can't find the game. " + + (Assembly.GetCallingAssembly().Location.Contains(Path.Combine("internal", "Windows")) || Assembly.GetCallingAssembly().Location.Contains(Path.Combine("internal", "Mono")) + ? "It looks like you're running SMAPI from the download package, but you need to run the installed version instead. " + : "Make sure you're running StardewModdingAPI.exe in your game folder. " + ) + + "See the readme.txt file for details." + ); + } + + // Stardew Valley 1.2 types not present + if (Type.GetType($"StardewValley.LocalizedContentManager+LanguageCode, {gameAssemblyName}", throwOnError: false) == null) + { + PrintErrorAndExit(Constants.GameVersion.IsOlderThan(Constants.MinimumGameVersion) + ? $"Oops! You're running Stardew Valley {Constants.GameVersion}, but the oldest supported version is {Constants.MinimumGameVersion}. Please update your game before using SMAPI." + : "Oops! SMAPI doesn't seem to be compatible with your game. Make sure you're running the latest version of Stardew Valley and SMAPI." + ); + } + } + + /// <summary>Initialise SMAPI and mods after the game starts.</summary> + private void InitialiseAfterGameStart() + { + // load settings + this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath)); + this.GameInstance.VerboseLogging = this.Settings.VerboseLogging; + + // load core components + this.ModRegistry = new ModRegistry(); + this.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry); + this.CommandManager = new CommandManager(); + + // redirect direct console output + { + Monitor monitor = this.GetSecondaryMonitor("Console.Out"); + if (monitor.WriteToConsole) + this.ConsoleManager.OnMessageIntercepted += message => this.HandleConsoleMessage(monitor, message); + } + + // add headers + if (this.Settings.DeveloperMode) + { + this.Monitor.ShowTraceInConsole = true; + this.Monitor.ShowFullStampInConsole = true; + this.Monitor.Log($"You configured SMAPI to run in developer mode. The console may be much more verbose. You can disable developer mode by installing the non-developer version of SMAPI, or by editing {Constants.ApiConfigPath}.", LogLevel.Info); + } + if (!this.Settings.CheckForUpdates) + this.Monitor.Log($"You configured SMAPI to not check for updates. Running an old version of SMAPI is not recommended. You can enable update checks by reinstalling SMAPI or editing {Constants.ApiConfigPath}.", LogLevel.Warn); + if (!this.Monitor.WriteToConsole) + this.Monitor.Log("Writing to the terminal is disabled because the --no-terminal argument was received. This usually means launching the terminal failed.", LogLevel.Warn); + this.VerboseLog("Verbose logging enabled."); + + // validate XNB integrity + if (!this.ValidateContentIntegrity()) + this.Monitor.Log("SMAPI found problems in your game's content files which are likely to cause errors or crashes. Consider uninstalling XNB mods or reinstalling the game.", LogLevel.Error); + + // load mods + { + this.Monitor.Log("Loading mod metadata...", LogLevel.Trace); + ModResolver resolver = new ModResolver(); + + // load manifests + IModMetadata[] mods = resolver.ReadManifests(Constants.ModPath, new JsonHelper(), this.Settings.ModData).ToArray(); + resolver.ValidateManifests(mods, Constants.ApiVersion, Constants.VendorModUrls); + + // process dependencies + mods = resolver.ProcessDependencies(mods).ToArray(); + + // load mods + this.LoadMods(mods, new JsonHelper(), this.ContentManager); + + // check for updates + this.CheckForUpdatesAsync(mods); + } + if (this.Monitor.IsExiting) + { + this.Monitor.Log("SMAPI shutting down: aborting initialisation.", LogLevel.Warn); + return; + } + + // update window titles + int modsLoaded = this.ModRegistry.GetMods().Count(); + this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion} with {modsLoaded} mods"; + Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion} with {modsLoaded} mods"; + + // start SMAPI console + new Thread(this.RunConsoleLoop).Start(); + } + + /// <summary>Handle the game changing locale.</summary> + private void OnLocaleChanged() + { + // get locale + string locale = this.ContentManager.GetLocale(); + LocalizedContentManager.LanguageCode languageCode = this.ContentManager.GetCurrentLanguage(); + + // update mod translation helpers + foreach (IModMetadata mod in this.ModRegistry.GetMods()) + (mod.Mod.Helper.Translation as TranslationHelper)?.SetLocale(locale, languageCode); + } + + /// <summary>Run a loop handling console input.</summary> + [SuppressMessage("ReSharper", "FunctionNeverReturns", Justification = "The thread is aborted when the game exits.")] + private void RunConsoleLoop() + { + // prepare console + this.Monitor.Log("Type 'help' for help, or 'help <cmd>' for a command's usage", LogLevel.Info); + this.CommandManager.Add("SMAPI", "help", "Lists command documentation.\n\nUsage: help\nLists all available commands.\n\nUsage: help <cmd>\n- cmd: The name of a command whose documentation to display.", this.HandleCommand); + this.CommandManager.Add("SMAPI", "reload_i18n", "Reloads translation files for all mods.\n\nUsage: reload_i18n", this.HandleCommand); + + // start handling command line input + Thread inputThread = new Thread(() => + { + while (true) + { + // get input + string input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input)) + continue; + + // parse input + try + { + if (!this.CommandManager.Trigger(input)) + this.Monitor.Log("Unknown command; type 'help' for a list of available commands.", LogLevel.Error); + } + catch (Exception ex) + { + this.Monitor.Log($"The handler registered for that command failed:\n{ex.GetLogSummary()}", LogLevel.Error); + } + } + }); + inputThread.Start(); + + // keep console thread alive while the game is running + while (this.IsGameRunning && !this.Monitor.IsExiting) + Thread.Sleep(1000 / 10); + if (inputThread.ThreadState == ThreadState.Running) + inputThread.Abort(); + } + + /// <summary>Look for common issues with the game's XNB content, and log warnings if anything looks broken or outdated.</summary> + /// <returns>Returns whether all integrity checks passed.</returns> + private bool ValidateContentIntegrity() + { + this.Monitor.Log("Detecting common issues...", LogLevel.Trace); + bool issuesFound = false; + + // object format (commonly broken by outdated files) + { + // detect issues + bool hasObjectIssues = false; + void LogIssue(int id, string issue) => this.Monitor.Log($@"Detected issue: item #{id} in Content\Data\ObjectInformation.xnb is invalid ({issue}).", LogLevel.Trace); + foreach (KeyValuePair<int, string> entry in Game1.objectInformation) + { + // must not be empty + if (string.IsNullOrWhiteSpace(entry.Value)) + { + LogIssue(entry.Key, "entry is empty"); + hasObjectIssues = true; + continue; + } + + // require core fields + string[] fields = entry.Value.Split('/'); + if (fields.Length < SObject.objectInfoDescriptionIndex + 1) + { + LogIssue(entry.Key, "too few fields for an object"); + hasObjectIssues = true; + continue; + } + + // check min length for specific types + switch (fields[SObject.objectInfoTypeIndex].Split(new[] { ' ' }, 2)[0]) + { + case "Cooking": + if (fields.Length < SObject.objectInfoBuffDurationIndex + 1) + { + LogIssue(entry.Key, "too few fields for a cooking item"); + hasObjectIssues = true; + } + break; + } + } + + // log error + if (hasObjectIssues) + { + issuesFound = true; + this.Monitor.Log(@"Your Content\Data\ObjectInformation.xnb file seems to be broken or outdated.", LogLevel.Warn); + } + } + + return !issuesFound; + } + + /// <summary>Asynchronously check for a new version of SMAPI and any installed mods, and print alerts to the console if an update is available.</summary> + /// <param name="mods">The mods to include in the update check (if eligible).</param> + private void CheckForUpdatesAsync(IModMetadata[] mods) + { + if (!this.Settings.CheckForUpdates) + return; + + new Thread(() => + { + // create client + WebApiClient client = new WebApiClient(this.Settings.WebApiBaseUrl, Constants.ApiVersion); + + // check SMAPI version + try + { + this.Monitor.Log("Checking for SMAPI update...", LogLevel.Trace); + + ModInfoModel response = client.GetModInfo($"GitHub:{this.Settings.GitHubProjectName}").Single().Value; + if (response.Error != null) + { + this.Monitor.Log("Couldn't check for a new version of SMAPI. This won't affect your game, but you may not be notified of new versions if this keeps happening.", LogLevel.Warn); + this.Monitor.Log($"Error: {response.Error}"); + } + else if (new SemanticVersion(response.Version).IsNewerThan(Constants.ApiVersion)) + this.Monitor.Log($"You can update SMAPI to {response.Version}: {response.Url}", LogLevel.Alert); + else + this.VerboseLog(" OK."); + } + catch (Exception ex) + { + this.Monitor.Log("Couldn't check for a new version of SMAPI. This won't affect your game, but you may not be notified of new versions if this keeps happening.", LogLevel.Warn); + this.Monitor.Log($"Error: {ex.GetLogSummary()}"); + } + + // check mod versions + try + { + // log issues + if (this.Settings.VerboseLogging) + { + this.VerboseLog("Validating mod update keys..."); + foreach (IModMetadata mod in mods) + { + if (mod.Manifest == null) + this.VerboseLog($" {mod.DisplayName}: no manifest."); + else if (mod.Manifest.UpdateKeys == null || !mod.Manifest.UpdateKeys.Any()) + this.VerboseLog($" {mod.DisplayName}: no update keys."); + } + } + + // prepare update keys + Dictionary<string, IModMetadata[]> modsByKey = + ( + from mod in mods + where mod.Manifest?.UpdateKeys != null + from key in mod.Manifest.UpdateKeys + select new { key, mod } + ) + .GroupBy(p => p.key, StringComparer.InvariantCultureIgnoreCase) + .ToDictionary( + group => group.Key, + group => group.Select(p => p.mod).ToArray(), + StringComparer.InvariantCultureIgnoreCase + ); + + // fetch results + this.Monitor.Log($"Checking for updates to {modsByKey.Keys.Count} keys...", LogLevel.Trace); + var results = + ( + from entry in client.GetModInfo(modsByKey.Keys.ToArray()) + from mod in modsByKey[entry.Key] + orderby mod.DisplayName + select new { entry.Key, Mod = mod, Info = entry.Value } + ) + .ToArray(); + + // extract latest versions + IDictionary<IModMetadata, ModInfoModel> updatesByMod = new Dictionary<IModMetadata, ModInfoModel>(); + foreach (var result in results) + { + IModMetadata mod = result.Mod; + ModInfoModel info = result.Info; + + // handle error + if (info.Error != null) + { + this.Monitor.Log($" {mod.DisplayName} ({result.Key}): update error: {info.Error}", LogLevel.Trace); + continue; + } + + // track update + ISemanticVersion localVersion = mod.DataRecord != null + ? new SemanticVersion(mod.DataRecord.GetLocalVersionForUpdateChecks(mod.Manifest.Version.ToString())) + : mod.Manifest.Version; + ISemanticVersion latestVersion = new SemanticVersion(mod.DataRecord != null + ? mod.DataRecord.GetRemoteVersionForUpdateChecks(new SemanticVersion(info.Version).ToString()) + : info.Version + ); + bool isUpdate = latestVersion.IsNewerThan(localVersion); + this.VerboseLog($" {mod.DisplayName} ({result.Key}): {(isUpdate ? $"{mod.Manifest.Version}{(!localVersion.Equals(mod.Manifest.Version) ? $" [{localVersion}]" : "")} => {info.Version}{(!latestVersion.Equals(new SemanticVersion(info.Version)) ? $" [{latestVersion}]" : "")}" : "OK")}."); + if (isUpdate) + { + if (!updatesByMod.TryGetValue(mod, out ModInfoModel other) || latestVersion.IsNewerThan(other.Version)) + updatesByMod[mod] = info; + } + } + + // output + if (updatesByMod.Any()) + { + this.Monitor.Newline(); + this.Monitor.Log($"You can update {updatesByMod.Count} mod{(updatesByMod.Count != 1 ? "s" : "")}:", LogLevel.Alert); + foreach (var entry in updatesByMod.OrderBy(p => p.Key.DisplayName)) + this.Monitor.Log($" {entry.Key.DisplayName} {entry.Value.Version}: {entry.Value.Url}", LogLevel.Alert); + } + } + catch (Exception ex) + { + this.Monitor.Log($"Couldn't check for new mod versions:\n{ex.GetLogSummary()}", LogLevel.Trace); + } + }).Start(); + } + + /// <summary>Create a directory path if it doesn't exist.</summary> + /// <param name="path">The directory path.</param> + private void VerifyPath(string path) + { + try + { + if (!Directory.Exists(path)) + Directory.CreateDirectory(path); + } + catch (Exception ex) + { + this.Monitor.Log($"Couldn't create a path: {path}\n\n{ex.GetLogSummary()}", LogLevel.Error); + } + } + + /// <summary>Load and hook up the given mods.</summary> + /// <param name="mods">The mods to load.</param> + /// <param name="jsonHelper">The JSON helper with which to read mods' JSON files.</param> + /// <param name="contentManager">The content manager to use for mod content.</param> + private void LoadMods(IModMetadata[] mods, JsonHelper jsonHelper, SContentManager contentManager) + { + this.Monitor.Log("Loading mods...", LogLevel.Trace); + + // load mod assemblies + IDictionary<IModMetadata, string> skippedMods = new Dictionary<IModMetadata, string>(); + { + void TrackSkip(IModMetadata mod, string reasonPhrase) => skippedMods[mod] = reasonPhrase; + + AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.TargetPlatform, this.Monitor, this.Settings.DeveloperMode); + AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => modAssemblyLoader.ResolveAssembly(e.Name); + foreach (IModMetadata metadata in mods) + { + // get basic info + IManifest manifest = metadata.Manifest; + string assemblyPath = metadata.Manifest?.EntryDll != null + ? Path.Combine(metadata.DirectoryPath, metadata.Manifest.EntryDll) + : null; + this.Monitor.Log(assemblyPath != null + ? $"Loading {metadata.DisplayName} from {assemblyPath.Replace(Constants.ModPath, "").TrimStart(Path.DirectorySeparatorChar)}..." + : $"Loading {metadata.DisplayName}...", LogLevel.Trace); + + // validate status + if (metadata.Status == ModMetadataStatus.Failed) + { + this.Monitor.Log($" Failed: {metadata.Error}", LogLevel.Trace); + TrackSkip(metadata, metadata.Error); + continue; + } + + // preprocess & load mod assembly + Assembly modAssembly; + try + { + modAssembly = modAssemblyLoader.Load(metadata, assemblyPath, assumeCompatible: metadata.DataRecord?.GetCompatibility(metadata.Manifest.Version)?.Status == ModStatus.AssumeCompatible); + } + catch (IncompatibleInstructionException ex) + { + TrackSkip(metadata, $"it's no longer compatible (detected {ex.NounPhrase}). Please check for a newer version of the mod."); + continue; + } + catch (SAssemblyLoadFailedException ex) + { + TrackSkip(metadata, $"its DLL '{manifest.EntryDll}' couldn't be loaded: {ex.Message}"); + continue; + } + catch (Exception ex) + { + TrackSkip(metadata, $"its DLL '{manifest.EntryDll}' couldn't be loaded:\n{ex.GetLogSummary()}"); + continue; + } + + // validate assembly + try + { + int modEntries = modAssembly.DefinedTypes.Count(type => typeof(Mod).IsAssignableFrom(type) && !type.IsAbstract); + if (modEntries == 0) + { + TrackSkip(metadata, $"its DLL has no '{nameof(Mod)}' subclass."); + continue; + } + if (modEntries > 1) + { + TrackSkip(metadata, $"its DLL contains multiple '{nameof(Mod)}' subclasses."); + continue; + } + } + catch (Exception ex) + { + TrackSkip(metadata, $"its DLL couldn't be loaded:\n{ex.GetLogSummary()}"); + continue; + } + + // initialise mod + try + { + // get implementation + TypeInfo modEntryType = modAssembly.DefinedTypes.First(type => typeof(Mod).IsAssignableFrom(type) && !type.IsAbstract); + Mod mod = (Mod)modAssembly.CreateInstance(modEntryType.ToString()); + if (mod == null) + { + TrackSkip(metadata, "its entry class couldn't be instantiated."); + continue; + } + + // inject data + { + IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName); + ICommandHelper commandHelper = new CommandHelper(manifest.UniqueID, metadata.DisplayName, this.CommandManager); + IContentHelper contentHelper = new ContentHelper(contentManager, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor); + IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, metadata.DisplayName, this.Reflection); + IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry); + ITranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, manifest.Name, contentManager.GetLocale(), contentManager.GetCurrentLanguage()); + + mod.ModManifest = manifest; + mod.Helper = new ModHelper(manifest.UniqueID, metadata.DirectoryPath, jsonHelper, contentHelper, commandHelper, modRegistryHelper, reflectionHelper, translationHelper); + mod.Monitor = monitor; + } + + // track mod + metadata.SetMod(mod); + this.ModRegistry.Add(metadata); + } + catch (Exception ex) + { + TrackSkip(metadata, $"initialisation failed:\n{ex.GetLogSummary()}"); + } + } + } + IModMetadata[] loadedMods = this.ModRegistry.GetMods().ToArray(); + + // log skipped mods + this.Monitor.Newline(); + if (skippedMods.Any()) + { + this.Monitor.Log($"Skipped {skippedMods.Count} mods:", LogLevel.Error); + foreach (var pair in skippedMods.OrderBy(p => p.Key.DisplayName)) + { + IModMetadata mod = pair.Key; + string reason = pair.Value; + + if (mod.Manifest?.Version != null) + this.Monitor.Log($" {mod.DisplayName} {mod.Manifest.Version} because {reason}", LogLevel.Error); + else + this.Monitor.Log($" {mod.DisplayName} because {reason}", LogLevel.Error); + } + this.Monitor.Newline(); + } + + // log loaded mods + this.Monitor.Log($"Loaded {loadedMods.Length} mods" + (loadedMods.Length > 0 ? ":" : "."), LogLevel.Info); + foreach (IModMetadata metadata in loadedMods.OrderBy(p => p.DisplayName)) + { + IManifest manifest = metadata.Manifest; + this.Monitor.Log( + $" {metadata.DisplayName} {manifest.Version}" + + (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "") + + (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""), + LogLevel.Info + ); + } + this.Monitor.Newline(); + + // initialise translations + this.ReloadTranslations(); + + // initialise loaded mods + foreach (IModMetadata metadata in loadedMods) + { + // add interceptors + if (metadata.Mod.Helper.Content is ContentHelper helper) + { + this.ContentManager.Editors[metadata] = helper.ObservableAssetEditors; + this.ContentManager.Loaders[metadata] = helper.ObservableAssetLoaders; + } + + // call entry method + try + { + IMod mod = metadata.Mod; + mod.Entry(mod.Helper); + } + catch (Exception ex) + { + this.Monitor.Log($"{metadata.DisplayName} failed on entry and might not work correctly. Technical details:\n{ex.GetLogSummary()}", LogLevel.Error); + } + } + + // invalidate cache entries when needed + // (These listeners are registered after Entry to avoid repeatedly reloading assets as mods initialise.) + foreach (IModMetadata metadata in loadedMods) + { + if (metadata.Mod.Helper.Content is ContentHelper helper) + { + helper.ObservableAssetEditors.CollectionChanged += (sender, e) => + { + if (e.NewItems.Count > 0) + { + this.Monitor.Log("Invalidating cache entries for new asset editors...", LogLevel.Trace); + this.ContentManager.InvalidateCacheFor(e.NewItems.Cast<IAssetEditor>().ToArray(), new IAssetLoader[0]); + } + }; + helper.ObservableAssetLoaders.CollectionChanged += (sender, e) => + { + if (e.NewItems.Count > 0) + { + this.Monitor.Log("Invalidating cache entries for new asset loaders...", LogLevel.Trace); + this.ContentManager.InvalidateCacheFor(new IAssetEditor[0], e.NewItems.Cast<IAssetLoader>().ToArray()); + } + }; + } + } + + // reset cache now if any editors or loaders were added during entry + IAssetEditor[] editors = loadedMods.SelectMany(p => p.Mod.Helper.Content.AssetEditors).ToArray(); + IAssetLoader[] loaders = loadedMods.SelectMany(p => p.Mod.Helper.Content.AssetLoaders).ToArray(); + if (editors.Any() || loaders.Any()) + { + this.Monitor.Log("Invalidating cached assets for new editors & loaders...", LogLevel.Trace); + this.ContentManager.InvalidateCacheFor(editors, loaders); + } + } + + /// <summary>Reload translations for all mods.</summary> + private void ReloadTranslations() + { + JsonHelper jsonHelper = new JsonHelper(); + foreach (IModMetadata metadata in this.ModRegistry.GetMods()) + { + // read translation files + IDictionary<string, IDictionary<string, string>> translations = new Dictionary<string, IDictionary<string, string>>(); + DirectoryInfo translationsDir = new DirectoryInfo(Path.Combine(metadata.DirectoryPath, "i18n")); + if (translationsDir.Exists) + { + foreach (FileInfo file in translationsDir.EnumerateFiles("*.json")) + { + string locale = Path.GetFileNameWithoutExtension(file.Name.ToLower().Trim()); + try + { + translations[locale] = jsonHelper.ReadJsonFile<IDictionary<string, string>>(file.FullName); + } + catch (Exception ex) + { + this.Monitor.Log($"Couldn't read {metadata.DisplayName}'s i18n/{locale}.json file: {ex.GetLogSummary()}"); + } + } + } + + // update translation + TranslationHelper translationHelper = (TranslationHelper)metadata.Mod.Helper.Translation; + translationHelper.SetTranslations(translations); + } + } + + /// <summary>The method called when the user submits a core SMAPI command in the console.</summary> + /// <param name="name">The command name.</param> + /// <param name="arguments">The command arguments.</param> + private void HandleCommand(string name, string[] arguments) + { + switch (name) + { + case "help": + if (arguments.Any()) + { + Command result = this.CommandManager.Get(arguments[0]); + if (result == null) + this.Monitor.Log("There's no command with that name.", LogLevel.Error); + else + this.Monitor.Log($"{result.Name}: {result.Documentation}\n(Added by {result.ModName}.)", LogLevel.Info); + } + else + { + string message = "The following commands are registered:\n"; + IGrouping<string, string>[] groups = (from command in this.CommandManager.GetAll() orderby command.ModName, command.Name group command.Name by command.ModName).ToArray(); + foreach (var group in groups) + { + string modName = group.Key; + string[] commandNames = group.ToArray(); + message += $"{modName}:\n {string.Join("\n ", commandNames)}\n\n"; + } + message += "For more information about a command, type 'help command_name'."; + + this.Monitor.Log(message, LogLevel.Info); + } + break; + + case "reload_i18n": + this.ReloadTranslations(); + this.Monitor.Log("Reloaded translation files for all mods. This only affects new translations the mods fetch; if they cached some text, it may not be updated.", LogLevel.Info); + break; + + default: + throw new NotSupportedException($"Unrecognise core SMAPI command '{name}'."); + } + } + + /// <summary>Redirect messages logged directly to the console to the given monitor.</summary> + /// <param name="monitor">The monitor with which to log messages.</param> + /// <param name="message">The message to log.</param> + private void HandleConsoleMessage(IMonitor monitor, string message) + { + LogLevel level = message.Contains("Exception") ? LogLevel.Error : LogLevel.Trace; // intercept potential exceptions + monitor.Log(message, level); + } + + /// <summary>Show a 'press any key to exit' message, and exit when they press a key.</summary> + private void PressAnyKeyToExit() + { + this.Monitor.Log("Game has ended. Press any key to exit.", LogLevel.Info); + Program.PressAnyKeyToExit(showMessage: false); + } + + /// <summary>Show a 'press any key to exit' message, and exit when they press a key.</summary> + /// <param name="showMessage">Whether to print a 'press any key to exit' message to the console.</param> + private static void PressAnyKeyToExit(bool showMessage) + { + if (showMessage) + Console.WriteLine("Game has ended. Press any key to exit."); + Thread.Sleep(100); + Console.ReadKey(); + Environment.Exit(0); + } + + /// <summary>Get a monitor instance derived from SMAPI's current settings.</summary> + /// <param name="name">The name of the module which will log messages with this instance.</param> + private Monitor GetSecondaryMonitor(string name) + { + return new Monitor(name, this.ConsoleManager, this.LogFile, this.CancellationTokenSource) + { + WriteToConsole = this.Monitor.WriteToConsole, + ShowTraceInConsole = this.Settings.DeveloperMode, + ShowFullStampInConsole = this.Settings.DeveloperMode + }; + } + + /// <summary>Get a human-readable name for the current platform.</summary> + [SuppressMessage("ReSharper", "EmptyGeneralCatchClause", Justification = "Error suppressed deliberately to fallback to default behaviour.")] + private string GetFriendlyPlatformName() + { +#if SMAPI_FOR_WINDOWS + try + { + return new ManagementObjectSearcher("SELECT Caption FROM Win32_OperatingSystem") + .Get() + .Cast<ManagementObject>() + .Select(entry => entry.GetPropertyValue("Caption").ToString()) + .FirstOrDefault(); + } + catch { } +#endif + return Environment.OSVersion.ToString(); + } + + /// <summary>Log a message if verbose mode is enabled.</summary> + /// <param name="message">The message to log.</param> + private void VerboseLog(string message) + { + if (this.Settings.VerboseLogging) + this.Monitor.Log(message, LogLevel.Trace); + } + } +} diff --git a/src/StardewModdingAPI/Properties/AssemblyInfo.cs b/src/StardewModdingAPI/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..b0a065f5 --- /dev/null +++ b/src/StardewModdingAPI/Properties/AssemblyInfo.cs @@ -0,0 +1,9 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("Stardew Modding API (SMAPI)")] +[assembly: AssemblyDescription("A modding API for Stardew Valley.")] +[assembly: Guid("5c3f7f42-fefd-43db-aaea-92ea3bcad531")] +[assembly: InternalsVisibleTo("StardewModdingAPI.Tests")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // Moq for unit testing diff --git a/src/StardewModdingAPI/SemanticVersion.cs b/src/StardewModdingAPI/SemanticVersion.cs new file mode 100644 index 00000000..1b99dae6 --- /dev/null +++ b/src/StardewModdingAPI/SemanticVersion.cs @@ -0,0 +1,237 @@ +using System; +using System.Text.RegularExpressions; +using Newtonsoft.Json; + +namespace StardewModdingAPI +{ + /// <summary>A semantic version with an optional release tag.</summary> + public class SemanticVersion : ISemanticVersion + { + /********* + ** Properties + *********/ + /// <summary>A regular expression matching a semantic version string.</summary> + /// <remarks> + /// This pattern is derived from the BNF documentation in the <a href="https://github.com/mojombo/semver">semver repo</a>, + /// with three important deviations intended to support Stardew Valley mod conventions: + /// - allows short-form "x.y" versions; + /// - allows hyphens in prerelease tags as synonyms for dots (like "-unofficial-update.3"); + /// - doesn't allow '+build' suffixes. + /// </remarks> + private static readonly Regex Regex = new Regex(@"^(?>(?<major>0|[1-9]\d*))\.(?>(?<minor>0|[1-9]\d*))(?>(?:\.(?<patch>0|[1-9]\d*))?)(?:-(?<prerelease>(?>[a-z0-9]+[\-\.]?)+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.ExplicitCapture); + + + /********* + ** Accessors + *********/ + /// <summary>The major version incremented for major API changes.</summary> + public int MajorVersion { get; } + + /// <summary>The minor version incremented for backwards-compatible changes.</summary> + public int MinorVersion { get; } + + /// <summary>The patch version for backwards-compatible bug fixes.</summary> + public int PatchVersion { get; } + + /// <summary>An optional build tag.</summary> + public string Build { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="majorVersion">The major version incremented for major API changes.</param> + /// <param name="minorVersion">The minor version incremented for backwards-compatible changes.</param> + /// <param name="patchVersion">The patch version for backwards-compatible bug fixes.</param> + /// <param name="build">An optional build tag.</param> + [JsonConstructor] + public SemanticVersion(int majorVersion, int minorVersion, int patchVersion, string build = null) + { + this.MajorVersion = majorVersion; + this.MinorVersion = minorVersion; + this.PatchVersion = patchVersion; + this.Build = this.GetNormalisedTag(build); + } + + /// <summary>Construct an instance.</summary> + /// <param name="version">The semantic version string.</param> + /// <exception cref="ArgumentNullException">The <paramref name="version"/> is null.</exception> + /// <exception cref="FormatException">The <paramref name="version"/> is not a valid semantic version.</exception> + public SemanticVersion(string version) + { + // parse + if (version == null) + throw new ArgumentNullException(nameof(version), "The input version string can't be null."); + var match = SemanticVersion.Regex.Match(version.Trim()); + if (!match.Success) + throw new FormatException($"The input '{version}' isn't a valid semantic version."); + + // initialise + this.MajorVersion = int.Parse(match.Groups["major"].Value); + this.MinorVersion = match.Groups["minor"].Success ? int.Parse(match.Groups["minor"].Value) : 0; + this.PatchVersion = match.Groups["patch"].Success ? int.Parse(match.Groups["patch"].Value) : 0; + this.Build = match.Groups["prerelease"].Success ? this.GetNormalisedTag(match.Groups["prerelease"].Value) : null; + } + + /// <summary>Get an integer indicating whether this version precedes (less than 0), supercedes (more than 0), or is equivalent to (0) the specified version.</summary> + /// <param name="other">The version to compare with this instance.</param> + /// <exception cref="ArgumentNullException">The <paramref name="other"/> value is null.</exception> + /// <remarks>The implementation is defined by Semantic Version 2.0 (http://semver.org/).</remarks> + public int CompareTo(ISemanticVersion other) + { + if (other == null) + throw new ArgumentNullException(nameof(other)); + + const int same = 0; + const int curNewer = 1; + const int curOlder = -1; + + // compare stable versions + if (this.MajorVersion != other.MajorVersion) + return this.MajorVersion.CompareTo(other.MajorVersion); + if (this.MinorVersion != other.MinorVersion) + return this.MinorVersion.CompareTo(other.MinorVersion); + if (this.PatchVersion != other.PatchVersion) + return this.PatchVersion.CompareTo(other.PatchVersion); + if (this.Build == other.Build) + return same; + + // stable supercedes pre-release + bool curIsStable = string.IsNullOrWhiteSpace(this.Build); + bool otherIsStable = string.IsNullOrWhiteSpace(other.Build); + if (curIsStable) + return curNewer; + if (otherIsStable) + return curOlder; + + // compare two pre-release tag values + string[] curParts = this.Build.Split('.', '-'); + string[] otherParts = other.Build.Split('.', '-'); + for (int i = 0; i < curParts.Length; i++) + { + // longer prerelease tag supercedes if otherwise equal + if (otherParts.Length <= i) + return curNewer; + + // compare if different + if (curParts[i] != otherParts[i]) + { + // compare numerically if possible + { + if (int.TryParse(curParts[i], out int curNum) && int.TryParse(otherParts[i], out int otherNum)) + return curNum.CompareTo(otherNum); + } + + // else compare lexically + return string.Compare(curParts[i], otherParts[i], StringComparison.OrdinalIgnoreCase); + } + } + + // fallback (this should never happen) + return string.Compare(this.ToString(), other.ToString(), StringComparison.InvariantCultureIgnoreCase); + } + + /// <summary>Get whether this version is older than the specified version.</summary> + /// <param name="other">The version to compare with this instance.</param> + public bool IsOlderThan(ISemanticVersion other) + { + return this.CompareTo(other) < 0; + } + + /// <summary>Get whether this version is older than the specified version.</summary> + /// <param name="other">The version to compare with this instance.</param> + /// <exception cref="FormatException">The specified version is not a valid semantic version.</exception> + public bool IsOlderThan(string other) + { + return this.IsOlderThan(new SemanticVersion(other)); + } + + /// <summary>Get whether this version is newer than the specified version.</summary> + /// <param name="other">The version to compare with this instance.</param> + public bool IsNewerThan(ISemanticVersion other) + { + return this.CompareTo(other) > 0; + } + + /// <summary>Get whether this version is newer than the specified version.</summary> + /// <param name="other">The version to compare with this instance.</param> + /// <exception cref="FormatException">The specified version is not a valid semantic version.</exception> + public bool IsNewerThan(string other) + { + return this.IsNewerThan(new SemanticVersion(other)); + } + + /// <summary>Get whether this version is between two specified versions (inclusively).</summary> + /// <param name="min">The minimum version.</param> + /// <param name="max">The maximum version.</param> + public bool IsBetween(ISemanticVersion min, ISemanticVersion max) + { + return this.CompareTo(min) >= 0 && this.CompareTo(max) <= 0; + } + + /// <summary>Get whether this version is between two specified versions (inclusively).</summary> + /// <param name="min">The minimum version.</param> + /// <param name="max">The maximum version.</param> + /// <exception cref="FormatException">One of the specified versions is not a valid semantic version.</exception> + public bool IsBetween(string min, string max) + { + return this.IsBetween(new SemanticVersion(min), new SemanticVersion(max)); + } + + /// <summary>Indicates whether the current object is equal to another object of the same type.</summary> + /// <returns>true if the current object is equal to the <paramref name="other" /> parameter; otherwise, false.</returns> + /// <param name="other">An object to compare with this object.</param> + public bool Equals(ISemanticVersion other) + { + return other != null && this.CompareTo(other) == 0; + } + + /// <summary>Get a string representation of the version.</summary> + public override string ToString() + { + // version + string result = this.PatchVersion != 0 + ? $"{this.MajorVersion}.{this.MinorVersion}.{this.PatchVersion}" + : $"{this.MajorVersion}.{this.MinorVersion}"; + + // tag + string tag = this.Build; + if (tag != null) + result += $"-{tag}"; + return result; + } + + /// <summary>Parse a version string without throwing an exception if it fails.</summary> + /// <param name="version">The version string.</param> + /// <param name="parsed">The parsed representation.</param> + /// <returns>Returns whether parsing the version succeeded.</returns> + internal static bool TryParse(string version, out ISemanticVersion parsed) + { + try + { + parsed = new SemanticVersion(version); + return true; + } + catch + { + parsed = null; + return false; + } + } + + + /********* + ** Private methods + *********/ + /// <summary>Get a normalised build tag.</summary> + /// <param name="tag">The tag to normalise.</param> + private string GetNormalisedTag(string tag) + { + tag = tag?.Trim(); + if (string.IsNullOrWhiteSpace(tag) || tag == "0") // '0' from incorrect examples in old SMAPI documentation + return null; + return tag; + } + } +} diff --git a/src/StardewModdingAPI/StardewModdingAPI.config.json b/src/StardewModdingAPI/StardewModdingAPI.config.json new file mode 100644 index 00000000..ebc1235b --- /dev/null +++ b/src/StardewModdingAPI/StardewModdingAPI.config.json @@ -0,0 +1,2063 @@ +/* + + + +This file contains advanced configuration for SMAPI. You generally shouldn't change this file. + + + +*/ +{ + /** + * Whether to enable features intended for mod developers. Currently this only makes TRACE-level + * messages appear in the console. + */ + "DeveloperMode": true, + + /** + * Whether SMAPI should check for newer versions of SMAPI and mods when you load the game. If new + * versions are available, an alert will be shown in the console. This doesn't affect the load + * time even if your connection is offline or slow, because it happens in the background. + */ + "CheckForUpdates": true, + + /** + * SMAPI's GitHub project name, used to perform update checks. + */ + "GitHubProjectName": "Pathoschild/SMAPI", + + /** + * The base URL for SMAPI's web API, used to perform update checks. + * Note: the protocol will be changed to http:// on Linux/Mac due to OpenSSL issues with the + * game's bundled Mono. + */ + "WebApiBaseUrl": "https://api.smapi.io", + + /** + * Whether SMAPI should log more information about the game context. + */ + "VerboseLogging": false, + + /** + * Extra metadata about some SMAPI mods. All fields except 'ID' are optional. + * + * - 'ID' uniquely identifies the mod across all versions, even if its manifest fields changed or + * the mod doesn't have a unique ID. The format is as follows: + * - If the mod's identifier changed over time, multiple variants are separated by |. + * - Each variant can take one of two forms: a simple string matching the mod's UniqueID, + * or a JSON structure containing any of three manifest fields (ID, Name, and Author) to + * match. + * + * - 'UpdateKeys' specifies the value of the equivalent manifest field if it's not already set. + * This is used to enable update checks for older mods that haven't been updated to use it yet. + * + * - 'AlternativeUrl' specifies a URL where the player can find an unofficial update or + * alternative if the mod is no longer compatible. + * + * - 'Compatibility' overrides SMAPI's normal compatibility detection. The keys are version + * ranges in the form lower~upper, where either side can be blank for an unbounded range. (For + * example, "~1.0" means all versions up to 1.0 inclusively.) The values have two fields: + * - 'Status' specifies the compatibility. Valid values are Obsolete (SMAPI won't load it + * because the mod should no longer be used), AssumeBroken (SMAPI won't load it because + * the specified version isn't compatible), or AssumeCompatible (SMAPI will load it even + * if it detects incompatible code). + * - 'ReasonPhrase' (optional) specifies a message to show to the player explaining why the + * mod isn't loaded. This has no effect for AssumeCompatible. + * + * - 'MapLocalVersions' and 'MapRemoteVersions' substitute versions for update checks. For + * example, if the API returns version '1.1-1078', MapRemoteVersions can map it to '1.1' when + * comparing to the mod's current version. This is only intended to support legacy mods with + * injected update keys. + */ + "ModData": [ + { + // AccessChestAnywhere + "ID": "AccessChestAnywhere", + "UpdateKeys": [ "Nexus:257" ], + "AlternativeUrl": "https://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.1": { "Status": "AssumeBroken" } // broke in SDV 1.1 + }, + "MapLocalVersions": { + "1.1-1078": "1.1" + } + }, + { + // AdjustArtisanPrices + "ID": "1e36d4ca-c7ef-4dfb-9927-d27a6c3c8bdc", + "UpdateKeys": [ "Chucklefish:3532" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~0.1": { "Status": "AssumeBroken" } // broke in SMAPI 1.9 + } + }, + { + // Adjust Monster + "ID": "mmanlapat.AdjustMonster", + "UpdateKeys": [ "Nexus:1161" ] + }, + { + // Advanced Location Loader + "ID": "Entoarox.AdvancedLocationLoader", + //"UpdateKeys": [ "Chucklefish:3619" ], // Entoarox opted out of mod update checks + "Compatibility": { + "~1.2.10": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Adventure Shop Inventory + "ID": "HammurabiAdventureShopInventory", + "UpdateKeys": [ "Chucklefish:4608" ] + }, + { + // AgingMod + "ID": "skn.AgingMod", + "UpdateKeys": [ "Nexus:1129" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // All Crops All Seasons + "ID": "29ee8246-d67b-4242-a340-35a9ae0d5dd7 | community.AllCropsAllSeasons", // changed in 1.3 + "UpdateKeys": [ "Nexus:170" ] + }, + { + // All Professions + "ID": "8c37b1a7-4bfb-4916-9d8a-9533e6363ea3 | community.AllProfessions", // changed in 1.2 + "UpdateKeys": [ "Nexus:174" ] + }, + { + // Almighty Tool + "ID": "AlmightyTool.dll | 439", // changed in 1.2.1 + "UpdateKeys": [ "Nexus:439" ], + "Compatibility": { + "~1.1.1": { "Status": "AssumeBroken" } // broke in SDV 1.2 + }, + "MapRemoteVersions": { + "1.21": "1.2.1" + } + }, + { + // Animal Mood Fix + "ID": "GPeters-AnimalMoodFix", + "Compatibility": { + "~": { + "Status": "Obsolete", + "ReasonPhrase": "the animal mood bugs were fixed in Stardew Valley 1.2." + } + } + }, + { + // Animal Sitter + "ID": "AnimalSitter.dll | jwdred.AnimalSitter", // changed in 1.0.9 + "UpdateKeys": [ "Nexus:581" ], + "Compatibility": { + "~1.0.8": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // A Tapper's Dream + "ID": "ddde5195-8f85-4061-90cc-0d4fd5459358", + "UpdateKeys": [ "Nexus:260" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.4": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Auto Animal Doors + "ID": "AaronTaggart.AutoAnimalDoors", + "UpdateKeys": [ "Nexus:1019" ], + "MapRemoteVersions": { + "1.1.1": "1.1" // manifest not updated + } + }, + { + // Auto-Eat + "ID": "BALANCEMOD_AutoEat | Permamiss.AutoEat", // changed in 1.1.1 + "UpdateKeys": [ "Nexus:643" ] + }, + { + // AutoGate + "ID": "AutoGate", + "UpdateKeys": [ "Nexus:820" ] + }, + { + // Automate + "ID": "Pathoschild.Automate", + "UpdateKeys": [ "Nexus:1063" ] + }, + { + // Automated Doors + "ID": "1abcfa07-2cf4-4dc3-a6e9-6068b642112b | azah.automated-doors", // changed in 1.4.1 + "UpdateKeys": [ "GitHub:azah/AutomatedDoors" ], + "MapLocalVersions": { + "1.4.1-1": "1.4.1" + } + }, + { + // AutoSpeed + "ID": "{ID:'4be88c18-b6f3-49b0-ba96-f94b1a5be890', Name:'AutoSpeed'} | Omegasis.AutoSpeed", // changed in 1.4; disambiguate from other Alpha_Omegasis mods + "UpdateKeys": [ "Nexus:443" ] + }, + { + // Basic Sprinkler Improved + "ID": "lrsk_sdvm_bsi.0117171308", + "UpdateKeys": [ "Nexus:833" ], + "MapRemoteVersions": { + "1.0.2": "1.0.1-release" // manifest not updated + } + }, + { + // Better Hay + "ID": "cat.betterhay", + "UpdateKeys": [ "Nexus:1430" ] + }, + { + // Better Quality More Seasons + "ID": "SB_BQMS", + "UpdateKeys": [ "Nexus:935" ] + }, + { + // Better Quarry + "ID": "BetterQuarry", + "UpdateKeys": [ "Nexus:771" ] + }, + { + // Better Ranching + "ID": "BetterRanching", + "UpdateKeys": [ "Nexus:859" ] + }, + { + // Better Shipping Box + "ID": "Kithio:BetterShippingBox", + "UpdateKeys": [ "Chucklefish:4302" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0.2": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + }, + "MapLocalVersions": { + "1.0.1": "1.0.2" + } + }, + { + // Better Sprinklers + "ID": "SPDSprinklersMod | Speeder.BetterSprinklers", // changed in 2.3 + "UpdateKeys": [ "Nexus:41" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~2.3.1-pathoschild-update": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Billboard Anywhere + "ID": "{ID:'7ad4f6f7-c3de-4729-a40f-7a11d2b2a358', Name:'Billboard Anywhere'} | Omegasis.BillboardAnywhere", // changed in 1.4; disambiguate from other mods by Alpha_Omegasis + "UpdateKeys": [ "Nexus:492" ] + }, + { + // Birthday Mail + "ID": "005e02dc-d900-425c-9c68-1ff55c5a295d | KathrynHazuka.BirthdayMail", // changed in 1.2.3-pathoschild-update + "UpdateKeys": [ "Nexus:276" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.2.2": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Breed Like Rabbits + "ID": "dycedarger.breedlikerabbits", + "UpdateKeys": [ "Nexus:948" ] + }, + { + // Build Endurance + "ID": "{ID:'4be88c18-b6f3-49b0-ba96-f94b1a5be890', Name:'BuildEndurance'} | Omegasis.BuildEndurance", // changed in 1.4; disambiguate from other Alpha_Omegasis mods + "UpdateKeys": [ "Nexus:445" ], + "Compatibility": { + "~1.3": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Build Health + "ID": "{ID:'4be88c18-b6f3-49b0-ba96-f94b1a5be890', Name:'BuildHealth'} | Omegasis.BuildHealth", // changed in 1.4; disambiguate from other Alpha_Omegasis mods + "UpdateKeys": [ "Nexus:446" ], + "Compatibility": { + "~1.3": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Buy Cooking Recipes + "ID": "Denifia.BuyRecipes", + "UpdateKeys": [ "Nexus:1126" ], // added in 1.0.1 (2017-10-04) + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Buy Back Collectables + "ID": "BuyBackCollectables | Omegasis.BuyBackCollectables", // changed in 1.4 + "UpdateKeys": [ "Nexus:507" ], + "Compatibility": { + "~1.3": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Carry Chest + "ID": "spacechase0.CarryChest", + "UpdateKeys": [ "Nexus:1333" ] + }, + { + // Casks Anywhere + "ID": "CasksAnywhere", + "UpdateKeys": [ "Nexus:878" ], + "MapLocalVersions": { + "1.1-alpha": "1.1" + } + }, + { + // Categorize Chests + "ID": "CategorizeChests", + "UpdateKeys": [ "Nexus:1300" ] + }, + { + // ChefsCloset + "ID": "Duder.ChefsCloset", + "UpdateKeys": [ "Nexus:1030" ], + "MapLocalVersions": { + "1.3-1": "1.3" + } + }, + { + // Chest Label System + "ID": "SPDChestLabel | Speeder.ChestLabel", // changed in 1.5.1-pathoschild-update + "UpdateKeys": [ "Nexus:242" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.6": { "Status": "AssumeBroken" } // broke in SDV 1.1 + } + }, + { + // Chest Pooling + "ID": "ChestPooling.dll | mralbobo.ChestPooling", // changed in 1.3 + "UpdateKeys": [ "GitHub:mralbobo/stardew-chest-pooling" ], + "Compatibility": { + "~1.2": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Chests Anywhere + "ID": "ChestsAnywhere | Pathoschild.ChestsAnywhere", // changed in 1.9 + "UpdateKeys": [ "Nexus:518" ], + "Compatibility": { + "~1.9-beta": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Choose Baby Gender + "ID": "ChooseBabyGender.dll", + "UpdateKeys": [ "Nexus:590" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0.2": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // CJB Automation + "ID": "CJBAutomation", + "UpdateKeys": [ "Nexus:211" ], + "AlternativeUrl": "http://www.nexusmods.com/stardewvalley/mods/1063", + "Compatibility": { + "~1.4": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // CJB Cheats Menu + "ID": "CJBCheatsMenu | CJBok.CheatsMenu", // changed in 1.14 + "UpdateKeys": [ "Nexus:4" ], + "Compatibility": { + "~1.12": { "Status": "AssumeBroken" } // broke in SDV 1.1 + } + }, + { + // CJB Item Spawner + "ID": "CJBItemSpawner | CJBok.ItemSpawner", // changed in 1.7 + "UpdateKeys": [ "Nexus:93" ], + "Compatibility": { + "~1.5": { "Status": "AssumeBroken" } // broke in SDV 1.1 + } + }, + { + // CJB Show Item Sell Price + "ID": "CJBShowItemSellPrice | CJBok.ShowItemSellPrice", // changed in 1.7 + "UpdateKeys": [ "Nexus:5" ], + "Compatibility": { + "~1.6": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Clean Farm + "ID": "tstaples.CleanFarm", + "UpdateKeys": [ "Nexus:794" ] + }, + { + // Climates of Ferngill + "ID": "KoihimeNakamura.ClimatesOfFerngill", + "UpdateKeys": [ "Nexus:604" ], + "Compatibility": { + "~1.0": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Cold Weather Haley + "ID": "LordXamon.ColdWeatherHaleyPRO", + "UpdateKeys": [ "Nexus:1169" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Colored Chests + "ID": "4befde5c-731c-4853-8e4b-c5cdf946805f", + "Compatibility": { + "~": { + "Status": "Obsolete", + "ReasonPhrase": "colored chests were added in Stardew Valley 1.1." + } + } + }, + { + // Combat with Farm Implements + "ID": "SPDFarmingImplementsInCombat", + "UpdateKeys": [ "Nexus:313" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Community Bundle Item Tooltip + "ID": "musbah.bundleTooltip", + "UpdateKeys": [ "Nexus:1329" ] + }, + { + // Concentration on Farming + "ID": "punyo.ConcentrationOnFarming", + "UpdateKeys": [ "Nexus:1445" ] + }, + { + // Configurable Machines + "ID": "21da6619-dc03-4660-9794-8e5b498f5b97", + "UpdateKeys": [ "Nexus:280" ], + "MapLocalVersions": { + "1.2-beta": "1.2" + } + }, + { + // Configurable Shipping Dates + "ID": "ConfigurableShippingDates", + "UpdateKeys": [ "Nexus:675" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.1.1": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Cooking Skill + "ID": "CookingSkill | spacechase0.CookingSkill", // changed in 1.0.4–6 + "UpdateKeys": [ "Nexus:522" ], + "Compatibility": { + "~1.0.6": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // CrabNet + "ID": "CrabNet.dll | jwdred.CrabNet", // changed in 1.0.5 + "UpdateKeys": [ "Nexus:584" ], + "Compatibility": { + "~1.0.4": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Current Location + "ID": "CurrentLocation102120161203", + "UpdateKeys": [ "Nexus:638" ] + }, + { + // Custom Critters + "ID": "spacechase0.CustomCritters", + "UpdateKeys": [ "Nexus:1255" ] + }, + { + // Custom Element Handler + "ID": "Platonymous.CustomElementHandler", + "UpdateKeys": [ "Nexus:1068" ] // added in 1.3.1 + }, + { + // Custom Farming + "ID": "Platonymous.CustomFarming", + "UpdateKeys": [ "Nexus:991" ] // added in 0.6.1 + }, + { + // Custom Farming Automate Bridge + "ID": "Platonymous.CFAutomate", + "Compatibility": { + "~1.0.1": { "Status": "AssumeBroken" } // no longer compatible with Automate + } + }, + { + // Custom Farm Types + "ID": "spacechase0.CustomFarmTypes", + "UpdateKeys": [ "Nexus:1140" ] + }, + { + // Custom Furniture + "ID": "Platonymous.CustomFurniture", + "UpdateKeys": [ "Nexus:1254" ] // added in 0.4.1 + }, + { + // Customize Exterior + "ID": "CustomizeExterior | spacechase0.CustomizeExterior", // changed in 1.0.3 + "UpdateKeys": [ "Nexus:1099" ], + "Compatibility": { + "~1.0.2": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Customizable Cart Redux + "ID": "KoihimeNakamura.CCR", + "UpdateKeys": [ "Nexus:1402" ], + "MapLocalVersions": { + "1.1-20170917": "1.1" + } + }, + { + // Customizable Traveling Cart Days + "ID": "TravelingCartYyeahdude", + "UpdateKeys": [ "Nexus:567" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Custom Linens + "ID": "Mevima.CustomLinens", + "UpdateKeys": [ "Nexus:1027" ], + "MapRemoteVersions": { + "1.1": "1.0" // manifest not updated + } + }, + { + // Custom Shops Redux + "ID": "Omegasis.CustomShopReduxGui", + "UpdateKeys": [ "Nexus:1378" ] + }, + { + // Custom TV + "ID": "Platonymous.CustomTV", + "UpdateKeys": [ "Nexus:1139" ] // added in 1.0.6 + }, + { + // Daily Luck Message + "ID": "Schematix.DailyLuckMessage", + "UpdateKeys": [ "Nexus:1327" ] + }, + { + // Daily News + "ID": "bashNinja.DailyNews", + "UpdateKeys": [ "Nexus:1141" ], + "Compatibility": { + "~1.1": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Daily Quest Anywhere + "ID": "DailyQuest | Omegasis.DailyQuestAnywhere", // changed in 1.4 + "UpdateKeys": [ "Nexus:513" ] + }, + { + // Debug Mode + "ID": "Pathoschild.Stardew.DebugMode | Pathoschild.DebugMode", // changed in 1.4 + "UpdateKeys": [ "Nexus:679" ] + }, + { + // Dynamic Checklist + "ID": "gunnargolf.DynamicChecklist", + "UpdateKeys": [ "Nexus:1145" ], // added in 1.0.1-pathoschild-update + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Dynamic Horses + "ID": "Bpendragon-DynamicHorses", + "UpdateKeys": [ "Nexus:874" ], + "MapRemoteVersions": { + "1.2": "1.1-release" // manifest not updated + } + }, + { + // Dynamic Machines + "ID": "DynamicMachines", + "UpdateKeys": [ "Nexus:374" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.1.1": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + }, + "MapLocalVersions": { + "1.1": "1.1.1" + } + }, + { + // Dynamic NPC Sprites + "ID": "BashNinja.DynamicNPCSprites", + "UpdateKeys": [ "Nexus:1183" ] + }, + { + // Easier Farming + "ID": "cautiouswafffle.EasierFarming", + "UpdateKeys": [ "Nexus:1426" ] + }, + { + // Empty Hands + "ID": "QuicksilverFox.EmptyHands", + "UpdateKeys": [ "Nexus:1176" ], // added in 1.0.1-pathoschild-update + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Enemy Health Bars + "ID": "SPDHealthBar | Speeder.HealthBars", // changed in 1.7.1-pathoschild-update + "UpdateKeys": [ "Nexus:193" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.7": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Entoarox Framework + "ID": "eacdb74b-4080-4452-b16b-93773cda5cf9 | Entoarox.EntoaroxFramework", // changed in ??? + //"UpdateKeys": [ "Chucklefish:4228" ], // Entoarox opted out of mod update checks + "Compatibility": { + "~1.7.9": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Expanded Fridge / Dynamic Expanded Fridge + "ID": "Uwazouri.ExpandedFridge", + "UpdateKeys": [ "Nexus:1191" ] + }, + { + // Experience Bars + "ID": "ExperienceBars | spacechase0.ExperienceBars", // changed in 1.0.2 + "UpdateKeys": [ "Nexus:509" ] + }, + { + // Extended Bus System + "ID": "ExtendedBusSystem", + "UpdateKeys": [ "Chucklefish:4373" ] + }, + { + // Extended Fridge + "ID": "Mystra007ExtendedFridge | Crystalmir.ExtendedFridge", // changed in 1.0.1 + "UpdateKeys": [ "Nexus:485" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Extended Greenhouse + "ID": "ExtendedGreenhouse", + "UpdateKeys": [ "Chucklefish:4303" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0.2": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Extended Minecart + "ID": "{ID:'EntoaroxFurnitureAnywhere', Name:'Extended Minecart'} | Entoarox.ExtendedMinecart" // changed in 1.6.1 + //"UpdateKeys": [ "Chucklefish:4359" ] // Entoarox opted out of mod update checks + }, + { + // Extended Reach + "ID": "spacechase0.ExtendedReach", + "UpdateKeys": [ "Nexus:1493" ] + }, + { + // Fall 28 Snow Day + "ID": "{ID:'7ad4f6f7-c3de-4729-a40f-7a11d2b2a358', Name:'Fall28 Snow Day'} | Omegasis.Fall28SnowDay", // changed in 1.4; disambiguate from other mods by Alpha_Omegasis + "UpdateKeys": [ "Nexus:486" ], + "Compatibility": { + "~1.3": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Farm Automation: Barn Door Automation + "ID": "FarmAutomation.BarnDoorAutomation.dll", + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Farm Automation: Item Collector + "ID": "FarmAutomation.ItemCollector.dll", + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Farm Automation Unofficial: Item Collector + "ID": "Maddy99.FarmAutomation.ItemCollector", + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~0.5": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Farm Expansion + "ID": "3888bdfd-73f6-4776-8bb7-8ad45aea1915 | AdvizeFarmExpansionMod-2-0 | AdvizeFarmExpansionMod-2-0-5 | Advize.FarmExpansion", // changed in 2.0, 2.0.5, and 3.0 + "UpdateKeys": [ "Nexus:130" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~2.0.5": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Farm Resource Generator + "ID": "FarmResourceGenerator.dll", + "UpdateKeys": [ "Nexus:647" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0.4": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Fast Animations + "ID": "Pathoschild.FastAnimations", + "UpdateKeys": [ "Nexus:1089" ] + }, + { + // Faster Paths + "ID": "{ID:'821ce8f6-e629-41ad-9fde-03b54f68b0b6', Name:'Faster Paths'} | 615f85f8-5c89-44ee-aecc-c328f172e413 | Entoarox.FasterPaths" // changed in 1.2 and 1.3; disambiguate from Shop Expander + // "UpdateKeys": [ "Chucklefish:3641" ] // Entoarox opted out of mod update checks + }, + { + // Faster Run + "ID": "FasterRun.dll | KathrynHazuka.FasterRun", // changed in 1.1.1-pathoschild-update + "UpdateKeys": [ "Nexus:733" ], // added in 1.1.1-pathoschild-update + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.1": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Fishing Adjust + "ID": "shuaiz.FishingAdjustMod", + "UpdateKeys": [ "Nexus:1350" ] + }, + { + // Fishing Tuner Redux + "ID": "HammurabiFishingTunerRedux", + "UpdateKeys": [ "Chucklefish:4578" ] + }, + { + // FlorenceMod + "ID": "FlorenceMod.dll", + "UpdateKeys": [ "Nexus:591" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.1": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + }, + "MapLocalVersions": { + "1.0.1": "1.1" + } + }, + { + // Flower Color Picker + "ID": "spacechase0.FlowerColorPicker", + "UpdateKeys": [ "Nexus:1229" ] + }, + { + // Forage at the Farm + "ID": "ForageAtTheFarm", + "UpdateKeys": [ "Nexus:673" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.5.1": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Furniture Anywhere + "ID": "{ID:'EntoaroxFurnitureAnywhere', Name:'Furniture Anywhere'} | Entoarox.FurnitureAnywhere" // changed in 1.1; disambiguate from Extended Minecart + // "UpdateKeys": [ "Chucklefish:4324" ] // Entoarox opted out of mod update checks + }, + { + // Game Reminder + "ID": "mmanlapat.GameReminder", + "UpdateKeys": [ "Nexus:1153" ] + }, + { + // Gate Opener + "ID": "GateOpener.dll | mralbobo.GateOpener", // changed in 1.1 + "UpdateKeys": [ "GitHub:mralbobo/stardew-gate-opener" ], + "Compatibility": { + "~1.0.1": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // GenericShopExtender + "ID": "GenericShopExtender", + "UpdateKeys": [ "Nexus:814" ], // added in 0.1.3 + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~0.1.2": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Geode Info Menu + "ID": "cat.geodeinfomenu", + "UpdateKeys": [ "Nexus:1448" ] + }, + { + // Get Dressed + "ID": "GetDressed.dll | Advize.GetDressed", // changed in 3.3 + "UpdateKeys": [ "Nexus:331" ], + "Compatibility": { + "~3.3": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Giant Crop Ring + "ID": "cat.giantcropring", + "UpdateKeys": [ "Nexus:1182" ] + }, + { + // Gift Taste Helper + "ID": "8008db57-fa67-4730-978e-34b37ef191d6 | tstaples.GiftTasteHelper", // changed in 2.5 + "UpdateKeys": [ "Nexus:229" ], + "Compatibility": { + "~2.3.1": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Grandfather's Gift + "ID": "ShadowDragon.GrandfathersGift", + "UpdateKeys": [ "Nexus:985" ] + }, + { + // Happy Animals + "ID": "HappyAnimals", + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0.3": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Happy Birthday (Omegasis) + "ID": "{ID:'HappyBirthday', Author:'Alpha_Omegasis'} | Omegasis.HappyBirthday", // changed in 1.4; disambiguate from Oxyligen's fork + "UpdateKeys": [ "Nexus:520" ], + "Compatibility": { + "~1.3": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Happy Birthday (Oxyligen fork) + "ID": "{ID:'HappyBirthday', Author:'Alpha_Omegasis/Oxyligen'}", // disambiguate from Oxyligen's fork + "UpdateKeys": [ "Nexus:1064" ] + }, + { + // Harp of Yoba Redux + "ID": "Platonymous.HarpOfYobaRedux", + "UpdateKeys": [ "Nexus:914" ] // added in 2.0.3 + }, + { + // Harvest Moon Witch Princess + "ID": "Sasara.WitchPrincess", + "UpdateKeys": [ "Nexus:1157" ] + }, + { + // Harvest With Scythe + "ID": "965169fd-e1ed-47d0-9f12-b104535fb4bc", + "UpdateKeys": [ "Nexus:236" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0.6": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Horse Whistle (icepuente) + "ID": "icepuente.HorseWhistle", + "UpdateKeys": [ "Nexus:1131" ] + }, + { + // Hunger (Yyeadude) + "ID": "HungerYyeadude", + "UpdateKeys": [ "Nexus:613" ] + }, + { + // Hunger for Food (Tigerle) + "ID": "HungerForFoodByTigerle", + "UpdateKeys": [ "Nexus:810" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~0.1.2": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Hunger Mod (skn) + "ID": "skn.HungerMod", + "UpdateKeys": [ "Nexus:1127" ], + "MapRemoteVersions": { + "1.2.1": "1.0" // manifest not updated + } + }, + { + // Idle Pause + "ID": "Veleek.IdlePause", + "UpdateKeys": [ "Nexus:1092" ], + "MapRemoteVersions": { + "1.2": "1.1" // manifest not updated + } + }, + { + // Improved Quality of Life + "ID": "Demiacle.ImprovedQualityOfLife", + "UpdateKeys": [ "Nexus:1025" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.1": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Instant Geode + "ID": "InstantGeode", + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.12": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Instant Grow Trees + "ID": "dc50c58b-c7d8-4e60-86cc-e27b5d95ee59 | community.InstantGrowTrees", // changed in 1.2 + "UpdateKeys": [ "Nexus:173" ] + }, + { + // Interaction Helper + "ID": "HammurabiInteractionHelper", + "UpdateKeys": [ "Chucklefish:4640" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0.3": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Item Auto Stacker + "ID": "cat.autostacker", + "UpdateKeys": [ "Nexus:1184" ], + "MapRemoteVersions": { + "1.0.1": "1.0" // manifest not updated + } + }, + { + // Junimo Farm + "ID": "Platonymous.JunimoFarm", + "UpdateKeys": [ "Nexus:984" ], // added in 1.1.3 + "MapRemoteVersions": { + "1.1.2": "1.1.1" // manifest not updated + } + }, + { + // Less Strict Over-Exertion (AntiExhaustion) + "ID": "BALANCEMOD_AntiExhaustion", + "UpdateKeys": [ "Nexus:637" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.1": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + }, + "MapLocalVersions": { + "0.0": "1.1" + } + }, + { + // Level Extender + "ID": "Devin Lematty.Level Extender", + "UpdateKeys": [ "Nexus:1471" ], + "MapRemoteVersions": { + "1.1": "1.0" // manifest not updated + } + }, + { + // Level Up Notifications + "ID": "Level Up Notifications", + "UpdateKeys": [ "Nexus:855" ] + }, + { + // Location and Music Logging + "ID": "Brandy Lover.LMlog", + "UpdateKeys": [ "Nexus:1366" ] + }, + { + // Longevity + "ID": "RTGOAT.Longevity", + "UpdateKeys": [ "Nexus:649" ] + }, + { + // Lookup Anything + "ID": "LookupAnything | Pathoschild.LookupAnything", // changed in 1.10.1 + "UpdateKeys": [ "Nexus:541" ], + "Compatibility": { + "~1.10.1": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Love Bubbles + "ID": "LoveBubbles", + "UpdateKeys": [ "Nexus:1318" ] + }, + { + // Loved Labels + "ID": "LovedLabels.dll", + "UpdateKeys": [ "Nexus:279" ], + "Compatibility": { + "~2.0": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Luck Skill + "ID": "LuckSkill | spacechase0.LuckSkill", // changed in 0.1.4 + "UpdateKeys": [ "Nexus:521" ], + "Compatibility": { + "~0.1.4": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // MailOrderPigs + "ID": "MailOrderPigs.dll | jwdred.MailOrderPigs", // changed in 1.0.2 + "UpdateKeys": [ "Nexus:632" ], + "Compatibility": { + "~1.0.1": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Makeshift Multiplayer + "ID": "StardewValleyMP | spacechase0.StardewValleyMP", // changed in 0.3 + "UpdateKeys": [ "Nexus:501" ], + "Compatibility": { + "~0.3.6": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Map Image Exporter + "ID": "MapImageExporter | spacechase0.MapImageExporter", // changed in 1.0.2 + "UpdateKeys": [ "Nexus:1073" ] + }, + { + // Message Box [API]? (ChatMod) + "ID": "Kithio:ChatMod", + "UpdateKeys": [ "Chucklefish:4296" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Mining at the Farm + "ID": "MiningAtTheFarm", + "UpdateKeys": [ "Nexus:674" ] + }, + { + // Mining With Explosives + "ID": "MiningWithExplosives", + "UpdateKeys": [ "Nexus:770" ] + }, + { + // Modder Serialization Utility + "ID": "SerializerUtils-0-1", + "Compatibility": { + "~": { + "Status": "Obsolete", + "ReasonPhrase": "it's no longer maintained or used." + } + } + }, + { + // More Artifact Spots + "ID": "451", + "UpdateKeys": [ "Nexus:451" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0.1": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // More Map Layers + "ID": "Platonymous.MoreMapLayers", + "UpdateKeys": [ "Nexus:1134" ] // added in 1.1.1 + }, + { + // More Pets + "ID": "821ce8f6-e629-41ad-9fde-03b54f68b0b6MOREPETS | Entoarox.MorePets", // changed in 1.3 + // "UpdateKeys": [ "Chucklefish:4288" ], // Entoarox opted out of mod update checks + "Compatibility": { + "~1.3.2": { "Status": "AssumeBroken" } // overhauled for SMAPI 1.11+ compatibility + } + }, + { + // More Rain + "ID": "{ID:'4108e859-333c-4fec-a1a7-d2e18c1019fe', Name:'More_Rain'} | Omegasis.MoreRain", // changed in 1.5; disambiguate from other mods by Alpha_Omegasis + "UpdateKeys": [ "Nexus:441" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.4": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // More Weapons + "ID": "Joco80.MoreWeapons", + "UpdateKeys": [ "Nexus:1168" ] + }, + { + // Move Faster + "ID": "shuaiz.MoveFasterMod", + "UpdateKeys": [ "Nexus:1351" ] + }, + { + // Multiple Sprites and Portraits On Rotation (File Loading) + "ID": "FileLoading", + "UpdateKeys": [ "Nexus:1094" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.12": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + }, + "MapLocalVersions": { + "1.1": "1.12" + } + }, + { + // Museum Rearranger + "ID": "{ID:'7ad4f6f7-c3de-4729-a40f-7a11d2b2a358', Name:'Museum Rearranger'} | Omegasis.MuseumRearranger", // changed in 1.4; disambiguate from other mods by Alpha_Omegasis + "UpdateKeys": [ "Nexus:428" ], + "Compatibility": { + "~1.3": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // New Machines + "ID": "F70D4FAB-0AB2-4B78-9F1B-AF2CA2236A59", + "UpdateKeys": [ "Chucklefish:3683" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~4.2.1343": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Night Owl + "UpdateKeys": [ "Nexus:433" ], + "ID": "{ID:'SaveAnywhere', Name:'Stardew_NightOwl'} | Omegasis.NightOwl", // changed in 1.4; disambiguate from Save Anywhere + "Compatibility": { + "~1.3": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + }, + "MapLocalVersions": { + "2.1": "1.3" // 1.3 had wrong version in manifest + } + }, + { + // No Kids Ever + "ID": "Hangy.NoKidsEver", + "UpdateKeys": [ "Nexus:1464" ] + }, + { + // No Debug Mode + "ID": "NoDebugMode", + "Compatibility": { + "~": { + "Status": "Obsolete", + "ReasonPhrase": "debug mode was removed in SMAPI 1.0." + } + } + }, + { + // No Fence Decay + "ID": "cat.nofencedecay", + "UpdateKeys": [ "Nexus:1180" ] + }, + { + // No More Pets + "ID": "NoMorePets | Omegasis.NoMorePets", // changed in 1.4 + "UpdateKeys": [ "Nexus:506" ] + }, + { + // NoSoilDecay + "ID": "289dee03-5f38-4d8e-8ffc-e440198e8610", + "UpdateKeys": [ "Nexus:237" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~0.5": { "Status": "AssumeBroken" } // broke in SDV 1.2, and uses Assembly.GetExecutingAssembly().Location + } + }, + { + // No Soil Decay Redux + "ID": "Platonymous.NoSoilDecayRedux", + "UpdateKeys": [ "Nexus:1084" ] // added in 1.1.9 + }, + { + // NPC Map Locations + "ID": "NPCMapLocationsMod", + "UpdateKeys": [ "Nexus:239" ], + "Compatibility": { + "1.42~1.43": { + "Status": "AssumeBroken", + "ReasonPhrase": "this version has an update check error which crashes the game." + } + } + }, + { + // NPC Speak + "ID": "NpcEcho.dll", + "UpdateKeys": [ "Nexus:694" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Object Time Left + "ID": "spacechase0.ObjectTimeLeft", + "UpdateKeys": [ "Nexus:1315" ] + }, + { + // OmniFarm + "ID": "BlueMod_OmniFarm | PhthaloBlue.OmniFarm", // changed in 2.0.2-pathoschild-update + "UpdateKeys": [ "GitHub:lambui/StardewValleyMod_OmniFarm" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~2.0.1": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Out of Season Bonuses / Seasonal Items + "ID": "midoriarmstrong.seasonalitems", + "UpdateKeys": [ "Nexus:1452" ] + }, + { + // Part of the Community + "ID": "SB_PotC", + "UpdateKeys": [ "Nexus:923" ], + "Compatibility": { + "~1.0.8": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // PelicanFiber + "ID": "PelicanFiber.dll | jwdred.PelicanFiber", // changed in 3.0.1 + "UpdateKeys": [ "Nexus:631" ], + "Compatibility": { + "~3.0": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + }, + "MapRemoteVersions": { + "3.0.2": "3.0.1" // didn't change manifest version + } + }, + { + // PelicanTTS + "ID": "Platonymous.PelicanTTS", + "UpdateKeys": [ "Nexus:1079" ], // added in 1.6.1 + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.6": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Persia the Mermaid - Standalone Custom NPC + "ID": "63b9f419-7449-42db-ab2e-440b4d05c073", + "UpdateKeys": [ "Nexus:1419" ] + }, + { + // Persival's BundleMod + "ID": "BundleMod.dll", + "UpdateKeys": [ "Nexus:438" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0": { "Status": "AssumeBroken" } // broke in SDV 1.1 + } + }, + { + // Plant on Grass + "ID": "Demiacle.PlantOnGrass", + "UpdateKeys": [ "Nexus:1026" ] + }, + { + // Point-and-Plant + "ID": "PointAndPlant.dll | jwdred.PointAndPlant", // changed in 1.0.3 + "UpdateKeys": [ "Nexus:572" ], + "Compatibility": { + "~1.0.2": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Pony Weight Loss Program + "ID": "BadNetCode.PonyWeightLossProgram", + "UpdateKeys": [ "Nexus:1232" ] + }, + { + // Portraiture + "ID": "Platonymous.Portraiture", + "UpdateKeys": [ "Nexus:999" ] // added in 1.3.1 + }, + { + // Prairie King Made Easy + "ID": "PrairieKingMadeEasy.dll | Mucchan.PrairieKingMadeEasy", // changed in 1.0.1 + "UpdateKeys": [ "Chucklefish:3594" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Quest Delay + "ID": "BadNetCode.QuestDelay", + "UpdateKeys": [ "Nexus:1239" ] + }, + { + // Rain Randomizer + "ID": "RainRandomizer.dll", + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0.3": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Recatch Legendary Fish + "ID": "b3af8c31-48f0-43cf-8343-3eb08bcfa1f9 | community.RecatchLegendaryFish", // changed in 1.3 + "UpdateKeys": [ "Nexus:172" ] + }, + { + // Regeneration + "ID": "HammurabiRegeneration", + "UpdateKeys": [ "Chucklefish:4584" ] + }, + { + // Relationship Bar UI + "ID": "RelationshipBar", + "UpdateKeys": [ "Nexus:1009" ] + }, + { + // RelationshipsEnhanced + "ID": "relationshipsenhanced", + "UpdateKeys": [ "Chucklefish:4435" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Relationship Status + "ID": "relationshipstatus", + "UpdateKeys": [ "Nexus:751" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0.5": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + }, + "MapRemoteVersions": { + "1.0.5": "1.0.4" // not updated in manifest + } + }, + { + // Rented Tools + "ID": "JarvieK.RentedTools", + "UpdateKeys": [ "Nexus:1307" ] + }, + { + // Replanter + "ID": "Replanter.dll | jwdred.Replanter", // changed in 1.0.5 + "UpdateKeys": [ "Nexus:589" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0.4": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // ReRegeneration + "ID": "lrsk_sdvm_rerg.0925160827", + "UpdateKeys": [ "Chucklefish:4465" ], + "MapLocalVersions": { + "1.1.2-release": "1.1.2" + } + }, + { + // Reseed + "ID": "Roc.Reseed", + "UpdateKeys": [ "Nexus:887" ] + }, + { + // Reusable Wallpapers and Floors (Wallpaper Retain) + "ID": "dae1b553-2e39-43e7-8400-c7c5c836134b", + "UpdateKeys": [ "Nexus:356" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.5": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Ring of Fire + "ID": "Platonymous.RingOfFire", + "UpdateKeys": [ "Nexus:1166" ] // added in 1.0.1 + }, + { + // Rope Bridge + "ID": "RopeBridge", + "UpdateKeys": [ "Nexus:824" ] + }, + { + // Rotate Toolbar + "ID": "Pathoschild.RotateToolbar", + "UpdateKeys": [ "Nexus:1100" ] + }, + { + // Rush Orders + "ID": "RushOrders | spacechase0.RushOrders", // changed in 1.1 + "UpdateKeys": [ "Nexus:605" ], + "Compatibility": { + "~1.1": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Save Anywhere + "ID": "{ID:'SaveAnywhere', Name:'Save Anywhere'} | Omegasis.SaveAnywhere", // changed in 2.5; disambiguate from Night Owl + "UpdateKeys": [ "Nexus:444" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~2.4": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + }, + "MapRemoteVersions": { + "2.6": "2.5" // not updated in manifest + } + }, + { + // Save Backup + "ID": "{ID:'4be88c18-b6f3-49b0-ba96-f94b1a5be890', Name:'Stardew_Save_Backup'} | Omegasis.SaveBackup", // changed in 1.3; disambiguate from other Alpha_Omegasis mods + "UpdateKeys": [ "Nexus:435" ], + "Compatibility": { + "~1.2": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Scroll to Blank + "ID": "caraxian.scroll.to.blank", + "UpdateKeys": [ "Chucklefish:4405" ] + }, + { + // Scythe Harvesting + "ID": "ScytheHarvesting | mmanlapat.ScytheHarvesting", // changed in 1.6 + "UpdateKeys": [ "Nexus:1106" ] + }, + { + // Seasonal Immersion + "ID": "EntoaroxSeasonalHouse | EntoaroxSeasonalBuildings | EntoaroxSeasonalImmersion | Entoarox.SeasonalImmersion", // changed in 1.1, 1.6 or earlier, and 1.7 + // "UpdateKeys": [ "Chucklefish:4262" ], // Entoarox opted out of mod update checks + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.8.2": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Seed Bag + "ID": "Platonymous.SeedBag", + "UpdateKeys": [ "Nexus:1133" ] // added in 1.1.2 + }, + { + // Self Service + "ID": "JarvieK.SelfService", + "UpdateKeys": [ "Nexus:1304" ], + "MapRemoteVersions": { + "0.2.1": "0.2" // manifest not updated + } + }, + { + // Send Items + "ID": "Denifia.SendItems", + "UpdateKeys": [ "Nexus:1087" ], // added in 1.0.3 (2017-10-04) + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0.2": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Shed Notifications (BuildingsNotifications) + "ID": "TheCroak.BuildingsNotifications", + "UpdateKeys": [ "Nexus:620" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~0.4.1": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Shenandoah Project + "ID": "Shenandoah Project", + "UpdateKeys": [ "Nexus:756" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.1.1": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + }, + "MapRemoteVersions": { + "1.1.1": "1.1" // not updated in manifest + } + }, + { + // Ship Anywhere + "ID": "spacechase0.ShipAnywhere", + "UpdateKeys": [ "Nexus:1379" ] + }, + { + // Shipment Tracker + "ID": "7e474181-e1a0-40f9-9c11-d08a3dcefaf3", + "UpdateKeys": [ "Nexus:321" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.1": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Shop Expander + "ID": "{ID:'821ce8f6-e629-41ad-9fde-03b54f68b0b6', Name:'Shop Expander'} | EntoaroxShopExpander | Entoarox.ShopExpander", // changed in 1.5 and 1.5.2; disambiguate from Faster Paths + // "UpdateKeys": [ "Chucklefish:4381" ], // Entoarox opted out of mod update checks + "Compatibility": { + "~1.5.3": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Showcase Mod + "ID": "Igorious.Showcase", + "UpdateKeys": [ "Chucklefish:4487" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~0.9": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + }, + "MapLocalVersions": { + "0.9-500": "0.9" + } + }, + { + // Shroom Spotter + "ID": "TehPers.ShroomSpotter", + "UpdateKeys": [ "Nexus:908" ] + }, + { + // Simple Crop Label + "ID": "SimpleCropLabel", + "UpdateKeys": [ "Nexus:314" ] + }, + { + // Simple Sound Manager + "ID": "Omegasis.SimpleSoundManager", + "UpdateKeys": [ "Nexus:1410" ] + }, + { + // Simple Sprinklers + "ID": "SimpleSprinkler.dll | tZed.SimpleSprinkler", // changed in 1.5 + "UpdateKeys": [ "Nexus:76" ], + "Compatibility": { + "~1.4": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Siv's Marriage Mod + "ID": "6266959802", + "UpdateKeys": [ "Nexus:366" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.2.2": { "Status": "AssumeBroken" } // broke in SMAPI 1.9 (has multiple Mod instances) + }, + "MapLocalVersions": { + "0.0": "1.4" + } + }, + { + // Skill Prestige + "ID": "6b843e60-c8fc-4a25-a67b-4a38ac8dcf9b | alphablackwolf.skillPrestige", // changed circa 1.2.3 + "UpdateKeys": [ "Nexus:569" ], + "Compatibility": { + "~1.0.9": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Skill Prestige: Cooking Adapter + "ID": "20d6b8a3-b6e7-460b-a6e4-07c2b0cb6c63 | Alphablackwolf.CookingSkillPrestigeAdapter", // changed circa 1.1 + "UpdateKeys": [ "Nexus:569" ], + "Compatibility": { + "~1.0.9": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + }, + "MapRemoteVersions": { + "1.2.3": "1.1" // manifest not updated + } + }, + { + // Skip Intro + "ID": "SkipIntro | Pathoschild.SkipIntro", // changed in 1.4 + "UpdateKeys": [ "Nexus:533" ] + }, + { + // Skull Cavern Elevator + "ID": "SkullCavernElevator", + "UpdateKeys": [ "Nexus:963" ] + }, + { + // Skull Cave Saver + "ID": "8ac06349-26f7-4394-806c-95d48fd35774 | community.SkullCaveSaver", // changed in 1.1 + "UpdateKeys": [ "Nexus:175" ] + }, + { + // Sleepy Eye + "ID": "spacechase0.SleepyEye", + "UpdateKeys": [ "Nexus:1152" ] + }, + { + // Slower Fence Decay + "ID": "SPDSlowFenceDecay | Speeder.SlowerFenceDecay", // changed in 0.5.2-pathoschild-update + "UpdateKeys": [ "Nexus:252" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~0.5.1": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Smart Mod + "ID": "KuroBear.SmartMod", + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~2.2": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Solar Eclipse Event + "ID": "KoihimeNakamura.SolarEclipseEvent", + "UpdateKeys": [ "Nexus:897" ], + "Compatibility": { + "~1.1": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + }, + "MapLocalVersions": { + "1.3-20170917": "1.3" + } + }, + { + // SpaceCore + "ID": "spacechase0.SpaceCore", + "UpdateKeys": [ "Nexus:1348" ] + }, + { + // Speedster + "ID": "Platonymous.Speedster", + "UpdateKeys": [ "Nexus:1102" ] // added in 1.3.1 + }, + { + // Sprinkler Range + "ID": "cat.sprinklerrange", + "UpdateKeys": [ "Nexus:1179" ], + "MapRemoteVersions": { + "1.0.1": "1.0" // manifest not updated + } + }, + { + // Sprinkles + "ID": "Platonymous.Sprinkles", + "UpdateKeys": [ "Chucklefish:4592" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.1.3": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Sprint and Dash + "ID": "SPDSprintAndDash", + "UpdateKeys": [ "Chucklefish:3531" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Sprint and Dash Redux + "ID": "lrsk_sdvm_sndr.0921161059 | littleraskol.SprintAndDashRedux", // changed in 1.3 + "UpdateKeys": [ "Chucklefish:4201" ] + }, + { + // Sprinting Mod + "ID": "a10d3097-b073-4185-98ba-76b586cba00c", + "UpdateKeys": [ "GitHub:oliverpl/SprintingMod" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~2.1": { "Status": "AssumeBroken" } // broke in SDV 1.2 + }, + "MapLocalVersions": { + "1.0": "2.1" // not updated in manifest + } + }, + { + // StackSplitX + "ID": "StackSplitX.dll | tstaples.StackSplitX", // changed circa 1.3.1 + "UpdateKeys": [ "Nexus:798" ], + "Compatibility": { + "~1.2": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // StaminaRegen + "ID": "StaminaRegen.dll", + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0.3": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Stardew Config Menu + "ID": "Juice805.StardewConfigMenu", + "UpdateKeys": [ "Nexus:1312" ] + }, + { + // Stardew Content Compatibility Layer (SCCL) + "ID": "SCCL", + "UpdateKeys": [ "Nexus:889" ], + "Compatibility": { + "~0.1": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Stardew Editor Game Integration + "ID": "spacechase0.StardewEditor.GameIntegration", + "UpdateKeys": [ "Nexus:1298" ] + }, + { + // Stardew Notification + "ID": "stardewnotification", + "UpdateKeys": [ "GitHub:monopandora/StardewNotification" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.7": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Stardew Symphony + "ID": "{ID:'4108e859-333c-4fec-a1a7-d2e18c1019fe', Name:'Stardew_Symphony'} | Omegasis.StardewSymphony", // changed in 1.4; disambiguate other mods by Alpha_Omegasis + "UpdateKeys": [ "Nexus:425" ], + "Compatibility": { + "~1.3": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // StarDustCore + "ID": "StarDustCore", + "Compatibility": { + "~": { + "Status": "Obsolete", + "ReasonPhrase": "it was only used by earlier versions of Save Anywhere, and is no longer used or maintained." + } + } + }, + { + // Starting Money + "ID": "StartingMoney | mmanlapat.StartingMoney", // changed in 1.1 + "UpdateKeys": [ "Nexus:1138" ] + }, + { + // StashItemsToChest + "ID": "BlueMod_StashItemsToChest", + "UpdateKeys": [ "GitHub:lambui/StardewValleyMod_StashItemsToChest" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0.1": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Stephan's Lots of Crops + "ID": "stephansstardewcrops", + "UpdateKeys": [ "Chucklefish:4314" ], + "MapRemoteVersions": { + "1.41": "1.1" // manifest not updated + } + }, + { + // Stone Bridge Over Pond (PondWithBridge) + "ID": "PondWithBridge.dll", + "UpdateKeys": [ "Nexus:316" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + }, + "MapLocalVersions": { + "0.0": "1.0" + } + }, + { + // Stumps to Hardwood Stumps + "ID": "StumpsToHardwoodStumps", + "UpdateKeys": [ "Nexus:691" ] + }, + { + // Super Greenhouse Warp Modifier + "ID": "SuperGreenhouse", + "UpdateKeys": [ "Chucklefish:4334" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Swim Almost Anywhere / Swim Suit + "ID": "Platonymous.SwimSuit", + "UpdateKeys": [ "Nexus:1215" ] // added in 0.5.1 + }, + { + // Tainted Cellar + "ID": "TaintedCellar.dll", + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0": { "Status": "AssumeBroken" } // broke in SDV 1.1 or 1.11 + } + }, + { + // Tapper Ready + "ID": "skunkkk.TapperReady", + "UpdateKeys": [ "Nexus:1219" ] + }, + { + // Teh's Fishing Overhaul + "ID": "TehPers.FishingOverhaul", + "UpdateKeys": [ "Nexus:866" ] + }, + { + // Teleporter + "ID": "Teleporter", + "UpdateKeys": [ "Chucklefish:4374" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0.2": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // The Long Night + "ID": "Pathoschild.TheLongNight", + "UpdateKeys": [ "Nexus:1369" ] + }, + { + // Three-heart Dance Partner + "ID": "ThreeHeartDancePartner", + "UpdateKeys": [ "Nexus:500" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0.1": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // TimeFreeze + "ID": "4108e859-333c-4fec-a1a7-d2e18c1019fe", + "UpdateKeys": [ "Nexus:973" ] + }, + { + // Time Reminder + "ID": "KoihimeNakamura.TimeReminder", + "UpdateKeys": [ "Nexus:1000" ], + "MapLocalVersions": { + "1.0-20170314": "1.0.2" + } + }, + { + // TimeSpeed + "ID": "TimeSpeed.dll | {ID:'4108e859-333c-4fec-a1a7-d2e18c1019fe', Name:'TimeSpeed'} | {ID:'4108e859-333c-4fec-a1a7-d2e18c1019fe', Name:'TimeSpeed Mod (unofficial)'} | community.TimeSpeed", // changed in 2.0.3 and 2.1; disambiguate other mods by Alpha_Omegasis + "UpdateKeys": [ "Nexus:169" ], + "Compatibility": { + "~2.2": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // TractorMod + "ID": "BlueMod_TractorMod | PhthaloBlue.TractorMod | community.TractorMod | Pathoschild.TractorMod", // changed in 3.2, 4.0 beta, and 4.0 + "UpdateKeys": [ "Nexus:1401" ] + }, + { + // Tree Transplant + "ID": "TreeTransplant", + "UpdateKeys": [ "Nexus:1342" ] + }, + { + // UI Info Suite + "ID": "Cdaragorn.UiInfoSuite", + "UpdateKeys": [ "Nexus:1150" ] + }, + { + // UiModSuite + "ID": "Demiacle.UiModSuite", + "UpdateKeys": [ "Nexus:1023" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0": { "Status": "AssumeBroken" } // broke in SDV 1.2 + }, + "MapLocalVersions": { + "0.5": "1.0" // not updated in manifest + } + }, + { + // Variable Grass + "ID": "dantheman999.VariableGrass", + "UpdateKeys": [ "GitHub:dantheman999301/StardewMods" ] + }, + { + // Vertical Toolbar + "ID": "SB_VerticalToolMenu", + "UpdateKeys": [ "Nexus:943" ] + }, + { + // WakeUp + "ID": "WakeUp.dll", + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0.2": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // Wallpaper Fix + "ID": "WallpaperFix.dll", + "UpdateKeys": [ "Chucklefish:4211" ], + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.1": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // WarpAnimals + "ID": "Symen.WarpAnimals", + "UpdateKeys": [ "Nexus:1400" ] + }, + { + // Weather Controller + "ID": "WeatherController.dll", + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0.2": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // What Farm Cave / WhatAMush + "ID": "WhatAMush", + "UpdateKeys": [ "Nexus:1097" ] + }, + { + // WHats Up + "ID": "wHatsUp", + "UpdateKeys": [ "Nexus:1082" ] + }, + { + // Wonderful Farm Life + "ID": "WonderfulFarmLife.dll", + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0": { "Status": "AssumeBroken" } // broke in SDV 1.1 or 1.11 + } + }, + { + // XmlSerializerRetool + "ID": "XmlSerializerRetool.dll", + "Compatibility": { + "~": { + "Status": "Obsolete", + "ReasonPhrase": "it's no longer maintained or used." + } + } + }, + { + // Xnb Loader + "ID": "Entoarox.XnbLoader", + // "UpdateKeys": [ "Chucklefish:4506" ], // Entoarox opted out of mod update checks + "AlternativeUrl": "http://stardewvalleywiki.com/Modding:SMAPI_2.0", + "Compatibility": { + "~1.0.6": { "Status": "AssumeBroken" } // broke in SMAPI 2.0 + } + }, + { + // zDailyIncrease + "ID": "zdailyincrease", + "UpdateKeys": [ "Chucklefish:4247" ], + "Compatibility": { + "~1.2": { "Status": "AssumeBroken" } // broke in SDV 1.2 + }, + "MapRemoteVersions": { + "1.3.5": "1.3.4" // not updated in manifest + } + }, + { + // Zoom Out Extreme + "ID": "ZoomMod | RockinMods.ZoomMod", // changed circa 1.2.1 + "UpdateKeys": [ "Nexus:1326" ], + "Compatibility": { + "~0.1": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Zoryn's Better RNG + "ID": "76b6d1e1-f7ba-4d72-8c32-5a1e6d2716f6 | Zoryn.BetterRNG", // changed in 1.6 + "UpdateKeys": [ "GitHub:Zoryn4163/SMAPI-Mods" ], + "Compatibility": { + "~1.6": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Zoryn's Calendar Anywhere + "ID": "a41c01cd-0437-43eb-944f-78cb5a53002a | Zoryn.CalendarAnywhere", // changed in 1.6 + "UpdateKeys": [ "GitHub:Zoryn4163/SMAPI-Mods" ], + "Compatibility": { + "~1.6": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Zoryn's Durable Fences + "ID": "56d3439c-7b9b-497e-9496-0c4890e8a00e | Zoryn.DurableFences", // changed in 1.6 + "UpdateKeys": [ "GitHub:Zoryn4163/SMAPI-Mods" ] + }, + { + // Zoryn's Health Bars + "ID": "HealthBars.dll | Zoryn.HealthBars", // changed in 1.6 + "UpdateKeys": [ "GitHub:Zoryn4163/SMAPI-Mods" ], + "Compatibility": { + "~1.6": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Zoryn's Fishing Mod + "ID": "fa277b1f-265e-47c3-a84f-cd320cc74949 | Zoryn.FishingMod", // changed in 1.6 + "UpdateKeys": [ "GitHub:Zoryn4163/SMAPI-Mods" ] + }, + { + // Zoryn's Junimo Deposit Anywhere + "ID": "f93a4fe8-cade-4146-9335-b5f82fbbf7bc | Zoryn.JunimoDepositAnywhere", // changed in 1.6 + "UpdateKeys": [ "GitHub:Zoryn4163/SMAPI-Mods" ], + "Compatibility": { + "~1.7": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Zoryn's Movement Mod + "ID": "8a632929-8335-484f-87dd-c29d2ba3215d | Zoryn.MovementModifier", // changed in 1.6 + "UpdateKeys": [ "GitHub:Zoryn4163/SMAPI-Mods" ], + "Compatibility": { + "~1.6": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + }, + { + // Zoryn's Regen Mod + "ID": "dfac4383-1b6b-4f33-ae4e-37fc23e5252e | Zoryn.RegenMod", // changed in 1.6 + "UpdateKeys": [ "GitHub:Zoryn4163/SMAPI-Mods" ], + "Compatibility": { + "~1.6": { "Status": "AssumeBroken" } // broke in SDV 1.2 + } + } + ] +} diff --git a/src/StardewModdingAPI/StardewModdingAPI.csproj b/src/StardewModdingAPI/StardewModdingAPI.csproj new file mode 100644 index 00000000..3721a11b --- /dev/null +++ b/src/StardewModdingAPI/StardewModdingAPI.csproj @@ -0,0 +1,277 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="12.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> + <PropertyGroup> + <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> + <Platform Condition=" '$(Platform)' == '' ">x86</Platform> + <ProjectGuid>{F1A573B0-F436-472C-AE29-0B91EA6B9F8F}</ProjectGuid> + <OutputType>Exe</OutputType> + <AppDesignerFolder>Properties</AppDesignerFolder> + <RootNamespace>StardewModdingAPI</RootNamespace> + <AssemblyName>StardewModdingAPI</AssemblyName> + <TargetFrameworkVersion>v4.5</TargetFrameworkVersion> + <FileAlignment>512</FileAlignment> + <IsWebBootstrapper>false</IsWebBootstrapper> + <TargetFrameworkProfile /> + <PublishUrl>publish\</PublishUrl> + <Install>true</Install> + <InstallFrom>Disk</InstallFrom> + <UpdateEnabled>false</UpdateEnabled> + <UpdateMode>Foreground</UpdateMode> + <UpdateInterval>7</UpdateInterval> + <UpdateIntervalUnits>Days</UpdateIntervalUnits> + <UpdatePeriodically>false</UpdatePeriodically> + <UpdateRequired>false</UpdateRequired> + <MapFileExtensions>true</MapFileExtensions> + <ApplicationRevision>0</ApplicationRevision> + <ApplicationVersion>1.0.0.%2a</ApplicationVersion> + <UseApplicationTrust>false</UseApplicationTrust> + <BootstrapperEnabled>true</BootstrapperEnabled> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x86'"> + <PlatformTarget>x86</PlatformTarget> + <Prefer32Bit>false</Prefer32Bit> + <DefineConstants>DEBUG;TRACE</DefineConstants> + <UseVSHostingProcess>true</UseVSHostingProcess> + <Optimize>false</Optimize> + <OutputPath>$(SolutionDir)\..\bin\Debug\SMAPI</OutputPath> + <DocumentationFile>$(SolutionDir)\..\bin\Debug\SMAPI\StardewModdingAPI.xml</DocumentationFile> + <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x86'"> + <PlatformTarget>x86</PlatformTarget> + <Prefer32Bit>false</Prefer32Bit> + <OutputPath>$(SolutionDir)\..\bin\Release\SMAPI</OutputPath> + <DocumentationFile>$(SolutionDir)\..\bin\Debug\SMAPI\StardewModdingAPI.xml</DocumentationFile> + <DefineConstants>TRACE</DefineConstants> + <Optimize>true</Optimize> + <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> + <DebugType>pdbonly</DebugType> + <DebugSymbols>true</DebugSymbols> + </PropertyGroup> + <PropertyGroup> + <ApplicationIcon>icon.ico</ApplicationIcon> + </PropertyGroup> + <ItemGroup> + <Reference Include="Mono.Cecil, Version=0.9.6.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL"> + <HintPath>..\packages\Mono.Cecil.0.9.6.4\lib\net45\Mono.Cecil.dll</HintPath> + <Private>True</Private> + </Reference> + <Reference Include="Mono.Cecil.Mdb, Version=0.9.6.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL"> + <HintPath>..\packages\Mono.Cecil.0.9.6.4\lib\net45\Mono.Cecil.Mdb.dll</HintPath> + <Private>True</Private> + </Reference> + <Reference Include="Mono.Cecil.Pdb, Version=0.9.6.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL"> + <HintPath>..\packages\Mono.Cecil.0.9.6.4\lib\net45\Mono.Cecil.Pdb.dll</HintPath> + <Private>True</Private> + </Reference> + <Reference Include="Newtonsoft.Json, Version=8.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL"> + <HintPath>..\packages\Newtonsoft.Json.8.0.3\lib\net45\Newtonsoft.Json.dll</HintPath> + <Private>True</Private> + </Reference> + <Reference Include="System" /> + <Reference Include="System.Core" /> + <Reference Include="System.Drawing" /> + <Reference Include="System.Management" Condition="$(OS) == 'Windows_NT'" /> + <Reference Include="System.Numerics"> + <Private>True</Private> + </Reference> + <Reference Include="System.Runtime.Caching"> + <Private>True</Private> + </Reference> + <Reference Include="System.Windows.Forms" Condition="$(OS) == 'Windows_NT'" /> + <Reference Include="System.Xml.Linq" /> + <Reference Include="System.Data.DataSetExtensions" /> + <Reference Include="Microsoft.CSharp" /> + <Reference Include="System.Data" /> + <Reference Include="System.Xml" /> + </ItemGroup> + <ItemGroup> + <Compile Include="..\GlobalAssemblyInfo.cs"> + <Link>Properties\GlobalAssemblyInfo.cs</Link> + </Compile> + <Compile Include="Framework\Models\ModCompatibility.cs" /> + <Compile Include="Framework\ModLoading\Finders\EventFinder.cs" /> + <Compile Include="Framework\ModLoading\Finders\FieldFinder.cs" /> + <Compile Include="Framework\ModLoading\Finders\MethodFinder.cs" /> + <Compile Include="Framework\ModLoading\Finders\PropertyFinder.cs" /> + <Compile Include="Framework\ModLoading\Finders\TypeFinder.cs" /> + <Compile Include="Framework\ModLoading\IInstructionHandler.cs" /> + <Compile Include="Framework\ModLoading\IncompatibleInstructionException.cs" /> + <Compile Include="Framework\ModLoading\InstructionHandleResult.cs" /> + <Compile Include="Framework\ModLoading\Platform.cs" /> + <Compile Include="Framework\ModLoading\PlatformAssemblyMap.cs" /> + <Compile Include="Framework\ModLoading\RewriteHelper.cs" /> + <Compile Include="Framework\ModLoading\Rewriters\FieldReplaceRewriter.cs" /> + <Compile Include="Framework\ModLoading\Rewriters\FieldToPropertyRewriter.cs" /> + <Compile Include="Framework\ModLoading\Rewriters\VirtualEntryCallRemover.cs" /> + <Compile Include="Framework\ModLoading\Rewriters\MethodParentRewriter.cs" /> + <Compile Include="Framework\ModLoading\Rewriters\TypeReferenceRewriter.cs" /> + <Compile Include="Framework\ContentManagerShim.cs" /> + <Compile Include="Framework\Exceptions\SAssemblyLoadFailedException.cs" /> + <Compile Include="Framework\ModLoading\AssemblyLoadStatus.cs" /> + <Compile Include="Framework\Utilities\ContextHash.cs" /> + <Compile Include="Metadata\CoreAssets.cs" /> + <Compile Include="ContentSource.cs" /> + <Compile Include="Events\ContentEvents.cs" /> + <Compile Include="Events\EventArgsInput.cs" /> + <Compile Include="Events\EventArgsValueChanged.cs" /> + <Compile Include="Events\InputEvents.cs" /> + <Compile Include="Framework\Content\AssetInfo.cs" /> + <Compile Include="Framework\Exceptions\SContentLoadException.cs" /> + <Compile Include="Framework\Command.cs" /> + <Compile Include="Constants.cs" /> + <Compile Include="Events\ControlEvents.cs" /> + <Compile Include="Events\EventArgsClickableMenuChanged.cs" /> + <Compile Include="Events\EventArgsClickableMenuClosed.cs" /> + <Compile Include="Events\EventArgsControllerButtonPressed.cs" /> + <Compile Include="Events\EventArgsControllerButtonReleased.cs" /> + <Compile Include="Events\EventArgsControllerTriggerPressed.cs" /> + <Compile Include="Events\EventArgsControllerTriggerReleased.cs" /> + <Compile Include="Events\EventArgsCurrentLocationChanged.cs" /> + <Compile Include="Events\EventArgsGameLocationsChanged.cs" /> + <Compile Include="Events\EventArgsIntChanged.cs" /> + <Compile Include="Events\EventArgsInventoryChanged.cs" /> + <Compile Include="Events\EventArgsKeyboardStateChanged.cs" /> + <Compile Include="Events\EventArgsKeyPressed.cs" /> + <Compile Include="Events\EventArgsLevelUp.cs" /> + <Compile Include="Events\EventArgsLocationObjectsChanged.cs" /> + <Compile Include="Events\EventArgsMineLevelChanged.cs" /> + <Compile Include="Events\EventArgsMouseStateChanged.cs" /> + <Compile Include="Events\GameEvents.cs" /> + <Compile Include="Events\GraphicsEvents.cs" /> + <Compile Include="Framework\Utilities\Countdown.cs" /> + <Compile Include="Framework\GameVersion.cs" /> + <Compile Include="Framework\IModMetadata.cs" /> + <Compile Include="Framework\Models\ModDataID.cs" /> + <Compile Include="Framework\ModHelpers\BaseHelper.cs" /> + <Compile Include="Framework\ModHelpers\CommandHelper.cs" /> + <Compile Include="Framework\ModHelpers\ContentHelper.cs" /> + <Compile Include="Framework\ModHelpers\ModHelper.cs" /> + <Compile Include="Framework\ModHelpers\ModRegistryHelper.cs" /> + <Compile Include="Framework\ModHelpers\ReflectionHelper.cs" /> + <Compile Include="Framework\ModHelpers\TranslationHelper.cs" /> + <Compile Include="Framework\ModLoading\InvalidModStateException.cs" /> + <Compile Include="Framework\ModLoading\ModDependencyStatus.cs" /> + <Compile Include="Framework\ModLoading\ModMetadataStatus.cs" /> + <Compile Include="Framework\ModLoading\ModResolver.cs" /> + <Compile Include="Framework\ModLoading\AssemblyDefinitionResolver.cs" /> + <Compile Include="Framework\ModLoading\AssemblyParseResult.cs" /> + <Compile Include="Framework\CommandManager.cs" /> + <Compile Include="Framework\Content\AssetData.cs" /> + <Compile Include="Framework\Content\AssetDataForObject.cs" /> + <Compile Include="Framework\Content\AssetDataForDictionary.cs" /> + <Compile Include="Framework\Content\AssetDataForImage.cs" /> + <Compile Include="Context.cs" /> + <Compile Include="Framework\Logging\ConsoleInterceptionManager.cs" /> + <Compile Include="Framework\Logging\InterceptingTextWriter.cs" /> + <Compile Include="Framework\Models\ManifestDependency.cs" /> + <Compile Include="Framework\Models\ModStatus.cs" /> + <Compile Include="Framework\Models\SConfig.cs" /> + <Compile Include="Framework\ModLoading\ModMetadata.cs" /> + <Compile Include="Framework\Reflection\PrivateProperty.cs" /> + <Compile Include="Framework\RequestExitDelegate.cs" /> + <Compile Include="Framework\SContentManager.cs" /> + <Compile Include="Framework\Exceptions\SParseException.cs" /> + <Compile Include="Framework\Serialisation\JsonHelper.cs" /> + <Compile Include="Framework\Serialisation\SelectiveStringEnumConverter.cs" /> + <Compile Include="Framework\Serialisation\SFieldConverter.cs" /> + <Compile Include="IAssetEditor.cs" /> + <Compile Include="IAssetInfo.cs" /> + <Compile Include="IAssetLoader.cs" /> + <Compile Include="ICommandHelper.cs" /> + <Compile Include="IAssetData.cs" /> + <Compile Include="IAssetDataForDictionary.cs" /> + <Compile Include="IAssetDataForImage.cs" /> + <Compile Include="IContentHelper.cs" /> + <Compile Include="IManifestDependency.cs" /> + <Compile Include="IModRegistry.cs" /> + <Compile Include="Events\LocationEvents.cs" /> + <Compile Include="Events\MenuEvents.cs" /> + <Compile Include="Events\MineEvents.cs" /> + <Compile Include="Events\PlayerEvents.cs" /> + <Compile Include="Events\SaveEvents.cs" /> + <Compile Include="Events\TimeEvents.cs" /> + <Compile Include="Framework\DeprecationLevel.cs" /> + <Compile Include="Framework\DeprecationManager.cs" /> + <Compile Include="Framework\InternalExtensions.cs" /> + <Compile Include="Framework\Models\ModDataRecord.cs" /> + <Compile Include="Framework\ModLoading\AssemblyLoader.cs" /> + <Compile Include="Framework\Reflection\CacheEntry.cs" /> + <Compile Include="Framework\Reflection\PrivateField.cs" /> + <Compile Include="Framework\Reflection\PrivateMethod.cs" /> + <Compile Include="Framework\Reflection\Reflector.cs" /> + <Compile Include="IManifest.cs" /> + <Compile Include="IMod.cs" /> + <Compile Include="IModHelper.cs" /> + <Compile Include="IModLinked.cs" /> + <Compile Include="Framework\Logging\LogFileManager.cs" /> + <Compile Include="IPrivateProperty.cs" /> + <Compile Include="ISemanticVersion.cs" /> + <Compile Include="ITranslationHelper.cs" /> + <Compile Include="LogLevel.cs" /> + <Compile Include="Framework\ModRegistry.cs" /> + <Compile Include="Framework\WebApiClient.cs" /> + <Compile Include="IMonitor.cs" /> + <Compile Include="Events\ChangeType.cs" /> + <Compile Include="Events\ItemStackChange.cs" /> + <Compile Include="Framework\Monitor.cs" /> + <Compile Include="Framework\Models\Manifest.cs" /> + <Compile Include="Metadata\InstructionMetadata.cs" /> + <Compile Include="Mod.cs" /> + <Compile Include="PatchMode.cs" /> + <Compile Include="Program.cs" /> + <Compile Include="Properties\AssemblyInfo.cs" /> + <Compile Include="Framework\SGame.cs" /> + <Compile Include="IPrivateField.cs" /> + <Compile Include="IPrivateMethod.cs" /> + <Compile Include="IReflectionHelper.cs" /> + <Compile Include="SemanticVersion.cs" /> + <Compile Include="Translation.cs" /> + <Compile Include="ICursorPosition.cs" /> + <Compile Include="Utilities\SDate.cs" /> + <Compile Include="Utilities\SButton.cs" /> + <Compile Include="Framework\CursorPosition.cs" /> + </ItemGroup> + <ItemGroup> + <None Include="App.config"> + <SubType>Designer</SubType> + </None> + <None Include="packages.config"> + <SubType>Designer</SubType> + </None> + <Content Include="StardewModdingAPI.config.json"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </Content> + <None Include="unix-launcher.sh"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </None> + </ItemGroup> + <ItemGroup> + <Content Include="icon.ico" /> + <Content Include="steam_appid.txt"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </Content> + </ItemGroup> + <ItemGroup> + <BootstrapperPackage Include=".NETFramework,Version=v4.5"> + <Visible>False</Visible> + <ProductName>Microsoft .NET Framework 4.5 %28x86 and x64%29</ProductName> + <Install>true</Install> + </BootstrapperPackage> + <BootstrapperPackage Include="Microsoft.Net.Framework.3.5.SP1"> + <Visible>False</Visible> + <ProductName>.NET Framework 3.5 SP1</ProductName> + <Install>false</Install> + </BootstrapperPackage> + </ItemGroup> + <Import Project="..\StardewModdingAPI.Models\StardewModdingAPI.Models.projitems" Label="Shared" /> + <ItemGroup> + <ProjectReference Include="..\StardewModdingAPI.AssemblyRewriters\StardewModdingAPI.AssemblyRewriters.csproj"> + <Project>{10db0676-9fc1-4771-a2c8-e2519f091e49}</Project> + <Name>StardewModdingAPI.AssemblyRewriters</Name> + </ProjectReference> + </ItemGroup> + <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> + <Import Project="$(SolutionDir)\common.targets" /> +</Project>
\ No newline at end of file diff --git a/src/StardewModdingAPI/Translation.cs b/src/StardewModdingAPI/Translation.cs new file mode 100644 index 00000000..ce344f81 --- /dev/null +++ b/src/StardewModdingAPI/Translation.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; +using System.Text.RegularExpressions; + +namespace StardewModdingAPI +{ + /// <summary>A translation string with a fluent API to customise it.</summary> + public class Translation + { + /********* + ** Properties + *********/ + /// <summary>The placeholder text when the translation is <c>null</c> or empty, where <c>{0}</c> is the translation key.</summary> + internal const string PlaceholderText = "(no translation:{0})"; + + /// <summary>The name of the relevant mod for error messages.</summary> + private readonly string ModName; + + /// <summary>The locale for which the translation was fetched.</summary> + private readonly string Locale; + + /// <summary>The underlying translation text.</summary> + private readonly string Text; + + /// <summary>The value to return if the translations is undefined.</summary> + private readonly string Placeholder; + + + /********* + ** Accessors + *********/ + /// <summary>The original translation key.</summary> + public string Key { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an isntance.</summary> + /// <param name="modName">The name of the relevant mod for error messages.</param> + /// <param name="locale">The locale for which the translation was fetched.</param> + /// <param name="key">The translation key.</param> + /// <param name="text">The underlying translation text.</param> + internal Translation(string modName, string locale, string key, string text) + : this(modName, locale, key, text, string.Format(Translation.PlaceholderText, key)) { } + + /// <summary>Construct an isntance.</summary> + /// <param name="modName">The name of the relevant mod for error messages.</param> + /// <param name="locale">The locale for which the translation was fetched.</param> + /// <param name="key">The translation key.</param> + /// <param name="text">The underlying translation text.</param> + /// <param name="placeholder">The value to return if the translations is undefined.</param> + internal Translation(string modName, string locale, string key, string text, string placeholder) + { + this.ModName = modName; + this.Locale = locale; + this.Key = key; + this.Text = text; + this.Placeholder = placeholder; + } + + /// <summary>Throw an exception if the translation text is <c>null</c> or empty.</summary> + /// <exception cref="KeyNotFoundException">There's no available translation matching the requested key and locale.</exception> + public Translation Assert() + { + if (!this.HasValue()) + throw new KeyNotFoundException($"The '{this.ModName}' mod doesn't have a translation with key '{this.Key}' for the '{this.Locale}' locale or its fallbacks."); + return this; + } + + /// <summary>Replace the text if it's <c>null</c> or empty. If you set a <c>null</c> or empty value, the translation will show the fallback "no translation" placeholder (see <see cref="UsePlaceholder"/> if you want to disable that). Returns a new instance if changed.</summary> + /// <param name="default">The default value.</param> + public Translation Default(string @default) + { + return this.HasValue() + ? this + : new Translation(this.ModName, this.Locale, this.Key, @default); + } + + /// <summary>Whether to return a "no translation" placeholder if the translation is <c>null</c> or empty. Returns a new instance.</summary> + /// <param name="use">Whether to return a placeholder.</param> + public Translation UsePlaceholder(bool use) + { + return new Translation(this.ModName, this.Locale, this.Key, this.Text, use ? string.Format(Translation.PlaceholderText, this.Key) : null); + } + + /// <summary>Replace tokens in the text like <c>{{value}}</c> with the given values. Returns a new instance.</summary> + /// <param name="tokens">An object containing token key/value pairs. This can be an anonymous object (like <c>new { value = 42, name = "Cranberries" }</c>), a dictionary, or a class instance.</param> + /// <exception cref="ArgumentNullException">The <paramref name="tokens"/> argument is <c>null</c>.</exception> + public Translation Tokens(object tokens) + { + if (string.IsNullOrWhiteSpace(this.Text) || tokens == null) + return this; + + // get dictionary of tokens + IDictionary<string, string> tokenLookup = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase); + { + // from dictionary + if (tokens is IDictionary inputLookup) + { + foreach (DictionaryEntry entry in inputLookup) + { + string key = entry.Key?.ToString().Trim(); + if (key != null) + tokenLookup[key] = entry.Value?.ToString(); + } + } + + // from object properties + else + { + Type type = tokens.GetType(); + foreach (PropertyInfo prop in type.GetProperties()) + tokenLookup[prop.Name] = prop.GetValue(tokens)?.ToString(); + foreach (FieldInfo field in type.GetFields()) + tokenLookup[field.Name] = field.GetValue(tokens)?.ToString(); + } + } + + // format translation + string text = Regex.Replace(this.Text, @"{{([ \w\.\-]+)}}", match => + { + string key = match.Groups[1].Value.Trim(); + return tokenLookup.TryGetValue(key, out string value) + ? value + : match.Value; + }); + return new Translation(this.ModName, this.Locale, this.Key, text); + } + + /// <summary>Get whether the translation has a defined value.</summary> + public bool HasValue() + { + return !string.IsNullOrEmpty(this.Text); + } + + /// <summary>Get the translation text. Calling this method isn't strictly necessary, since you can assign a <see cref="Translation"/> value directly to a string.</summary> + public override string ToString() + { + return this.Placeholder != null && !this.HasValue() + ? this.Placeholder + : this.Text; + } + + /// <summary>Get a string representation of the given translation.</summary> + /// <param name="translation">The translation key.</param> + public static implicit operator string(Translation translation) + { + return translation?.ToString(); + } + } +} diff --git a/src/StardewModdingAPI/Utilities/SButton.cs b/src/StardewModdingAPI/Utilities/SButton.cs new file mode 100644 index 00000000..fa5ae648 --- /dev/null +++ b/src/StardewModdingAPI/Utilities/SButton.cs @@ -0,0 +1,675 @@ +using System; +using Microsoft.Xna.Framework.Input; +using StardewValley; + +namespace StardewModdingAPI.Utilities +{ + /// <summary>A unified button constant which includes all controller, keyboard, and mouse buttons.</summary> + /// <remarks>Derived from <see cref="Keys"/>, <see cref="Buttons"/>, and <see cref="System.Windows.Forms.MouseButtons"/>.</remarks> + public enum SButton + { + /// <summary>No valid key.</summary> + None = 0, + + /********* + ** Mouse + *********/ + /// <summary>The left mouse button.</summary> + MouseLeft = 1000, + + /// <summary>The right mouse button.</summary> + MouseRight = 1001, + + /// <summary>The middle mouse button.</summary> + MouseMiddle = 1002, + + /// <summary>The first mouse XButton.</summary> + MouseX1 = 1003, + + /// <summary>The second mouse XButton.</summary> + MouseX2 = 1004, + + /********* + ** Controller + *********/ + /// <summary>The 'A' button on a controller.</summary> + ControllerA = SButtonExtensions.ControllerOffset + Buttons.A, + + /// <summary>The 'B' button on a controller.</summary> + ControllerB = SButtonExtensions.ControllerOffset + Buttons.B, + + /// <summary>The 'X' button on a controller.</summary> + ControllerX = SButtonExtensions.ControllerOffset + Buttons.X, + + /// <summary>The 'Y' button on a controller.</summary> + ControllerY = SButtonExtensions.ControllerOffset + Buttons.Y, + + /// <summary>The back button on a controller.</summary> + ControllerBack = SButtonExtensions.ControllerOffset + Buttons.Back, + + /// <summary>The start button on a controller.</summary> + ControllerStart = SButtonExtensions.ControllerOffset + Buttons.Start, + + /// <summary>The up button on the directional pad of a controller.</summary> + DPadUp = SButtonExtensions.ControllerOffset + Buttons.DPadUp, + + /// <summary>The down button on the directional pad of a controller.</summary> + DPadDown = SButtonExtensions.ControllerOffset + Buttons.DPadDown, + + /// <summary>The left button on the directional pad of a controller.</summary> + DPadLeft = SButtonExtensions.ControllerOffset + Buttons.DPadLeft, + + /// <summary>The right button on the directional pad of a controller.</summary> + DPadRight = SButtonExtensions.ControllerOffset + Buttons.DPadRight, + + /// <summary>The left bumper (shoulder) button on a controller.</summary> + LeftShoulder = SButtonExtensions.ControllerOffset + Buttons.LeftShoulder, + + /// <summary>The right bumper (shoulder) button on a controller.</summary> + RightShoulder = SButtonExtensions.ControllerOffset + Buttons.RightShoulder, + + /// <summary>The left trigger on a controller.</summary> + LeftTrigger = SButtonExtensions.ControllerOffset + Buttons.LeftTrigger, + + /// <summary>The right trigger on a controller.</summary> + RightTrigger = SButtonExtensions.ControllerOffset + Buttons.RightTrigger, + + /// <summary>The left analog stick on a controller (when pressed).</summary> + LeftStick = SButtonExtensions.ControllerOffset + Buttons.LeftStick, + + /// <summary>The right analog stick on a controller (when pressed).</summary> + RightStick = SButtonExtensions.ControllerOffset + Buttons.RightStick, + + /// <summary>The 'big button' on a controller.</summary> + BigButton = SButtonExtensions.ControllerOffset + Buttons.BigButton, + + /// <summary>The left analog stick on a controller (when pushed left).</summary> + LeftThumbstickLeft = SButtonExtensions.ControllerOffset + Buttons.LeftThumbstickLeft, + + /// <summary>The left analog stick on a controller (when pushed right).</summary> + LeftThumbstickRight = SButtonExtensions.ControllerOffset + Buttons.LeftThumbstickRight, + + /// <summary>The left analog stick on a controller (when pushed down).</summary> + LeftThumbstickDown = SButtonExtensions.ControllerOffset + Buttons.LeftThumbstickDown, + + /// <summary>The left analog stick on a controller (when pushed up).</summary> + LeftThumbstickUp = SButtonExtensions.ControllerOffset + Buttons.LeftThumbstickUp, + + /// <summary>The right analog stick on a controller (when pushed left).</summary> + RightThumbstickLeft = SButtonExtensions.ControllerOffset + Buttons.RightThumbstickLeft, + + /// <summary>The right analog stick on a controller (when pushed right).</summary> + RightThumbstickRight = SButtonExtensions.ControllerOffset + Buttons.RightThumbstickRight, + + /// <summary>The right analog stick on a controller (when pushed down).</summary> + RightThumbstickDown = SButtonExtensions.ControllerOffset + Buttons.RightThumbstickDown, + + /// <summary>The right analog stick on a controller (when pushed up).</summary> + RightThumbstickUp = SButtonExtensions.ControllerOffset + Buttons.RightThumbstickUp, + + /********* + ** Keyboard + *********/ + /// <summary>The A button on a keyboard.</summary> + A = Keys.A, + + /// <summary>The Add button on a keyboard.</summary> + Add = Keys.Add, + + /// <summary>The Applications button on a keyboard.</summary> + Apps = Keys.Apps, + + /// <summary>The Attn button on a keyboard.</summary> + Attn = Keys.Attn, + + /// <summary>The B button on a keyboard.</summary> + B = Keys.B, + + /// <summary>The Backspace button on a keyboard.</summary> + Back = Keys.Back, + + /// <summary>The Browser Back button on a keyboard in Windows 2000/XP.</summary> + BrowserBack = Keys.BrowserBack, + + /// <summary>The Browser Favorites button on a keyboard in Windows 2000/XP.</summary> + BrowserFavorites = Keys.BrowserFavorites, + + /// <summary>The Browser Favorites button on a keyboard in Windows 2000/XP.</summary> + BrowserForward = Keys.BrowserForward, + + /// <summary>The Browser Home button on a keyboard in Windows 2000/XP.</summary> + BrowserHome = Keys.BrowserHome, + + /// <summary>The Browser Refresh button on a keyboard in Windows 2000/XP.</summary> + BrowserRefresh = Keys.BrowserRefresh, + + /// <summary>The Browser Search button on a keyboard in Windows 2000/XP.</summary> + BrowserSearch = Keys.BrowserSearch, + + /// <summary>The Browser Stop button on a keyboard in Windows 2000/XP.</summary> + BrowserStop = Keys.BrowserStop, + + /// <summary>The C button on a keyboard.</summary> + C = Keys.C, + + /// <summary>The Caps Lock button on a keyboard.</summary> + CapsLock = Keys.CapsLock, + + /// <summary>The Green ChatPad button on a keyboard.</summary> + ChatPadGreen = Keys.ChatPadGreen, + + /// <summary>The Orange ChatPad button on a keyboard.</summary> + ChatPadOrange = Keys.ChatPadOrange, + + /// <summary>The CrSel button on a keyboard.</summary> + Crsel = Keys.Crsel, + + /// <summary>The D button on a keyboard.</summary> + D = Keys.D, + + /// <summary>A miscellaneous button on a keyboard; can vary by keyboard.</summary> + D0 = Keys.D0, + + /// <summary>A miscellaneous button on a keyboard; can vary by keyboard.</summary> + D1 = Keys.D1, + + /// <summary>A miscellaneous button on a keyboard; can vary by keyboard.</summary> + D2 = Keys.D2, + + /// <summary>A miscellaneous button on a keyboard; can vary by keyboard.</summary> + D3 = Keys.D3, + + /// <summary>A miscellaneous button on a keyboard; can vary by keyboard.</summary> + D4 = Keys.D4, + + /// <summary>A miscellaneous button on a keyboard; can vary by keyboard.</summary> + D5 = Keys.D5, + + /// <summary>A miscellaneous button on a keyboard; can vary by keyboard.</summary> + D6 = Keys.D6, + + /// <summary>A miscellaneous button on a keyboard; can vary by keyboard.</summary> + D7 = Keys.D7, + + /// <summary>A miscellaneous button on a keyboard; can vary by keyboard.</summary> + D8 = Keys.D8, + + /// <summary>A miscellaneous button on a keyboard; can vary by keyboard.</summary> + D9 = Keys.D9, + + /// <summary>The Decimal button on a keyboard.</summary> + Decimal = Keys.Decimal, + + /// <summary>The Delete button on a keyboard.</summary> + Delete = Keys.Delete, + + /// <summary>The Divide button on a keyboard.</summary> + Divide = Keys.Divide, + + /// <summary>The Down arrow button on a keyboard.</summary> + Down = Keys.Down, + + /// <summary>The E button on a keyboard.</summary> + E = Keys.E, + + /// <summary>The End button on a keyboard.</summary> + End = Keys.End, + + /// <summary>The Enter button on a keyboard.</summary> + Enter = Keys.Enter, + + /// <summary>The Erase EOF button on a keyboard.</summary> + EraseEof = Keys.EraseEof, + + /// <summary>The Escape button on a keyboard.</summary> + Escape = Keys.Escape, + + /// <summary>The Execute button on a keyboard.</summary> + Execute = Keys.Execute, + + /// <summary>The ExSel button on a keyboard.</summary> + Exsel = Keys.Exsel, + + /// <summary>The F button on a keyboard.</summary> + F = Keys.F, + + /// <summary>The F1 button on a keyboard.</summary> + F1 = Keys.F1, + + /// <summary>The F10 button on a keyboard.</summary> + F10 = Keys.F10, + + /// <summary>The F11 button on a keyboard.</summary> + F11 = Keys.F11, + + /// <summary>The F12 button on a keyboard.</summary> + F12 = Keys.F12, + + /// <summary>The F13 button on a keyboard.</summary> + F13 = Keys.F13, + + /// <summary>The F14 button on a keyboard.</summary> + F14 = Keys.F14, + + /// <summary>The F15 button on a keyboard.</summary> + F15 = Keys.F15, + + /// <summary>The F16 button on a keyboard.</summary> + F16 = Keys.F16, + + /// <summary>The F17 button on a keyboard.</summary> + F17 = Keys.F17, + + /// <summary>The F18 button on a keyboard.</summary> + F18 = Keys.F18, + + /// <summary>The F19 button on a keyboard.</summary> + F19 = Keys.F19, + + /// <summary>The F2 button on a keyboard.</summary> + F2 = Keys.F2, + + /// <summary>The F20 button on a keyboard.</summary> + F20 = Keys.F20, + + /// <summary>The F21 button on a keyboard.</summary> + F21 = Keys.F21, + + /// <summary>The F22 button on a keyboard.</summary> + F22 = Keys.F22, + + /// <summary>The F23 button on a keyboard.</summary> + F23 = Keys.F23, + + /// <summary>The F24 button on a keyboard.</summary> + F24 = Keys.F24, + + /// <summary>The F3 button on a keyboard.</summary> + F3 = Keys.F3, + + /// <summary>The F4 button on a keyboard.</summary> + F4 = Keys.F4, + + /// <summary>The F5 button on a keyboard.</summary> + F5 = Keys.F5, + + /// <summary>The F6 button on a keyboard.</summary> + F6 = Keys.F6, + + /// <summary>The F7 button on a keyboard.</summary> + F7 = Keys.F7, + + /// <summary>The F8 button on a keyboard.</summary> + F8 = Keys.F8, + + /// <summary>The F9 button on a keyboard.</summary> + F9 = Keys.F9, + + /// <summary>The G button on a keyboard.</summary> + G = Keys.G, + + /// <summary>The H button on a keyboard.</summary> + H = Keys.H, + + /// <summary>The Help button on a keyboard.</summary> + Help = Keys.Help, + + /// <summary>The Home button on a keyboard.</summary> + Home = Keys.Home, + + /// <summary>The I button on a keyboard.</summary> + I = Keys.I, + + /// <summary>The IME Convert button on a keyboard.</summary> + ImeConvert = Keys.ImeConvert, + + /// <summary>The IME NoConvert button on a keyboard.</summary> + ImeNoConvert = Keys.ImeNoConvert, + + /// <summary>The INS button on a keyboard.</summary> + Insert = Keys.Insert, + + /// <summary>The J button on a keyboard.</summary> + J = Keys.J, + + /// <summary>The K button on a keyboard.</summary> + K = Keys.K, + + /// <summary>The Kana button on a Japanese keyboard.</summary> + Kana = Keys.Kana, + + /// <summary>The Kanji button on a Japanese keyboard.</summary> + Kanji = Keys.Kanji, + + /// <summary>The L button on a keyboard.</summary> + L = Keys.L, + + /// <summary>The Start Applications 1 button on a keyboard in Windows 2000/XP.</summary> + LaunchApplication1 = Keys.LaunchApplication1, + + /// <summary>The Start Applications 2 button on a keyboard in Windows 2000/XP.</summary> + LaunchApplication2 = Keys.LaunchApplication2, + + /// <summary>The Start Mail button on a keyboard in Windows 2000/XP.</summary> + LaunchMail = Keys.LaunchMail, + + /// <summary>The Left arrow button on a keyboard.</summary> + Left = Keys.Left, + + /// <summary>The Left Alt button on a keyboard.</summary> + LeftAlt = Keys.LeftAlt, + + /// <summary>The Left Control button on a keyboard.</summary> + LeftControl = Keys.LeftControl, + + /// <summary>The Left Shift button on a keyboard.</summary> + LeftShift = Keys.LeftShift, + + /// <summary>The Left Windows button on a keyboard.</summary> + LeftWindows = Keys.LeftWindows, + + /// <summary>The M button on a keyboard.</summary> + M = Keys.M, + + /// <summary>The MediaNextTrack button on a keyboard in Windows 2000/XP.</summary> + MediaNextTrack = Keys.MediaNextTrack, + + /// <summary>The MediaPlayPause button on a keyboard in Windows 2000/XP.</summary> + MediaPlayPause = Keys.MediaPlayPause, + + /// <summary>The MediaPreviousTrack button on a keyboard in Windows 2000/XP.</summary> + MediaPreviousTrack = Keys.MediaPreviousTrack, + + /// <summary>The MediaStop button on a keyboard in Windows 2000/XP.</summary> + MediaStop = Keys.MediaStop, + + /// <summary>The Multiply button on a keyboard.</summary> + Multiply = Keys.Multiply, + + /// <summary>The N button on a keyboard.</summary> + N = Keys.N, + + /// <summary>The Num Lock button on a keyboard.</summary> + NumLock = Keys.NumLock, + + /// <summary>The Numeric keypad 0 button on a keyboard.</summary> + NumPad0 = Keys.NumPad0, + + /// <summary>The Numeric keypad 1 button on a keyboard.</summary> + NumPad1 = Keys.NumPad1, + + /// <summary>The Numeric keypad 2 button on a keyboard.</summary> + NumPad2 = Keys.NumPad2, + + /// <summary>The Numeric keypad 3 button on a keyboard.</summary> + NumPad3 = Keys.NumPad3, + + /// <summary>The Numeric keypad 4 button on a keyboard.</summary> + NumPad4 = Keys.NumPad4, + + /// <summary>The Numeric keypad 5 button on a keyboard.</summary> + NumPad5 = Keys.NumPad5, + + /// <summary>The Numeric keypad 6 button on a keyboard.</summary> + NumPad6 = Keys.NumPad6, + + /// <summary>The Numeric keypad 7 button on a keyboard.</summary> + NumPad7 = Keys.NumPad7, + + /// <summary>The Numeric keypad 8 button on a keyboard.</summary> + NumPad8 = Keys.NumPad8, + + /// <summary>The Numeric keypad 9 button on a keyboard.</summary> + NumPad9 = Keys.NumPad9, + + /// <summary>The O button on a keyboard.</summary> + O = Keys.O, + + /// <summary>A miscellaneous button on a keyboard; can vary by keyboard.</summary> + Oem8 = Keys.Oem8, + + /// <summary>The OEM Auto button on a keyboard.</summary> + OemAuto = Keys.OemAuto, + + /// <summary>The OEM Angle Bracket or Backslash button on the RT 102 keyboard in Windows 2000/XP.</summary> + OemBackslash = Keys.OemBackslash, + + /// <summary>The Clear button on a keyboard.</summary> + OemClear = Keys.OemClear, + + /// <summary>The OEM Close Bracket button on a US standard keyboard in Windows 2000/XP.</summary> + OemCloseBrackets = Keys.OemCloseBrackets, + + /// <summary>The ',' button on a keyboard in any country/region in Windows 2000/XP.</summary> + OemComma = Keys.OemComma, + + /// <summary>The OEM Copy button on a keyboard.</summary> + OemCopy = Keys.OemCopy, + + /// <summary>The OEM Enlarge Window button on a keyboard.</summary> + OemEnlW = Keys.OemEnlW, + + /// <summary>The '-' button on a keyboard in any country/region in Windows 2000/XP.</summary> + OemMinus = Keys.OemMinus, + + /// <summary>The OEM Open Bracket button on a US standard keyboard in Windows 2000/XP.</summary> + OemOpenBrackets = Keys.OemOpenBrackets, + + /// <summary>The '.' button on a keyboard in any country/region.</summary> + OemPeriod = Keys.OemPeriod, + + /// <summary>The OEM Pipe button on a US standard keyboard.</summary> + OemPipe = Keys.OemPipe, + + /// <summary>The '+' button on a keyboard in Windows 2000/XP.</summary> + OemPlus = Keys.OemPlus, + + /// <summary>The OEM Question Mark button on a US standard keyboard.</summary> + OemQuestion = Keys.OemQuestion, + + /// <summary>The OEM Single/Double Quote button on a US standard keyboard.</summary> + OemQuotes = Keys.OemQuotes, + + /// <summary>The OEM Semicolon button on a US standard keyboard.</summary> + OemSemicolon = Keys.OemSemicolon, + + /// <summary>The OEM Tilde button on a US standard keyboard.</summary> + OemTilde = Keys.OemTilde, + + /// <summary>The P button on a keyboard.</summary> + P = Keys.P, + + /// <summary>The PA1 button on a keyboard.</summary> + Pa1 = Keys.Pa1, + + /// <summary>The Page Down button on a keyboard.</summary> + PageDown = Keys.PageDown, + + /// <summary>The Page Up button on a keyboard.</summary> + PageUp = Keys.PageUp, + + /// <summary>The Pause button on a keyboard.</summary> + Pause = Keys.Pause, + + /// <summary>The Play button on a keyboard.</summary> + Play = Keys.Play, + + /// <summary>The Print button on a keyboard.</summary> + Print = Keys.Print, + + /// <summary>The Print Screen button on a keyboard.</summary> + PrintScreen = Keys.PrintScreen, + + /// <summary>The IME Process button on a keyboard in Windows 95/98/ME/NT 4.0/2000/XP.</summary> + ProcessKey = Keys.ProcessKey, + + /// <summary>The Q button on a keyboard.</summary> + Q = Keys.Q, + + /// <summary>The R button on a keyboard.</summary> + R = Keys.R, + + /// <summary>The Right Arrow button on a keyboard.</summary> + Right = Keys.Right, + + /// <summary>The Right Alt button on a keyboard.</summary> + RightAlt = Keys.RightAlt, + + /// <summary>The Right Control button on a keyboard.</summary> + RightControl = Keys.RightControl, + + /// <summary>The Right Shift button on a keyboard.</summary> + RightShift = Keys.RightShift, + + /// <summary>The Right Windows button on a keyboard.</summary> + RightWindows = Keys.RightWindows, + + /// <summary>The S button on a keyboard.</summary> + S = Keys.S, + + /// <summary>The Scroll Lock button on a keyboard.</summary> + Scroll = Keys.Scroll, + + /// <summary>The Select button on a keyboard.</summary> + Select = Keys.Select, + + /// <summary>The Select Media button on a keyboard in Windows 2000/XP.</summary> + SelectMedia = Keys.SelectMedia, + + /// <summary>The Separator button on a keyboard.</summary> + Separator = Keys.Separator, + + /// <summary>The Computer Sleep button on a keyboard.</summary> + Sleep = Keys.Sleep, + + /// <summary>The Space bar on a keyboard.</summary> + Space = Keys.Space, + + /// <summary>The Subtract button on a keyboard.</summary> + Subtract = Keys.Subtract, + + /// <summary>The T button on a keyboard.</summary> + T = Keys.T, + + /// <summary>The Tab button on a keyboard.</summary> + Tab = Keys.Tab, + + /// <summary>The U button on a keyboard.</summary> + U = Keys.U, + + /// <summary>The Up Arrow button on a keyboard.</summary> + Up = Keys.Up, + + /// <summary>The V button on a keyboard.</summary> + V = Keys.V, + + /// <summary>The Volume Down button on a keyboard in Windows 2000/XP.</summary> + VolumeDown = Keys.VolumeDown, + + /// <summary>The Volume Mute button on a keyboard in Windows 2000/XP.</summary> + VolumeMute = Keys.VolumeMute, + + /// <summary>The Volume Up button on a keyboard in Windows 2000/XP.</summary> + VolumeUp = Keys.VolumeUp, + + /// <summary>The W button on a keyboard.</summary> + W = Keys.W, + + /// <summary>The X button on a keyboard.</summary> + X = Keys.X, + + /// <summary>The Y button on a keyboard.</summary> + Y = Keys.Y, + + /// <summary>The Z button on a keyboard.</summary> + Z = Keys.Z, + + /// <summary>The Zoom button on a keyboard.</summary> + Zoom = Keys.Zoom + } + + /// <summary>Provides extension methods for <see cref="SButton"/>.</summary> + public static class SButtonExtensions + { + /********* + ** Accessors + *********/ + /// <summary>The offset added to <see cref="Buttons"/> values when converting them to <see cref="SButton"/> to avoid collisions with <see cref="Keys"/> values.</summary> + internal const int ControllerOffset = 2000; + + + /********* + ** Public methods + *********/ + /// <summary>Get the <see cref="SButton"/> equivalent for the given button.</summary> + /// <param name="key">The keyboard button to convert.</param> + internal static SButton ToSButton(this Keys key) + { + return (SButton)key; + } + + /// <summary>Get the <see cref="SButton"/> equivalent for the given button.</summary> + /// <param name="key">The controller button to convert.</param> + internal static SButton ToSButton(this Buttons key) + { + return (SButton)(SButtonExtensions.ControllerOffset + key); + } + + /// <summary>Get the <see cref="Keys"/> equivalent for the given button.</summary> + /// <param name="input">The button to convert.</param> + /// <param name="key">The keyboard equivalent.</param> + /// <returns>Returns whether the value was converted successfully.</returns> + public static bool TryGetKeyboard(this SButton input, out Keys key) + { + if (Enum.IsDefined(typeof(Keys), (int)input)) + { + key = (Keys)input; + return true; + } + + key = Keys.None; + return false; + } + + /// <summary>Get the <see cref="Buttons"/> equivalent for the given button.</summary> + /// <param name="input">The button to convert.</param> + /// <param name="button">The controller equivalent.</param> + /// <returns>Returns whether the value was converted successfully.</returns> + public static bool TryGetController(this SButton input, out Buttons button) + { + if (Enum.IsDefined(typeof(Buttons), (int)input - SButtonExtensions.ControllerOffset)) + { + button = (Buttons)(input - SButtonExtensions.ControllerOffset); + return true; + } + + button = 0; + return false; + } + + /// <summary>Get the <see cref="InputButton"/> equivalent for the given button.</summary> + /// <param name="input">The button to convert.</param> + /// <param name="button">The Stardew Valley input button equivalent.</param> + /// <returns>Returns whether the value was converted successfully.</returns> + public static bool TryGetStardewInput(this SButton input, out InputButton button) + { + // keyboard + if (input.TryGetKeyboard(out Keys key)) + { + button = new InputButton(key); + return true; + } + + // mouse + if (input == SButton.MouseLeft || input == SButton.MouseRight) + { + button = new InputButton(mouseLeft: input == SButton.MouseLeft); + return true; + } + + // not valid + button = default(InputButton); + return false; + } + } +} diff --git a/src/StardewModdingAPI/Utilities/SDate.cs b/src/StardewModdingAPI/Utilities/SDate.cs new file mode 100644 index 00000000..326d7fc7 --- /dev/null +++ b/src/StardewModdingAPI/Utilities/SDate.cs @@ -0,0 +1,232 @@ +using System; +using System.Linq; +using StardewValley; + +namespace StardewModdingAPI.Utilities +{ + /// <summary>Represents a Stardew Valley date.</summary> + public class SDate : IEquatable<SDate> + { + /********* + ** Properties + *********/ + /// <summary>The internal season names in order.</summary> + private readonly string[] Seasons = { "spring", "summer", "fall", "winter" }; + + /// <summary>The number of seasons in a year.</summary> + private int SeasonsInYear => this.Seasons.Length; + + /// <summary>The number of days in a season.</summary> + private readonly int DaysInSeason = 28; + + + /********* + ** Accessors + *********/ + /// <summary>The day of month.</summary> + public int Day { get; } + + /// <summary>The season name.</summary> + public string Season { get; } + + /// <summary>The year.</summary> + public int Year { get; } + + /// <summary>The day of week.</summary> + public DayOfWeek DayOfWeek { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="day">The day of month.</param> + /// <param name="season">The season name.</param> + /// <exception cref="ArgumentException">One of the arguments has an invalid value (like day 35).</exception> + public SDate(int day, string season) + : this(day, season, Game1.year) { } + + /// <summary>Construct an instance.</summary> + /// <param name="day">The day of month.</param> + /// <param name="season">The season name.</param> + /// <param name="year">The year.</param> + /// <exception cref="ArgumentException">One of the arguments has an invalid value (like day 35).</exception> + public SDate(int day, string season, int year) + { + // validate + if (season == null) + throw new ArgumentNullException(nameof(season)); + if (!this.Seasons.Contains(season)) + throw new ArgumentException($"Unknown season '{season}', must be one of [{string.Join(", ", this.Seasons)}]."); + if (day < 1 || day > this.DaysInSeason) + throw new ArgumentException($"Invalid day '{day}', must be a value from 1 to {this.DaysInSeason}."); + if (year < 1) + throw new ArgumentException($"Invalid year '{year}', must be at least 1."); + + // initialise + this.Day = day; + this.Season = season; + this.Year = year; + this.DayOfWeek = this.GetDayOfWeek(); + } + + /// <summary>Get the current in-game date.</summary> + public static SDate Now() + { + return new SDate(Game1.dayOfMonth, Game1.currentSeason, Game1.year); + } + + /// <summary>Get a new date with the given number of days added.</summary> + /// <param name="offset">The number of days to add.</param> + /// <returns>Returns the resulting date.</returns> + /// <exception cref="ArithmeticException">The offset would result in an invalid date (like year 0).</exception> + public SDate AddDays(int offset) + { + // get new hash code + int hashCode = this.GetHashCode() + offset; + if (hashCode < 1) + throw new ArithmeticException($"Adding {offset} days to {this} would result in a date before 01 spring Y1."); + + // get day + int day = hashCode % 28; + if (day == 0) + day = 28; + + // get season index + int seasonIndex = hashCode / 28; + if (seasonIndex > 0 && hashCode % 28 == 0) + seasonIndex -= 1; + seasonIndex %= 4; + + // get year + int year = hashCode / (this.Seasons.Length * this.DaysInSeason) + 1; + + // create date + return new SDate(day, this.Seasons[seasonIndex], year); + } + + /// <summary>Get a string representation of the date. This is mainly intended for debugging or console messages.</summary> + public override string ToString() + { + return $"{this.Day:00} {this.Season} Y{this.Year}"; + } + + /**** + ** IEquatable + ****/ + /// <summary>Get whether this instance is equal to another.</summary> + /// <param name="other">The other value to compare.</param> + public bool Equals(SDate other) + { + return this == other; + } + + /// <summary>Get whether this instance is equal to another.</summary> + /// <param name="obj">The other value to compare.</param> + public override bool Equals(object obj) + { + return obj is SDate other && this == other; + } + + /// <summary>Get a hash code which uniquely identifies a date.</summary> + public override int GetHashCode() + { + // return the number of days since 01 spring Y1 (inclusively) + int yearIndex = this.Year - 1; + return + yearIndex * this.DaysInSeason * this.SeasonsInYear + + this.GetSeasonIndex() * this.DaysInSeason + + this.Day; + } + + /**** + ** Operators + ****/ + /// <summary>Get whether one date is equal to another.</summary> + /// <param name="date">The base date to compare.</param> + /// <param name="other">The other date to compare.</param> + /// <returns>The equality of the dates</returns> + public static bool operator ==(SDate date, SDate other) + { + return date?.GetHashCode() == other?.GetHashCode(); + } + + /// <summary>Get whether one date is not equal to another.</summary> + /// <param name="date">The base date to compare.</param> + /// <param name="other">The other date to compare.</param> + public static bool operator !=(SDate date, SDate other) + { + return date?.GetHashCode() != other?.GetHashCode(); + } + + /// <summary>Get whether one date is more than another.</summary> + /// <param name="date">The base date to compare.</param> + /// <param name="other">The other date to compare.</param> + public static bool operator >(SDate date, SDate other) + { + return date?.GetHashCode() > other?.GetHashCode(); + } + + /// <summary>Get whether one date is more than or equal to another.</summary> + /// <param name="date">The base date to compare.</param> + /// <param name="other">The other date to compare.</param> + public static bool operator >=(SDate date, SDate other) + { + return date?.GetHashCode() >= other?.GetHashCode(); + } + + /// <summary>Get whether one date is less than or equal to another.</summary> + /// <param name="date">The base date to compare.</param> + /// <param name="other">The other date to compare.</param> + public static bool operator <=(SDate date, SDate other) + { + return date?.GetHashCode() <= other?.GetHashCode(); + } + + /// <summary>Get whether one date is less than another.</summary> + /// <param name="date">The base date to compare.</param> + /// <param name="other">The other date to compare.</param> + public static bool operator <(SDate date, SDate other) + { + return date?.GetHashCode() < other?.GetHashCode(); + } + + + /********* + ** Private methods + *********/ + /// <summary>Get the day of week for the current date.</summary> + private DayOfWeek GetDayOfWeek() + { + switch (this.Day % 7) + { + case 0: + return DayOfWeek.Sunday; + case 1: + return DayOfWeek.Monday; + case 2: + return DayOfWeek.Tuesday; + case 3: + return DayOfWeek.Wednesday; + case 4: + return DayOfWeek.Thursday; + case 5: + return DayOfWeek.Friday; + case 6: + return DayOfWeek.Saturday; + default: + return 0; + } + } + + /// <summary>Get the current season index.</summary> + /// <exception cref="InvalidOperationException">The current season wasn't recognised.</exception> + private int GetSeasonIndex() + { + int index = Array.IndexOf(this.Seasons, this.Season); + if (index == -1) + throw new InvalidOperationException($"The current season '{this.Season}' wasn't recognised."); + return index; + } + } +} diff --git a/src/StardewModdingAPI/icon.ico b/src/StardewModdingAPI/icon.ico Binary files differnew file mode 100644 index 00000000..587a6e74 --- /dev/null +++ b/src/StardewModdingAPI/icon.ico diff --git a/src/StardewModdingAPI/packages.config b/src/StardewModdingAPI/packages.config new file mode 100644 index 00000000..e5fa3c3a --- /dev/null +++ b/src/StardewModdingAPI/packages.config @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<packages> + <package id="Mono.Cecil" version="0.9.6.4" targetFramework="net45" /> + <package id="Newtonsoft.Json" version="8.0.3" targetFramework="net461" /> +</packages>
\ No newline at end of file diff --git a/src/StardewModdingAPI/steam_appid.txt b/src/StardewModdingAPI/steam_appid.txt new file mode 100644 index 00000000..9fe92b96 --- /dev/null +++ b/src/StardewModdingAPI/steam_appid.txt @@ -0,0 +1 @@ +413150
\ No newline at end of file diff --git a/src/StardewModdingAPI/unix-launcher.sh b/src/StardewModdingAPI/unix-launcher.sh new file mode 100644 index 00000000..70f1873a --- /dev/null +++ b/src/StardewModdingAPI/unix-launcher.sh @@ -0,0 +1,88 @@ +#!/bin/bash +# MonoKickstart Shell Script +# Written by Ethan "flibitijibibo" Lee +# Modified for StardewModdingAPI by Viz and Pathoschild + +# Move to script's directory +cd "`dirname "$0"`" + +# Get the system architecture +UNAME=`uname` +ARCH=`uname -m` + +# MonoKickstart picks the right libfolder, so just execute the right binary. +if [ "$UNAME" == "Darwin" ]; then + # ... Except on OSX. + export DYLD_LIBRARY_PATH=$DYLD_LIBRARY_PATH:./osx/ + + # El Capitan is a total idiot and wipes this variable out, making the + # Steam overlay disappear. This sidesteps "System Integrity Protection" + # and resets the variable with Valve's own variable (they provided this + # fix by the way, thanks Valve!). Note that you will need to update your + # launch configuration to the script location, NOT just the app location + # (i.e. Kick.app/Contents/MacOS/Kick, not just Kick.app). + # -flibit + if [ "$STEAM_DYLD_INSERT_LIBRARIES" != "" ] && [ "$DYLD_INSERT_LIBRARIES" == "" ]; then + export DYLD_INSERT_LIBRARIES="$STEAM_DYLD_INSERT_LIBRARIES" + fi + + # this was here before + ln -sf mcs.bin.osx mcs + + # fix "DllNotFoundException: libgdiplus.dylib" errors when loading images in SMAPI + if [ -f libgdiplus.dylib ]; then + rm libgdiplus.dylib + fi + if [ -f /Library/Frameworks/Mono.framework/Versions/Current/lib/libgdiplus.dylib ]; then + ln -s /Library/Frameworks/Mono.framework/Versions/Current/lib/libgdiplus.dylib libgdiplus.dylib + fi + + # launch SMAPI + cp StardewValley.bin.osx StardewModdingAPI.bin.osx + open -a Terminal ./StardewModdingAPI.bin.osx $@ +else + # choose launcher + LAUNCHER="" + if [ "$ARCH" == "x86_64" ]; then + ln -sf mcs.bin.x86_64 mcs + cp StardewValley.bin.x86_64 StardewModdingAPI.bin.x86_64 + LAUNCHER="./StardewModdingAPI.bin.x86_64 $@" + else + ln -sf mcs.bin.x86 mcs + cp StardewValley.bin.x86 StardewModdingAPI.bin.x86 + LAUNCHER="./StardewModdingAPI.bin.x86 $@" + fi + + # get cross-distro version of POSIX command + COMMAND="" + if command -v command 2>/dev/null; then + COMMAND="command -v" + elif type type 2>/dev/null; then + COMMAND="type" + fi + + # open SMAPI in terminal + if $COMMAND x-terminal-emulator 2>/dev/null; then + x-terminal-emulator -e "$LAUNCHER" + elif $COMMAND xfce4-terminal 2>/dev/null; then + xfce4-terminal -e "$LAUNCHER" + elif $COMMAND gnome-terminal 2>/dev/null; then + gnome-terminal -e "$LAUNCHER" + elif $COMMAND xterm 2>/dev/null; then + xterm -e "$LAUNCHER" + elif $COMMAND konsole 2>/dev/null; then + konsole -e "$LAUNCHER" + elif $COMMAND terminal 2>/dev/null; then + terminal -e "$LAUNCHER" + else + $LAUNCHER + fi + + # some Linux users get error 127 (command not found) from the above block, even though + # `command -v` indicates the command is valid. As a fallback, launch SMAPI without a terminal when + # that happens and pass in an argument indicating SMAPI shouldn't try writing to the terminal + # (which can be slow if there is none). + if [ $? -eq 127 ]; then + $LAUNCHER --no-terminal + fi +fi |