diff options
Diffstat (limited to 'src/StardewModdingAPI')
69 files changed, 2781 insertions, 1882 deletions
diff --git a/src/StardewModdingAPI/Advanced/ConfigFile.cs b/src/StardewModdingAPI/Advanced/ConfigFile.cs deleted file mode 100644 index 1a2e6618..00000000 --- a/src/StardewModdingAPI/Advanced/ConfigFile.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.IO; -using Newtonsoft.Json; - -namespace StardewModdingAPI.Advanced -{ - /// <summary>Wraps a configuration file with IO methods for convenience.</summary> - public abstract class ConfigFile : IConfigFile - { - /********* - ** Accessors - *********/ - /// <summary>Provides simplified APIs for writing mods.</summary> - public IModHelper ModHelper { get; set; } - - /// <summary>The file path from which the model was loaded, relative to the mod directory.</summary> - public string FilePath { get; set; } - - - /********* - ** Public methods - *********/ - /// <summary>Reparse the underlying file and update this model.</summary> - public void Reload() - { - string json = File.ReadAllText(Path.Combine(this.ModHelper.DirectoryPath, this.FilePath)); - JsonConvert.PopulateObject(json, this); - } - - /// <summary>Save this model to the underlying file.</summary> - public void Save() - { - this.ModHelper.WriteJsonFile(this.FilePath, this); - } - } -}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Advanced/IConfigFile.cs b/src/StardewModdingAPI/Advanced/IConfigFile.cs deleted file mode 100644 index 5bc31a88..00000000 --- a/src/StardewModdingAPI/Advanced/IConfigFile.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace StardewModdingAPI.Advanced -{ - /// <summary>Wraps a configuration file with IO methods for convenience.</summary> - public interface IConfigFile - { - /********* - ** Accessors - *********/ - /// <summary>Provides simplified APIs for writing mods.</summary> - IModHelper ModHelper { get; set; } - - /// <summary>The file path from which the model was loaded, relative to the mod directory.</summary> - string FilePath { get; set; } - - - /********* - ** Methods - *********/ - /// <summary>Reparse the underlying file and update this model.</summary> - void Reload(); - - /// <summary>Save this model to the underlying file.</summary> - void Save(); - } -} diff --git a/src/StardewModdingAPI/Command.cs b/src/StardewModdingAPI/Command.cs index 1fa18d49..e2d08538 100644 --- a/src/StardewModdingAPI/Command.cs +++ b/src/StardewModdingAPI/Command.cs @@ -1,23 +1,33 @@ using System; using System.Collections.Generic; -using System.Linq; using StardewModdingAPI.Events; using StardewModdingAPI.Framework; namespace StardewModdingAPI { /// <summary>A command that can be submitted through the SMAPI console to interact with SMAPI.</summary> + [Obsolete("Use " + nameof(IModHelper) + "." + nameof(IModHelper.ConsoleCommands))] public class Command { /********* ** Properties *********/ - /**** - ** SMAPI - ****/ /// <summary>The commands registered with SMAPI.</summary> - internal static List<Command> RegisteredCommands = new List<Command>(); + private static readonly IDictionary<string, Command> LegacyCommands = new Dictionary<string, Command>(StringComparer.InvariantCultureIgnoreCase); + + /// <summary>Manages console commands.</summary> + private static CommandManager CommandManager; + /// <summary>Manages deprecation warnings.</summary> + private static DeprecationManager DeprecationManager; + + /// <summary>Tracks the installed mods.</summary> + private static ModRegistry ModRegistry; + + + /********* + ** Accessors + *********/ /// <summary>The event raised when this command is submitted through the console.</summary> public event EventHandler<EventArgsCommand> CommandFired; @@ -43,6 +53,17 @@ namespace StardewModdingAPI /**** ** Command ****/ + /// <summary>Injects types required for backwards compatibility.</summary> + /// <param name="commandManager">Manages console commands.</param> + /// <param name="deprecationManager">Manages deprecation warnings.</param> + /// <param name="modRegistry">Tracks the installed mods.</param> + internal static void Shim(CommandManager commandManager, DeprecationManager deprecationManager, ModRegistry modRegistry) + { + Command.CommandManager = commandManager; + Command.DeprecationManager = deprecationManager; + Command.ModRegistry = modRegistry; + } + /// <summary>Construct an instance.</summary> /// <param name="name">The name of the command.</param> /// <param name="description">A human-readable description of what the command does.</param> @@ -64,44 +85,17 @@ namespace StardewModdingAPI this.CommandFired.Invoke(this, new EventArgsCommand(this)); } + /**** ** SMAPI ****/ /// <summary>Parse a command string and invoke it if valid.</summary> /// <param name="input">The command to run, including the command name and any arguments.</param> - [Obsolete("Use the overload which passes in your mod's monitor")] - public static void CallCommand(string input) - { - Program.DeprecationManager.Warn($"an old version of {nameof(Command)}.{nameof(Command.CallCommand)}", "1.1", DeprecationLevel.Notice); - Command.CallCommand(input, Program.GetLegacyMonitorForMod()); - } - - /// <summary>Parse a command string and invoke it if valid.</summary> - /// <param name="input">The command to run, including the command name and any arguments.</param> /// <param name="monitor">Encapsulates monitoring and logging.</param> public static void CallCommand(string input, IMonitor monitor) { - // normalise input - input = input?.Trim(); - if (string.IsNullOrWhiteSpace(input)) - return; - - // tokenise input - string[] args = input.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); - string commandName = args[0]; - args = args.Skip(1).ToArray(); - - // get command - Command command = Command.FindCommand(commandName); - if (command == null) - { - monitor.Log("Unknown command", LogLevel.Error); - return; - } - - // fire command - command.CalledArgs = args; - command.Fire(); + Command.DeprecationManager.Warn("Command.CallCommand", "1.9", DeprecationLevel.Info); + Command.CommandManager.Trigger(input); } /// <summary>Register a command with SMAPI.</summary> @@ -110,11 +104,25 @@ namespace StardewModdingAPI /// <param name="args">A human-readable list of accepted arguments.</param> public static Command RegisterCommand(string name, string description, string[] args = null) { - var command = new Command(name, description, args); - if (Command.RegisteredCommands.Contains(command)) - throw new InvalidOperationException($"The '{command.CommandName}' command is already registered!"); + name = name?.Trim().ToLower(); + + // raise deprecation warning + Command.DeprecationManager.Warn("Command.RegisterCommand", "1.9", DeprecationLevel.Info); - Command.RegisteredCommands.Add(command); + // validate + if (Command.LegacyCommands.ContainsKey(name)) + throw new InvalidOperationException($"The '{name}' command is already registered!"); + + // add command + string modName = Command.ModRegistry.GetModFromStack() ?? "<unknown mod>"; + string documentation = args?.Length > 0 + ? $"{description} - {string.Join(", ", args)}" + : description; + Command.CommandManager.Add(modName, name, documentation, Command.Fire); + + // add legacy command + Command command = new Command(name, description, args); + Command.LegacyCommands.Add(name, command); return command; } @@ -122,7 +130,28 @@ namespace StardewModdingAPI /// <param name="name">The command name to find.</param> public static Command FindCommand(string name) { - return Command.RegisteredCommands.Find(x => x.CommandName.Equals(name)); + Command.DeprecationManager.Warn("Command.FindCommand", "1.9", DeprecationLevel.Info); + if (name == null) + return null; + + Command command; + Command.LegacyCommands.TryGetValue(name.Trim(), out command); + return command; + } + + + /********* + ** Private methods + *********/ + /// <summary>Trigger this command.</summary> + /// <param name="name">The command name.</param> + /// <param name="args">The command arguments.</param> + private static void Fire(string name, string[] args) + { + Command command; + if (!Command.LegacyCommands.TryGetValue(name, out command)) + throw new InvalidOperationException($"Can't run command '{name}' because there's no such legacy command."); + command.Fire(); } } } diff --git a/src/StardewModdingAPI/Config.cs b/src/StardewModdingAPI/Config.cs index 037c0fdf..9f4bfad2 100644 --- a/src/StardewModdingAPI/Config.cs +++ b/src/StardewModdingAPI/Config.cs @@ -12,6 +12,13 @@ namespace StardewModdingAPI public abstract class Config { /********* + ** Properties + *********/ + /// <summary>Manages deprecation warnings.</summary> + private static DeprecationManager DeprecationManager; + + + /********* ** Accessors *********/ /// <summary>The full path to the configuration file.</summary> @@ -26,6 +33,13 @@ namespace StardewModdingAPI /********* ** Public methods *********/ + /// <summary>Injects types required for backwards compatibility.</summary> + /// <param name="deprecationManager">Manages deprecation warnings.</param> + internal static void Shim(DeprecationManager deprecationManager) + { + Config.DeprecationManager = deprecationManager; + } + /// <summary>Construct an instance of the config class.</summary> /// <typeparam name="T">The config class type.</typeparam> [Obsolete("This base class is obsolete since SMAPI 1.0. See the latest project README for details.")] @@ -111,8 +125,8 @@ namespace StardewModdingAPI /// <summary>Construct an instance.</summary> protected Config() { - Program.DeprecationManager.Warn("the Config class", "1.0", DeprecationLevel.Notice); - Program.DeprecationManager.MarkWarned($"{nameof(Mod)}.{nameof(Mod.BaseConfigPath)}", "1.0"); // typically used to construct config, avoid redundant warnings + Config.DeprecationManager.Warn("the Config class", "1.0", DeprecationLevel.Info); + Config.DeprecationManager.MarkWarned($"{nameof(Mod)}.{nameof(Mod.BaseConfigPath)}", "1.0"); // typically used to construct config, avoid redundant warnings } } diff --git a/src/StardewModdingAPI/Constants.cs b/src/StardewModdingAPI/Constants.cs index a62a0d58..4a036cd0 100644 --- a/src/StardewModdingAPI/Constants.cs +++ b/src/StardewModdingAPI/Constants.cs @@ -3,8 +3,12 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; +using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.AssemblyRewriters; +using StardewModdingAPI.AssemblyRewriters.Finders; using StardewModdingAPI.AssemblyRewriters.Rewriters; +using StardewModdingAPI.AssemblyRewriters.Rewriters.Wrappers; +using StardewModdingAPI.Events; using StardewValley; namespace StardewModdingAPI @@ -15,64 +19,74 @@ namespace StardewModdingAPI /********* ** Properties *********/ - /// <summary>The directory name containing the current save's data (if a save is loaded).</summary> - private static string RawSaveFolderName => Constants.PlayerNull ? string.Empty : Constants.GetSaveFolderName(); - /// <summary>The directory path containing the current save's data (if a save is loaded).</summary> - private static string RawSavePath => Constants.PlayerNull ? string.Empty : Path.Combine(Constants.SavesPath, Constants.RawSaveFolderName); + private static string RawSavePath => Constants.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 => Constants.IsSaveLoaded && Directory.Exists(Constants.RawSavePath); /********* ** Accessors *********/ + /**** + ** Public + ****/ /// <summary>SMAPI's current semantic version.</summary> - [Obsolete("Use " + nameof(Constants) + "." + nameof(ApiVersion))] - public static readonly Version Version = (Version)Constants.ApiVersion; - - /// <summary>SMAPI's current semantic version.</summary> - public static ISemanticVersion ApiVersion => new Version(1, 8, 0, null, suppressDeprecationWarning: true); + public static ISemanticVersion ApiVersion { get; } = new SemanticVersion(1, 9, 0); /// <summary>The minimum supported version of Stardew Valley.</summary> - public const string MinimumGameVersion = "1.1"; + public static ISemanticVersion MinimumGameVersion { get; } = new SemanticVersion("1.1"); - /// <summary>The GitHub repository to check for updates.</summary> - public const string GitHubRepository = "Pathoschild/SMAPI"; + /// <summary>The maximum supported version of Stardew Valley.</summary> + public static ISemanticVersion MaximumGameVersion { get; } = new SemanticVersion("1.1.1"); + + /// <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 => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley"); + public static string DataPath { get; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley"); - /// <summary>The directory path where all saves are stored.</summary> - public static string SavesPath => Path.Combine(Constants.DataPath, "Saves"); + /// <summary>The directory path in which error logs should be stored.</summary> + public static string LogDir { get; } = Path.Combine(Constants.DataPath, "ErrorLogs"); - /// <summary>Whether the directory containing the current save's data exists on disk.</summary> - public static bool CurrentSavePathExists => Directory.Exists(Constants.RawSavePath); + /// <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 => Constants.CurrentSavePathExists ? Constants.RawSaveFolderName : ""; + public static string SaveFolderName => Constants.SavePathReady ? 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.CurrentSavePathExists ? Constants.RawSavePath : ""; + public static string CurrentSavePath => Constants.SavePathReady ? Path.Combine(Constants.SavesPath, Constants.GetSaveFolderName()) : ""; - /// <summary>Whether a player save has been loaded.</summary> - public static bool PlayerNull => !Game1.hasLoadedGame || Game1.player == null || string.IsNullOrEmpty(Game1.player.name); + /**** + ** Internal + ****/ + /// <summary>The GitHub repository to check for updates.</summary> + internal const string GitHubRepository = "Pathoschild/SMAPI"; - /// <summary>The path to the current assembly being executing.</summary> - public static string ExecutionPath => Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + /// <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 title of the SMAPI console window.</summary> - public static string ConsoleTitle => $"Stardew Modding API Console - Version {Constants.ApiVersion} - Mods Loaded: {Program.ModsLoaded}"; + /// <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>The directory path in which error logs should be stored.</summary> - public static string LogDir => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley", "ErrorLogs"); + /// <summary>The full path to the folder containing mods.</summary> + internal static string ModPath { get; } = Path.Combine(Constants.ExecutionPath, "Mods"); - /// <summary>The file path to the error log where the latest output should be saved.</summary> - public static string LogPath => Path.Combine(Constants.LogDir, "MODDED_ProgramLog.Log_LATEST.txt"); + /// <summary>Whether a player save has been loaded.</summary> + internal static bool IsSaveLoaded => Game1.hasLoadedGame && !string.IsNullOrEmpty(Game1.player.name); - /// <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 game's current semantic version.</summary> + internal static ISemanticVersion GameVersion { get; } = Constants.GetGameVersion(); - /// <summary>The file path for the SMAPI data file containing metadata about known mods.</summary> - internal static string ApiModMetadataPath => Path.Combine(Constants.ExecutionPath, $"{typeof(Program).Assembly.GetName().Name}.data.json"); + /// <summary>The target game platform.</summary> + internal static Platform TargetPlatform { get; } = +#if SMAPI_FOR_WINDOWS + Platform.Windows; +#else + Platform.Mono; +#endif /********* @@ -124,20 +138,76 @@ namespace StardewModdingAPI return new PlatformAssemblyMap(targetPlatform, removeAssemblyReferences, targetAssemblies); } - /// <summary>Get method rewriters which fix incompatible method calls in mod assemblies.</summary> - internal static IEnumerable<IMethodRewriter> GetMethodRewriters() + /// <summary>Get rewriters which detect or fix incompatible CIL instructions in mod assemblies.</summary> + internal static IEnumerable<IInstructionRewriter> GetRewriters() { - return new[] + return new IInstructionRewriter[] { - new SpriteBatchRewriter() + /**** + ** Finders throw an exception when incompatible code is found. + ****/ + // APIs removed in SMAPI 1.9 + new TypeFinder("StardewModdingAPI.Advanced.ConfigFile"), + new TypeFinder("StardewModdingAPI.Advanced.IConfigFile"), + new TypeFinder("StardewModdingAPI.Entities.SPlayer"), + new TypeFinder("StardewModdingAPI.Extensions"), + new TypeFinder("StardewModdingAPI.Inheritance.SGame"), + new TypeFinder("StardewModdingAPI.Inheritance.SObject"), + new TypeFinder("StardewModdingAPI.LogWriter"), + new TypeFinder("StardewModdingAPI.Manifest"), + new TypeFinder("StardewModdingAPI.Version"), + new EventFinder("StardewModdingAPI.Events.GraphicsEvents", "DrawDebug"), + new EventFinder("StardewModdingAPI.Events.GraphicsEvents", "DrawTick"), + new EventFinder("StardewModdingAPI.Events.GraphicsEvents", "OnPostRenderHudEventNoCheck"), + new EventFinder("StardewModdingAPI.Events.GraphicsEvents", "OnPostRenderGuiEventNoCheck"), + new EventFinder("StardewModdingAPI.Events.GraphicsEvents", "OnPreRenderHudEventNoCheck"), + new EventFinder("StardewModdingAPI.Events.GraphicsEvents", "OnPreRenderGuiEventNoCheck"), + + /**** + ** Rewriters change CIL as needed to fix incompatible code + ****/ + // crossplatform + new MethodParentRewriter(typeof(SpriteBatch), typeof(SpriteBatchWrapper), onlyIfPlatformChanged: true), + + // SMAPI 1.9 + new TypeReferenceRewriter("StardewModdingAPI.Inheritance.ItemStackChange", typeof(ItemStackChange)) }; } + /// <summary>Get game current version as it should be displayed to players.</summary> + /// <param name="version">The semantic game version.</param> + internal static ISemanticVersion GetGameDisplayVersion(ISemanticVersion version) + { + switch (version.ToString()) + { + case "1.1.1": + return new SemanticVersion(1, 11, 0); // The 1.1 patch was released as 1.11 + default: + return version; + } + } + /// <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 semantic version.</summary> + private static ISemanticVersion GetGameVersion() + { + // get raw version + // 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."); + string version = (string)field.GetValue(null); + + // get semantic version + if (version == "1.11") + version = "1.1.1"; // The 1.1 patch was released as 1.11, which means it's out of order for semantic version checks + return new SemanticVersion(version); + } } } diff --git a/src/StardewModdingAPI/Entities/SPlayer.cs b/src/StardewModdingAPI/Entities/SPlayer.cs deleted file mode 100644 index 66c7ba44..00000000 --- a/src/StardewModdingAPI/Entities/SPlayer.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System; -using System.Collections.Generic; -using StardewModdingAPI.Framework; -using StardewValley; - -namespace StardewModdingAPI.Entities -{ - /// <summary>Static class for integrating with the player.</summary> - [Obsolete("This API was never officially documented and will be removed soon.")] - public class SPlayer - { - /********* - ** Accessors - *********/ - /// <summary>Obsolete.</summary> - [Obsolete("Use " + nameof(Game1) + "." + nameof(Game1.getAllFarmers) + " instead")] - public static List<Farmer> AllFarmers - { - get - { - Program.DeprecationManager.Warn(nameof(SPlayer), "1.0", DeprecationLevel.Info); - return Game1.getAllFarmers(); - } - } - - /// <summary>Obsolete.</summary> - [Obsolete("Use " + nameof(Game1) + "." + nameof(Game1.player) + " instead")] - public static Farmer CurrentFarmer - { - get - { - Program.DeprecationManager.Warn(nameof(SPlayer), "1.0", DeprecationLevel.Info); - return Game1.player; - } - } - - /// <summary>Obsolete.</summary> - [Obsolete("Use " + nameof(Game1) + "." + nameof(Game1.player) + " instead")] - public static Farmer Player - { - get - { - Program.DeprecationManager.Warn(nameof(SPlayer), "1.0", DeprecationLevel.Info); - return Game1.player; - } - } - - /// <summary>Obsolete.</summary> - [Obsolete("Use " + nameof(Game1) + "." + nameof(Game1.player) + "." + nameof(Farmer.currentLocation) + " instead")] - public static GameLocation CurrentFarmerLocation - { - get - { - Program.DeprecationManager.Warn(nameof(SPlayer), "1.0", DeprecationLevel.Info); - return Game1.player.currentLocation; - } - } - } -}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Inheritance/ChangeType.cs b/src/StardewModdingAPI/Events/ChangeType.cs index 94eb33ed..4b207f08 100644 --- a/src/StardewModdingAPI/Inheritance/ChangeType.cs +++ b/src/StardewModdingAPI/Events/ChangeType.cs @@ -1,4 +1,4 @@ -namespace StardewModdingAPI.Inheritance +namespace StardewModdingAPI.Events { /// <summary>Indicates how an inventory item changed.</summary> public enum ChangeType diff --git a/src/StardewModdingAPI/Events/ContentEvents.cs b/src/StardewModdingAPI/Events/ContentEvents.cs new file mode 100644 index 00000000..9418673a --- /dev/null +++ b/src/StardewModdingAPI/Events/ContentEvents.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using StardewModdingAPI.Framework; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events raised when the game loads content.</summary> + [Obsolete("This is an undocumented experimental API and may change without warning.")] + public static class ContentEvents + { + /********* + ** Properties + *********/ + /// <summary>Tracks the installed mods.</summary> + private static ModRegistry ModRegistry; + + /// <summary>Encapsulates monitoring and logging.</summary> + private static IMonitor Monitor; + + /// <summary>The mods using the experimental API for which a warning has been raised.</summary> + private static readonly HashSet<string> WarnedMods = new HashSet<string>(); + + + /********* + ** Events + *********/ + /// <summary>Raised after the content language changes.</summary> + public static event EventHandler<EventArgsValueChanged<string>> AfterLocaleChanged; + + /// <summary>Raised when an XNB file is being read into the cache. Mods can change the data here before it's cached.</summary> + internal static event EventHandler<IContentEventHelper> AfterAssetLoaded; + + + /********* + ** Internal methods + *********/ + /// <summary>Injects types required for backwards compatibility.</summary> + /// <param name="modRegistry">Tracks the installed mods.</param> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void Shim(ModRegistry modRegistry, IMonitor monitor) + { + ContentEvents.ModRegistry = modRegistry; + ContentEvents.Monitor = monitor; + } + + /// <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)); + } + + /// <summary>Raise an <see cref="AfterAssetLoaded"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="contentHelper">Encapsulates access and changes to content being read from a data file.</param> + internal static void InvokeAfterAssetLoaded(IMonitor monitor, IContentEventHelper contentHelper) + { + if (ContentEvents.AfterAssetLoaded != null) + { + Delegate[] handlers = ContentEvents.AfterAssetLoaded.GetInvocationList(); + ContentEvents.RaiseDeprecationWarning(handlers); + monitor.SafelyRaiseGenericEvent($"{nameof(ContentEvents)}.{nameof(ContentEvents.AfterAssetLoaded)}", handlers, null, contentHelper); + } + } + + + /********* + ** Private methods + *********/ + /// <summary>Raise a 'experimental API' warning for each mod using the content API.</summary> + /// <param name="handlers">The event handlers.</param> + private static void RaiseDeprecationWarning(Delegate[] handlers) + { + foreach (Delegate handler in handlers) + { + string modName = ContentEvents.ModRegistry.GetModFrom(handler) ?? "An unknown mod"; + if (!ContentEvents.WarnedMods.Contains(modName)) + { + ContentEvents.WarnedMods.Add(modName); + ContentEvents.Monitor.Log($"{modName} used the undocumented and experimental content API, which may change or be removed without warning.", LogLevel.Warn); + } + } + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsCommand.cs b/src/StardewModdingAPI/Events/EventArgsCommand.cs index ddf644fb..bae13694 100644 --- a/src/StardewModdingAPI/Events/EventArgsCommand.cs +++ b/src/StardewModdingAPI/Events/EventArgsCommand.cs @@ -3,6 +3,7 @@ namespace StardewModdingAPI.Events { /// <summary>Event arguments for a <see cref="StardewModdingAPI.Command.CommandFired"/> event.</summary> + [Obsolete("Use " + nameof(IModHelper) + "." + nameof(IModHelper.ConsoleCommands))] public class EventArgsCommand : EventArgs { /********* diff --git a/src/StardewModdingAPI/Events/EventArgsFarmerChanged.cs b/src/StardewModdingAPI/Events/EventArgsFarmerChanged.cs index 273f9d25..699d90be 100644 --- a/src/StardewModdingAPI/Events/EventArgsFarmerChanged.cs +++ b/src/StardewModdingAPI/Events/EventArgsFarmerChanged.cs @@ -1,5 +1,5 @@ using System; -using StardewValley; +using SFarmer = StardewValley.Farmer; namespace StardewModdingAPI.Events { @@ -10,10 +10,10 @@ namespace StardewModdingAPI.Events ** Accessors *********/ /// <summary>The previous player character.</summary> - public Farmer NewFarmer { get; } + public SFarmer NewFarmer { get; } /// <summary>The new player character.</summary> - public Farmer PriorFarmer { get; } + public SFarmer PriorFarmer { get; } /********* @@ -22,7 +22,7 @@ namespace StardewModdingAPI.Events /// <summary>Construct an instance.</summary> /// <param name="priorFarmer">The previous player character.</param> /// <param name="newFarmer">The new player character.</param> - public EventArgsFarmerChanged(Farmer priorFarmer, Farmer newFarmer) + public EventArgsFarmerChanged(SFarmer priorFarmer, SFarmer newFarmer) { this.PriorFarmer = priorFarmer; this.NewFarmer = newFarmer; diff --git a/src/StardewModdingAPI/Events/EventArgsIntChanged.cs b/src/StardewModdingAPI/Events/EventArgsIntChanged.cs index 31079730..0c742d12 100644 --- a/src/StardewModdingAPI/Events/EventArgsIntChanged.cs +++ b/src/StardewModdingAPI/Events/EventArgsIntChanged.cs @@ -9,11 +9,10 @@ namespace StardewModdingAPI.Events ** Accessors *********/ /// <summary>The previous value.</summary> - public int NewInt { get; } - - /// <summary>The current value.</summary> public int PriorInt { get; } + /// <summary>The current value.</summary> + public int NewInt { get; } /********* ** Public methods diff --git a/src/StardewModdingAPI/Events/EventArgsInventoryChanged.cs b/src/StardewModdingAPI/Events/EventArgsInventoryChanged.cs index 40c77419..11cbcedf 100644 --- a/src/StardewModdingAPI/Events/EventArgsInventoryChanged.cs +++ b/src/StardewModdingAPI/Events/EventArgsInventoryChanged.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using StardewModdingAPI.Inheritance; using StardewValley; namespace StardewModdingAPI.Events 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/GraphicsEvents.cs b/src/StardewModdingAPI/Events/GraphicsEvents.cs index 5f4feeac..25b976f1 100644 --- a/src/StardewModdingAPI/Events/GraphicsEvents.cs +++ b/src/StardewModdingAPI/Events/GraphicsEvents.cs @@ -15,24 +15,13 @@ namespace StardewModdingAPI.Events /// <summary>Raised after the game window is resized.</summary> public static event EventHandler Resize; - /// <summary>Raised when drawing debug information to the screen (when <see cref="StardewModdingAPI.Inheritance.SGame.Debug"/> is true). This is called after the sprite batch is begun. If you just want to add debug info, use <see cref="StardewModdingAPI.Inheritance.SGame.DebugMessageQueue" /> in your update loop.</summary> - public static event EventHandler DrawDebug; - - /// <summary>Obsolete.</summary> - [Obsolete("Use the other Pre/Post render events instead.")] - public static event EventHandler DrawTick; - - /// <summary>Obsolete.</summary> - [Obsolete("Use the other Pre/Post render events instead. All of them will automatically be drawn into the render target if needed.")] - public static event EventHandler DrawInRenderTargetTick; - /**** ** Main render events ****/ - /// <summary>Raised before drawing everything to the screen during a draw loop.</summary> + /// <summary>Raised before drawing the world to the screen.</summary> public static event EventHandler OnPreRenderEvent; - /// <summary>Raised after drawing everything to the screen during a draw loop.</summary> + /// <summary>Raised after drawing the world to the screen.</summary> public static event EventHandler OnPostRenderEvent; /**** @@ -41,30 +30,18 @@ namespace StardewModdingAPI.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>Equivalent to <see cref="OnPreRenderHudEvent"/>, but invoked even if the HUD isn't available.</summary> - public static event EventHandler OnPreRenderHudEventNoCheck; - /// <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; - /// <summary>Equivalent to <see cref="OnPostRenderHudEvent"/>, but invoked even if the HUD isn't available.</summary> - public static event EventHandler OnPostRenderHudEventNoCheck; - /**** ** 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>Equivalent to <see cref="OnPreRenderGuiEvent"/>, but invoked even if there's no menu being drawn.</summary> - public static event EventHandler OnPreRenderGuiEventNoCheck; - /// <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; - /// <summary>Equivalent to <see cref="OnPreRenderGuiEvent"/>, but invoked even if there's no menu being drawn.</summary> - public static event EventHandler OnPostRenderGuiEventNoCheck; - /********* ** Internal methods @@ -81,29 +58,6 @@ namespace StardewModdingAPI.Events monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.Resize)}", GraphicsEvents.Resize?.GetInvocationList(), sender, e); } - /// <summary>Raise a <see cref="DrawDebug"/> event.</summary> - /// <param name="monitor">Encapsulates monitoring and logging.</param> - internal static void InvokeDrawDebug(IMonitor monitor) - { - monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.DrawDebug)}", GraphicsEvents.DrawDebug?.GetInvocationList()); - } - - /// <summary>Raise a <see cref="DrawTick"/> event.</summary> - /// <param name="monitor">Encapsulates logging and monitoring.</param> - [Obsolete("Should not be used.")] - public static void InvokeDrawTick(IMonitor monitor) - { - monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.DrawTick)}", GraphicsEvents.DrawTick?.GetInvocationList()); - } - - /// <summary>Raise a <see cref="DrawInRenderTargetTick"/> event.</summary> - /// <param name="monitor">Encapsulates monitoring and logging.</param> - [Obsolete("Should not be used.")] - public static void InvokeDrawInRenderTargetTick(IMonitor monitor) - { - monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.DrawInRenderTargetTick)}", GraphicsEvents.DrawInRenderTargetTick?.GetInvocationList()); - } - /**** ** Main render events ****/ @@ -121,8 +75,14 @@ namespace StardewModdingAPI.Events 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; + } + /**** - ** HUD events + ** GUI events ****/ /// <summary>Raise an <see cref="OnPreRenderGuiEvent"/> event.</summary> /// <param name="monitor">Encapsulates monitoring and logging.</param> @@ -131,13 +91,6 @@ namespace StardewModdingAPI.Events monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.OnPreRenderGuiEvent)}", GraphicsEvents.OnPreRenderGuiEvent?.GetInvocationList()); } - /// <summary>Raise an <see cref="OnPreRenderGuiEventNoCheck"/> event.</summary> - /// <param name="monitor">Encapsulates monitoring and logging.</param> - internal static void InvokeOnPreRenderGuiEventNoCheck(IMonitor monitor) - { - monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.OnPreRenderGuiEventNoCheck)}", GraphicsEvents.OnPreRenderGuiEventNoCheck?.GetInvocationList()); - } - /// <summary>Raise an <see cref="OnPostRenderGuiEvent"/> event.</summary> /// <param name="monitor">Encapsulates monitoring and logging.</param> internal static void InvokeOnPostRenderGuiEvent(IMonitor monitor) @@ -145,15 +98,8 @@ namespace StardewModdingAPI.Events monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.OnPostRenderGuiEvent)}", GraphicsEvents.OnPostRenderGuiEvent?.GetInvocationList()); } - /// <summary>Raise an <see cref="OnPostRenderGuiEventNoCheck"/> event.</summary> - /// <param name="monitor">Encapsulates monitoring and logging.</param> - internal static void InvokeOnPostRenderGuiEventNoCheck(IMonitor monitor) - { - monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.OnPostRenderGuiEventNoCheck)}", GraphicsEvents.OnPostRenderGuiEventNoCheck?.GetInvocationList()); - } - /**** - ** GUI events + ** HUD events ****/ /// <summary>Raise an <see cref="OnPreRenderHudEvent"/> event.</summary> /// <param name="monitor">Encapsulates monitoring and logging.</param> @@ -162,25 +108,11 @@ namespace StardewModdingAPI.Events monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.OnPreRenderHudEvent)}", GraphicsEvents.OnPreRenderHudEvent?.GetInvocationList()); } - /// <summary>Raise an <see cref="OnPreRenderHudEventNoCheck"/> event.</summary> - /// <param name="monitor">Encapsulates monitoring and logging.</param> - internal static void InvokeOnPreRenderHudEventNoCheck(IMonitor monitor) - { - monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.OnPreRenderHudEventNoCheck)}", GraphicsEvents.OnPreRenderHudEventNoCheck?.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()); } - - /// <summary>Raise an <see cref="OnPostRenderHudEventNoCheck"/> event.</summary> - /// <param name="monitor">Encapsulates monitoring and logging.</param> - internal static void InvokeOnPostRenderHudEventNoCheck(IMonitor monitor) - { - monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.OnPostRenderHudEventNoCheck)}", GraphicsEvents.OnPostRenderHudEventNoCheck?.GetInvocationList()); - } } } diff --git a/src/StardewModdingAPI/Inheritance/ItemStackChange.cs b/src/StardewModdingAPI/Events/ItemStackChange.cs index 8d15b894..f9ae6df6 100644 --- a/src/StardewModdingAPI/Inheritance/ItemStackChange.cs +++ b/src/StardewModdingAPI/Events/ItemStackChange.cs @@ -1,6 +1,6 @@ -using StardewValley; +using StardewValley; -namespace StardewModdingAPI.Inheritance +namespace StardewModdingAPI.Events { /// <summary>Represents an inventory slot that changed.</summary> public class ItemStackChange diff --git a/src/StardewModdingAPI/Events/PlayerEvents.cs b/src/StardewModdingAPI/Events/PlayerEvents.cs index dd3ff220..b02ebfec 100644 --- a/src/StardewModdingAPI/Events/PlayerEvents.cs +++ b/src/StardewModdingAPI/Events/PlayerEvents.cs @@ -2,8 +2,8 @@ using System.Collections.Generic; using System.Linq; using StardewModdingAPI.Framework; -using StardewModdingAPI.Inheritance; using StardewValley; +using SFarmer = StardewValley.Farmer; namespace StardewModdingAPI.Events { @@ -11,6 +11,13 @@ namespace StardewModdingAPI.Events public static class PlayerEvents { /********* + ** Properties + *********/ + /// <summary>Manages deprecation warnings.</summary> + private static DeprecationManager DeprecationManager; + + + /********* ** Events *********/ /// <summary>Raised after the player loads a saved game.</summary> @@ -31,6 +38,13 @@ namespace StardewModdingAPI.Events /********* ** Internal methods *********/ + /// <summary>Injects types required for backwards compatibility.</summary> + /// <param name="deprecationManager">Manages deprecation warnings.</param> + internal static void Shim(DeprecationManager deprecationManager) + { + PlayerEvents.DeprecationManager = deprecationManager; + } + /// <summary>Raise a <see cref="LoadedGame"/> event.</summary> /// <param name="monitor">Encapsulates monitoring and logging.</param> /// <param name="loaded">Whether the save has been loaded. This is always true.</param> @@ -42,7 +56,7 @@ namespace StardewModdingAPI.Events string name = $"{nameof(PlayerEvents)}.{nameof(PlayerEvents.LoadedGame)}"; Delegate[] handlers = PlayerEvents.LoadedGame.GetInvocationList(); - Program.DeprecationManager.WarnForEvent(handlers, name, "1.6", DeprecationLevel.Notice); + PlayerEvents.DeprecationManager.WarnForEvent(handlers, name, "1.6", DeprecationLevel.Info); monitor.SafelyRaiseGenericEvent(name, handlers, null, loaded); } @@ -50,7 +64,7 @@ namespace StardewModdingAPI.Events /// <param name="monitor">Encapsulates monitoring and logging.</param> /// <param name="priorFarmer">The previous player character.</param> /// <param name="newFarmer">The new player character.</param> - internal static void InvokeFarmerChanged(IMonitor monitor, Farmer priorFarmer, Farmer newFarmer) + internal static void InvokeFarmerChanged(IMonitor monitor, SFarmer priorFarmer, SFarmer newFarmer) { if (PlayerEvents.FarmerChanged == null) return; @@ -58,7 +72,7 @@ namespace StardewModdingAPI.Events string name = $"{nameof(PlayerEvents)}.{nameof(PlayerEvents.FarmerChanged)}"; Delegate[] handlers = PlayerEvents.FarmerChanged.GetInvocationList(); - Program.DeprecationManager.WarnForEvent(handlers, name, "1.6", DeprecationLevel.Notice); + PlayerEvents.DeprecationManager.WarnForEvent(handlers, name, "1.6", DeprecationLevel.Info); monitor.SafelyRaiseGenericEvent(name, handlers, null, new EventArgsFarmerChanged(priorFarmer, newFarmer)); } diff --git a/src/StardewModdingAPI/Events/SaveEvents.cs b/src/StardewModdingAPI/Events/SaveEvents.cs index 2921003a..50e6d729 100644 --- a/src/StardewModdingAPI/Events/SaveEvents.cs +++ b/src/StardewModdingAPI/Events/SaveEvents.cs @@ -18,6 +18,9 @@ namespace StardewModdingAPI.Events /// <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 @@ -42,5 +45,12 @@ namespace StardewModdingAPI.Events { 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 index dedd7e77..3f06a46b 100644 --- a/src/StardewModdingAPI/Events/TimeEvents.cs +++ b/src/StardewModdingAPI/Events/TimeEvents.cs @@ -7,12 +7,22 @@ namespace StardewModdingAPI.Events public static class TimeEvents { /********* + ** Properties + *********/ + /// <summary>Manages deprecation warnings.</summary> + private static DeprecationManager DeprecationManager; + + + /********* ** 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; - /// <summary>Raised after the day-of-month value changes, including when loading a save (unlike <see cref="OnNewDay"/>).</summary> + /// <summary>Raised after the day-of-month value changes, including when loading a save. This may happen before save; in most cases you should use <see cref="AfterDayStarted"/> instead.</summary> public static event EventHandler<EventArgsIntChanged> DayOfMonthChanged; /// <summary>Raised after the year value changes.</summary> @@ -22,13 +32,27 @@ namespace StardewModdingAPI.Events public static event EventHandler<EventArgsStringChanged> SeasonOfYearChanged; /// <summary>Raised when the player is transitioning to a new day and the game is performing its day update logic. This event is triggered twice: once after the game starts transitioning, and again after it finishes.</summary> - [Obsolete("Use " + nameof(TimeEvents) + "." + nameof(DayOfMonthChanged) + " or " + nameof(SaveEvents) + " instead")] + [Obsolete("Use " + nameof(TimeEvents) + "." + nameof(TimeEvents.AfterDayStarted) + " or " + nameof(SaveEvents) + " instead")] public static event EventHandler<EventArgsNewDay> OnNewDay; /********* ** Internal methods *********/ + /// <summary>Injects types required for backwards compatibility.</summary> + /// <param name="deprecationManager">Manages deprecation warnings.</param> + internal static void Shim(DeprecationManager deprecationManager) + { + TimeEvents.DeprecationManager = deprecationManager; + } + + /// <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="InvokeDayOfMonthChanged"/> 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> @@ -78,7 +102,7 @@ namespace StardewModdingAPI.Events string name = $"{nameof(TimeEvents)}.{nameof(TimeEvents.OnNewDay)}"; Delegate[] handlers = TimeEvents.OnNewDay.GetInvocationList(); - Program.DeprecationManager.WarnForEvent(handlers, name, "1.6", DeprecationLevel.Notice); + TimeEvents.DeprecationManager.WarnForEvent(handlers, name, "1.6", DeprecationLevel.Info); monitor.SafelyRaiseGenericEvent(name, handlers, null, new EventArgsNewDay(priorDay, newDay, isTransitioning)); } } diff --git a/src/StardewModdingAPI/Extensions.cs b/src/StardewModdingAPI/Extensions.cs deleted file mode 100644 index 0e9dbbf7..00000000 --- a/src/StardewModdingAPI/Extensions.cs +++ /dev/null @@ -1,194 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Input; -using StardewModdingAPI.Framework; - -namespace StardewModdingAPI -{ - /// <summary>Provides general utility extensions.</summary> - public static class Extensions - { - /********* - ** Properties - *********/ - /// <summary>The backing field for <see cref="Random"/>.</summary> - private static readonly Random _random = new Random(); - - - /********* - ** Accessors - *********/ - /// <summary>A pseudo-random number generator.</summary> - public static Random Random - { - get - { - Program.DeprecationManager.Warn($"{nameof(Extensions)}.{nameof(Extensions.Random)}", "1.0", DeprecationLevel.PendingRemoval); - return Extensions._random; - } - } - - - /********* - ** Public methods - *********/ - /// <summary>Get whether the given key is currently being pressed.</summary> - /// <param name="key">The key to check.</param> - public static bool IsKeyDown(this Keys key) - { - Program.DeprecationManager.Warn($"{nameof(Extensions)}.{nameof(Extensions.IsKeyDown)}", "1.0", DeprecationLevel.PendingRemoval); - - return Keyboard.GetState().IsKeyDown(key); - } - - /// <summary>Get a random color.</summary> - public static Color RandomColour() - { - Program.DeprecationManager.Warn($"{nameof(Extensions)}.{nameof(Extensions.RandomColour)}", "1.0", DeprecationLevel.PendingRemoval); - - return new Color(Extensions.Random.Next(0, 255), Extensions.Random.Next(0, 255), Extensions.Random.Next(0, 255)); - } - - /// <summary>Concatenate an enumeration into a delimiter-separated string.</summary> - /// <param name="ienum">The values to concatenate.</param> - /// <param name="split">The value separator.</param> - [Obsolete("The usage of ToSingular has changed. Please update your call to use ToSingular<T>")] - public static string ToSingular(this IEnumerable ienum, string split = ", ") - { - Program.DeprecationManager.Warn($"{nameof(Extensions)}.{nameof(Extensions.ToSingular)}", "0.39.3", DeprecationLevel.PendingRemoval); - return ""; - } - - /// <summary>Concatenate an enumeration into a delimiter-separated string.</summary> - /// <typeparam name="T">The enumerated value type.</typeparam> - /// <param name="ienum">The values to concatenate.</param> - /// <param name="split">The value separator.</param> - public static string ToSingular<T>(this IEnumerable<T> ienum, string split = ", ") - { - Program.DeprecationManager.Warn($"{nameof(Extensions)}.{nameof(Extensions.ToSingular)}", "1.0", DeprecationLevel.PendingRemoval); - - //Apparently Keys[] won't split normally :l - if (typeof(T) == typeof(Keys)) - { - return string.Join(split, ienum.ToArray()); - } - return string.Join(split, ienum); - } - - /// <summary>Get whether the value can be parsed as a number.</summary> - /// <param name="o">The value.</param> - public static bool IsInt32(this object o) - { - Program.DeprecationManager.Warn($"{nameof(Extensions)}.{nameof(Extensions.IsInt32)}", "1.0", DeprecationLevel.PendingRemoval); - - int i; - return int.TryParse(o.ToString(), out i); - } - - /// <summary>Get the numeric representation of a value.</summary> - /// <param name="o">The value.</param> - public static int AsInt32(this object o) - { - Program.DeprecationManager.Warn($"{nameof(Extensions)}.{nameof(Extensions.AsInt32)}", "1.0", DeprecationLevel.PendingRemoval); - - return int.Parse(o.ToString()); - } - - /// <summary>Get whether the value can be parsed as a boolean.</summary> - /// <param name="o">The value.</param> - public static bool IsBool(this object o) - { - Program.DeprecationManager.Warn($"{nameof(Extensions)}.{nameof(Extensions.IsBool)}", "1.0", DeprecationLevel.PendingRemoval); - - bool b; - return bool.TryParse(o.ToString(), out b); - } - - /// <summary>Get the boolean representation of a value.</summary> - /// <param name="o">The value.</param> - public static bool AsBool(this object o) - { - Program.DeprecationManager.Warn($"{nameof(Extensions)}.{nameof(Extensions.AsBool)}", "1.0", DeprecationLevel.PendingRemoval); - - return bool.Parse(o.ToString()); - } - - /// <summary>Get a list hash calculated from the hashes of the values it contains.</summary> - /// <param name="enumerable">The values to hash.</param> - public static int GetHash(this IEnumerable enumerable) - { - Program.DeprecationManager.Warn($"{nameof(Extensions)}.{nameof(Extensions.GetHash)}", "1.0", DeprecationLevel.PendingRemoval); - - var hash = 0; - foreach (var v in enumerable) - hash ^= v.GetHashCode(); - return hash; - } - - /// <summary>Cast a value to the given type. This returns <c>null</c> if the value can't be cast.</summary> - /// <typeparam name="T">The type to which to cast.</typeparam> - /// <param name="o">The value.</param> - public static T Cast<T>(this object o) where T : class - { - Program.DeprecationManager.Warn($"{nameof(Extensions)}.{nameof(Extensions.Cast)}", "1.0", DeprecationLevel.PendingRemoval); - - return o as T; - } - - /// <summary>Get all private types on an object.</summary> - /// <param name="o">The object to scan.</param> - public static FieldInfo[] GetPrivateFields(this object o) - { - Program.DeprecationManager.Warn($"{nameof(Extensions)}.{nameof(Extensions.GetPrivateFields)}", "1.0", DeprecationLevel.PendingRemoval); - return o.GetType().GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Static); - } - - /// <summary>Get metadata for a private field.</summary> - /// <param name="t">The type to scan.</param> - /// <param name="name">The name of the field to find.</param> - public static FieldInfo GetBaseFieldInfo(this Type t, string name) - { - Program.DeprecationManager.Warn($"{nameof(Extensions)}.{nameof(Extensions.GetBaseFieldValue)}", "1.0", DeprecationLevel.PendingRemoval); - return t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Static); - } - - /// <summary>Get the value of a private field.</summary> - /// <param name="t">The type to scan.</param> - /// <param name="o">The instance for which to get a value.</param> - /// <param name="name">The name of the field to find.</param> - public static T GetBaseFieldValue<T>(this Type t, object o, string name) where T : class - { - Program.DeprecationManager.Warn($"{nameof(Extensions)}.{nameof(Extensions.GetBaseFieldValue)}", "1.0", DeprecationLevel.PendingRemoval); - return t.GetBaseFieldInfo(name).GetValue(o) as T; - } - - /// <summary>Set the value of a private field.</summary> - /// <param name="t">The type to scan.</param> - /// <param name="o">The instance for which to set a value.</param> - /// <param name="name">The name of the field to find.</param> - /// <param name="newValue">The value to set.</param> - public static void SetBaseFieldValue<T>(this Type t, object o, string name, object newValue) where T : class - { - Program.DeprecationManager.Warn($"{nameof(Extensions)}.{nameof(Extensions.SetBaseFieldValue)}", "1.0", DeprecationLevel.PendingRemoval); - t.GetBaseFieldInfo(name).SetValue(o, newValue as T); - } - - /// <summary>Get a copy of the string with only alphanumeric characters. (Numbers are not removed, despite the name.)</summary> - /// <param name="st">The string to copy.</param> - public static string RemoveNumerics(this string st) - { - Program.DeprecationManager.Warn($"{nameof(Extensions)}.{nameof(Extensions.RemoveNumerics)}", "1.0", DeprecationLevel.PendingRemoval); - var s = st; - foreach (var c in s) - { - if (!char.IsLetterOrDigit(c)) - s = s.Replace(c.ToString(), ""); - } - return s; - } - } -}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Framework/AssemblyLoader.cs b/src/StardewModdingAPI/Framework/AssemblyLoader.cs index 123211b9..f6fe89f5 100644 --- a/src/StardewModdingAPI/Framework/AssemblyLoader.cs +++ b/src/StardewModdingAPI/Framework/AssemblyLoader.cs @@ -5,7 +5,6 @@ using System.Linq; using System.Reflection; using Mono.Cecil; using Mono.Cecil.Cil; -using Mono.Cecil.Rocks; using StardewModdingAPI.AssemblyRewriters; namespace StardewModdingAPI.Framework @@ -55,14 +54,17 @@ namespace StardewModdingAPI.Framework /// <summary>Preprocess and load an assembly.</summary> /// <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> - public Assembly Load(string assemblyPath) + /// <exception cref="IncompatibleInstructionException">An incompatible CIL instruction was found while rewriting the assembly.</exception> + public Assembly Load(string assemblyPath, bool assumeCompatible) { // get referenced local assemblies AssemblyParseResult[] assemblies; { AssemblyDefinitionResolver resolver = new AssemblyDefinitionResolver(); - assemblies = this.GetReferencedLocalAssemblies(new FileInfo(assemblyPath), new HashSet<string>(), resolver).ToArray(); + 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(); if (!assemblies.Any()) throw new InvalidOperationException($"Could not load '{assemblyPath}' because it doesn't exist."); resolver.Add(assemblies.Select(p => p.Definition).ToArray()); @@ -72,10 +74,10 @@ namespace StardewModdingAPI.Framework Assembly lastAssembly = null; foreach (AssemblyParseResult assembly in assemblies) { - this.Monitor.Log($"Loading {assembly.File.Name}...", LogLevel.Trace); - bool changed = this.RewriteAssembly(assembly.Definition); + bool changed = this.RewriteAssembly(assembly.Definition, assumeCompatible); if (changed) { + this.Monitor.Log($"Loading {assembly.File.Name} (rewritten in memory)...", LogLevel.Trace); using (MemoryStream outStream = new MemoryStream()) { assembly.Definition.Write(outStream); @@ -84,7 +86,10 @@ namespace StardewModdingAPI.Framework } } else + { + this.Monitor.Log($"Loading {assembly.File.Name}...", LogLevel.Trace); lastAssembly = Assembly.UnsafeLoadFrom(assembly.File.FullName); + } } // last assembly loaded is the root @@ -116,18 +121,16 @@ namespace StardewModdingAPI.Framework ****/ /// <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="visitedAssemblyPaths">The assembly paths that should be skipped.</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> visitedAssemblyPaths, IAssemblyResolver assemblyResolver) + 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 (visitedAssemblyPaths.Contains(file.FullName)) - yield break; // already visited if (!file.Exists) yield break; // not a local assembly - visitedAssemblyPaths.Add(file.FullName); // read assembly byte[] assemblyBytes = File.ReadAllBytes(file.FullName); @@ -135,11 +138,16 @@ namespace StardewModdingAPI.Framework 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 break; + 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, visitedAssemblyPaths, assemblyResolver)) + foreach (AssemblyParseResult result in this.GetReferencedLocalAssemblies(dependencyFile, visitedAssemblyNames, assemblyResolver)) yield return result; } @@ -152,62 +160,88 @@ namespace StardewModdingAPI.Framework ****/ /// <summary>Rewrite the types referenced by an assembly.</summary> /// <param name="assembly">The assembly to rewrite.</param> + /// <param name="assumeCompatible">Assume the mod is compatible, even if incompatible code is detected.</param> /// <returns>Returns whether the assembly was modified.</returns> - private bool RewriteAssembly(AssemblyDefinition assembly) + /// <exception cref="IncompatibleInstructionException">An incompatible CIL instruction was found while rewriting the assembly.</exception> + private bool RewriteAssembly(AssemblyDefinition assembly, bool assumeCompatible) { - ModuleDefinition module = assembly.Modules.Single(); // technically an assembly can have multiple modules, but none of the build tools (including MSBuild) support it; simplify by assuming one module + ModuleDefinition module = assembly.MainModule; + HashSet<string> loggedMessages = new HashSet<string>(); - // remove old assembly references - bool shouldRewrite = false; + // 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)) { - shouldRewrite = true; + this.LogOnce(this.Monitor, loggedMessages, $"Rewriting {assembly.Name.Name} for OS..."); + platformChanged = true; module.AssemblyReferences.RemoveAt(i); i--; } } - if (!shouldRewrite) - return false; - - // add target assembly references - foreach (AssemblyNameReference target in this.AssemblyMap.TargetReferences.Values) - module.AssemblyReferences.Add(target); + 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); + // rewrite type scopes to use target assemblies + IEnumerable<TypeReference> typeReferences = module.GetTypeReferences().OrderBy(p => p.FullName); + foreach (TypeReference type in typeReferences) + this.ChangeTypeScope(type); + } - // rewrite incompatible methods - IMethodRewriter[] methodRewriters = Constants.GetMethodRewriters().ToArray(); + // find (and optionally rewrite) incompatible instructions + bool anyRewritten = false; + IInstructionRewriter[] rewriters = Constants.GetRewriters().ToArray(); foreach (MethodDefinition method in this.GetMethods(module)) { - // skip methods with no rewritable method - bool hasMethodToRewrite = method.Body.Instructions.Any(op => (op.OpCode == OpCodes.Call || op.OpCode == OpCodes.Callvirt) && methodRewriters.Any(rewriter => rewriter.ShouldRewrite((MethodReference)op.Operand))); - if (!hasMethodToRewrite) - continue; + // check method definition + foreach (IInstructionRewriter rewriter in rewriters) + { + try + { + if (rewriter.Rewrite(module, method, this.AssemblyMap, platformChanged)) + { + this.LogOnce(this.Monitor, loggedMessages, $"Rewrote {assembly.Name.Name} to fix {rewriter.NounPhrase}..."); + anyRewritten = true; + } + } + catch (IncompatibleInstructionException) + { + if (!assumeCompatible) + throw new IncompatibleInstructionException(rewriter.NounPhrase, $"Found an incompatible CIL instruction ({rewriter.NounPhrase}) while loading assembly {assembly.Name.Name}."); + this.LogOnce(this.Monitor, loggedMessages, $"Found an incompatible CIL instruction ({rewriter.NounPhrase}) while loading assembly {assembly.Name.Name}, but SMAPI is configured to allow it anyway. The mod may crash or behave unexpectedly.", LogLevel.Warn); + } + } - // rewrite method references - method.Body.SimplifyMacros(); + // check CIL instructions ILProcessor cil = method.Body.GetILProcessor(); - Instruction[] instructions = cil.Body.Instructions.ToArray(); - foreach (Instruction op in instructions) + foreach (Instruction instruction in cil.Body.Instructions.ToArray()) { - if (op.OpCode == OpCodes.Call || op.OpCode == OpCodes.Callvirt) + foreach (IInstructionRewriter rewriter in rewriters) { - IMethodRewriter rewriter = methodRewriters.FirstOrDefault(p => p.ShouldRewrite((MethodReference)op.Operand)); - if (rewriter != null) + try { - MethodReference methodRef = (MethodReference)op.Operand; - rewriter.Rewrite(module, cil, op, methodRef, this.AssemblyMap); + if (rewriter.Rewrite(module, cil, instruction, this.AssemblyMap, platformChanged)) + { + this.LogOnce(this.Monitor, loggedMessages, $"Rewrote {assembly.Name.Name} to fix {rewriter.NounPhrase}..."); + anyRewritten = true; + } + } + catch (IncompatibleInstructionException) + { + if (!assumeCompatible) + throw new IncompatibleInstructionException(rewriter.NounPhrase, $"Found an incompatible CIL instruction ({rewriter.NounPhrase}) while loading assembly {assembly.Name.Name}."); + this.LogOnce(this.Monitor, loggedMessages, $"Found an incompatible CIL instruction ({rewriter.NounPhrase}) while loading assembly {assembly.Name.Name}, but SMAPI is configured to allow it anyway. The mod may crash or behave unexpectedly.", LogLevel.Warn); } } } - method.Body.OptimizeMacros(); } - return true; + + return platformChanged || anyRewritten; } /// <summary>Get the correct reference to use for compatibility with the current platform.</summary> @@ -240,5 +274,19 @@ namespace StardewModdingAPI.Framework select method ); } + + /// <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> + private void LogOnce(IMonitor monitor, HashSet<string> hash, string message, LogLevel level = LogLevel.Trace) + { + if (!hash.Contains(message)) + { + this.Monitor.Log(message, level); + hash.Add(message); + } + } } } 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/CommandHelper.cs b/src/StardewModdingAPI/Framework/CommandHelper.cs new file mode 100644 index 00000000..2e9dea8e --- /dev/null +++ b/src/StardewModdingAPI/Framework/CommandHelper.cs @@ -0,0 +1,53 @@ +using System; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Provides an API for managing console commands.</summary> + internal class CommandHelper : 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="modName">The friendly mod name for this instance.</param> + /// <param name="commandManager">Manages console commands.</param> + public CommandHelper(string modName, CommandManager commandManager) + { + 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); + } + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Framework/CommandManager.cs b/src/StardewModdingAPI/Framework/CommandManager.cs new file mode 100644 index 00000000..9af3d27a --- /dev/null +++ b/src/StardewModdingAPI/Framework/CommandManager.cs @@ -0,0 +1,117 @@ +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); + Command command; + this.Commands.TryGetValue(name, out 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 + Command command; + if (this.Commands.TryGetValue(name, out 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; + } + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Framework/Content/ContentEventData.cs b/src/StardewModdingAPI/Framework/Content/ContentEventData.cs new file mode 100644 index 00000000..1a1779d4 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Content/ContentEventData.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework.Graphics; + +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 ContentEventData<TValue> : EventArgs, IContentEventData<TValue> + { + /********* + ** 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="IsAssetName"/> to compare with a known path.</summary> + public string AssetName { get; } + + /// <summary>The content data being read.</summary> + public TValue Data { get; protected set; } + + /// <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="data">The content data being read.</param> + /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + public ContentEventData(string locale, string assetName, TValue data, Func<string, string> getNormalisedPath) + : this(locale, assetName, data, data.GetType(), getNormalisedPath) { } + + /// <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="dataType">The content data type being read.</param> + /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + public ContentEventData(string locale, string assetName, TValue data, Type dataType, Func<string, string> getNormalisedPath) + { + this.Locale = locale; + this.AssetName = assetName; + this.Data = data; + this.DataType = dataType; + 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 IsAssetName(string path) + { + path = this.GetNormalisedPath(path); + return this.AssetName.Equals(path, StringComparison.InvariantCultureIgnoreCase); + } + + /// <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; + } + + + /********* + ** 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/Content/ContentEventHelper.cs b/src/StardewModdingAPI/Framework/Content/ContentEventHelper.cs new file mode 100644 index 00000000..9bf1ea17 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Content/ContentEventHelper.cs @@ -0,0 +1,47 @@ +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 ContentEventHelper : ContentEventData<object>, IContentEventHelper + { + /********* + ** 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 ContentEventHelper(string locale, string assetName, object data, Func<string, string> getNormalisedPath) + : base(locale, 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 IContentEventHelperForDictionary<TKey, TValue> AsDictionary<TKey, TValue>() + { + return new ContentEventHelperForDictionary<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 IContentEventHelperForImage AsImage() + { + return new ContentEventHelperForImage(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/ContentEventHelperForDictionary.cs b/src/StardewModdingAPI/Framework/Content/ContentEventHelperForDictionary.cs new file mode 100644 index 00000000..26f059e4 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Content/ContentEventHelperForDictionary.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 ContentEventHelperForDictionary<TKey, TValue> : ContentEventData<IDictionary<TKey, TValue>>, IContentEventHelperForDictionary<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 ContentEventHelperForDictionary(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/ContentEventHelperForImage.cs b/src/StardewModdingAPI/Framework/Content/ContentEventHelperForImage.cs new file mode 100644 index 00000000..da30590b --- /dev/null +++ b/src/StardewModdingAPI/Framework/Content/ContentEventHelperForImage.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 ContentEventHelperForImage : ContentEventData<Texture2D>, IContentEventHelperForImage + { + /********* + ** 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 ContentEventHelperForImage(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/DeprecationManager.cs b/src/StardewModdingAPI/Framework/DeprecationManager.cs index 8c32ba6a..e44cd369 100644 --- a/src/StardewModdingAPI/Framework/DeprecationManager.cs +++ b/src/StardewModdingAPI/Framework/DeprecationManager.cs @@ -70,7 +70,7 @@ namespace StardewModdingAPI.Framework break; case DeprecationLevel.Info: - this.Monitor.Log(message, LogLevel.Info); + this.Monitor.Log(message, LogLevel.Warn); break; case DeprecationLevel.PendingRemoval: diff --git a/src/StardewModdingAPI/Framework/InternalExtensions.cs b/src/StardewModdingAPI/Framework/InternalExtensions.cs index c4bd2d35..4ca79518 100644 --- a/src/StardewModdingAPI/Framework/InternalExtensions.cs +++ b/src/StardewModdingAPI/Framework/InternalExtensions.cs @@ -9,8 +9,22 @@ namespace StardewModdingAPI.Framework internal static class InternalExtensions { /********* + ** Properties + *********/ + /// <summary>Tracks the installed mods.</summary> + private static ModRegistry ModRegistry; + + + /********* ** Public methods *********/ + /// <summary>Injects types required for backwards compatibility.</summary> + /// <param name="modRegistry">Tracks the installed mods.</param> + internal static void Shim(ModRegistry modRegistry) + { + InternalExtensions.ModRegistry = modRegistry; + } + /**** ** IMonitor ****/ @@ -103,7 +117,7 @@ namespace StardewModdingAPI.Framework foreach (Delegate handler in handlers) { - string modName = Program.ModRegistry.GetModFrom(handler) ?? "an unknown mod"; // suppress stack trace for unknown mods, not helpful here + string modName = InternalExtensions.ModRegistry.GetModFrom(handler) ?? "an unknown mod"; // suppress stack trace for unknown mods, not helpful here deprecationManager.Warn(modName, nounPhrase, version, severity); } } diff --git a/src/StardewModdingAPI/Framework/Logging/ConsoleInterceptionManager.cs b/src/StardewModdingAPI/Framework/Logging/ConsoleInterceptionManager.cs new file mode 100644 index 00000000..d84671ee --- /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 something writes a line to the console directly.</summary> + public event Action<string> OnLineIntercepted; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public ConsoleInterceptionManager() + { + // redirect output through interceptor + this.Output = new InterceptingTextWriter(Console.Out); + this.Output.OnLineIntercepted += line => this.OnLineIntercepted?.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..14789109 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Logging/InterceptingTextWriter.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace StardewModdingAPI.Framework.Logging +{ + /// <summary>A text writer which allows intercepting output.</summary> + internal class InterceptingTextWriter : TextWriter + { + /********* + ** Properties + *********/ + /// <summary>The current line being intercepted.</summary> + private readonly List<char> Line = new List<char>(); + + + /********* + ** 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 line of text is intercepted.</summary> + public event Action<string> OnLineIntercepted; + + + /********* + ** 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 character to the text string or stream.</summary> + /// <param name="ch">The character to write to the text stream.</param> + public override void Write(char ch) + { + // intercept + if (this.ShouldIntercept) + { + switch (ch) + { + case '\r': + return; + + case '\n': + this.OnLineIntercepted?.Invoke(new string(this.Line.ToArray())); + this.Line.Clear(); + break; + + default: + this.Line.Add(ch); + break; + } + } + + // pass through + else + 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.OnLineIntercepted = null; + } + } +} diff --git a/src/StardewModdingAPI/Framework/LogFileManager.cs b/src/StardewModdingAPI/Framework/Logging/LogFileManager.cs index c2a2105b..1f6ade1d 100644 --- a/src/StardewModdingAPI/Framework/LogFileManager.cs +++ b/src/StardewModdingAPI/Framework/Logging/LogFileManager.cs @@ -1,7 +1,7 @@ using System; using System.IO; -namespace StardewModdingAPI.Framework +namespace StardewModdingAPI.Framework.Logging { /// <summary>Manages reading and writing to log file.</summary> internal class LogFileManager : IDisposable @@ -34,7 +34,9 @@ namespace StardewModdingAPI.Framework /// <param name="message">The message to log.</param> public void WriteLine(string message) { - this.Stream.WriteLine(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> @@ -43,4 +45,4 @@ namespace StardewModdingAPI.Framework this.Stream.Dispose(); } } -}
\ No newline at end of file +} diff --git a/src/StardewModdingAPI/Framework/Manifest.cs b/src/StardewModdingAPI/Framework/Manifest.cs new file mode 100644 index 00000000..189da9a8 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Manifest.cs @@ -0,0 +1,39 @@ +using System; +using Newtonsoft.Json; +using StardewModdingAPI.Framework.Serialisation; + +namespace StardewModdingAPI.Framework +{ + /// <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(SemanticVersionConverter))] + public ISemanticVersion Version { get; set; } + + /// <summary>The minimum SMAPI version required by this mod, if any.</summary> + public string MinimumApiVersion { get; set; } + + /// <summary>The name of the DLL in the directory that has the <see cref="Mod.Entry"/> method.</summary> + public string EntryDll { get; set; } + + /// <summary>The unique mod ID.</summary> + public string UniqueID { get; set; } + + /// <summary>Whether the mod uses per-save config files.</summary> + [Obsolete("Use " + nameof(Mod) + "." + nameof(Mod.Helper) + "." + nameof(IModHelper.ReadConfig) + " instead")] + public bool PerSaveConfigs { get; set; } + } +} diff --git a/src/StardewModdingAPI/ModHelper.cs b/src/StardewModdingAPI/Framework/ModHelper.cs index c20130cf..c8c44dba 100644 --- a/src/StardewModdingAPI/ModHelper.cs +++ b/src/StardewModdingAPI/Framework/ModHelper.cs @@ -1,24 +1,18 @@ using System; using System.IO; -using Newtonsoft.Json; -using StardewModdingAPI.Advanced; using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Framework.Serialisation; -namespace StardewModdingAPI +namespace StardewModdingAPI.Framework { /// <summary>Provides simplified APIs for writing mods.</summary> - [Obsolete("Use " + nameof(IModHelper) + " instead.")] // only direct mod access to this class is obsolete - public class ModHelper : IModHelper + internal class ModHelper : IModHelper { /********* ** Properties *********/ - /// <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 - }; + /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> + private readonly JsonHelper JsonHelper; /********* @@ -33,28 +27,38 @@ namespace StardewModdingAPI /// <summary>Metadata about loaded mods.</summary> public IModRegistry ModRegistry { get; } + /// <summary>An API for managing console commands.</summary> + public ICommandHelper ConsoleCommands { get; } + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> + /// <param name="modName">The friendly mod name.</param> /// <param name="modDirectory">The mod directory path.</param> + /// <param name="jsonHelper">Encapsulate SMAPI's JSON parsing.</param> /// <param name="modRegistry">Metadata about loaded mods.</param> - /// <exception cref="ArgumentException">An argument is null or invalid.</exception> + /// <param name="commandManager">Manages console commands.</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 modDirectory, IModRegistry modRegistry) + public ModHelper(string modName, string modDirectory, JsonHelper jsonHelper, IModRegistry modRegistry, CommandManager commandManager) { // validate - if (modRegistry == null) - throw new ArgumentException("The mod registry cannot be null."); if (string.IsNullOrWhiteSpace(modDirectory)) - throw new ArgumentException("The mod directory cannot be empty."); + throw new ArgumentNullException(nameof(modDirectory)); + if (jsonHelper == null) + throw new ArgumentNullException(nameof(jsonHelper)); + if (modRegistry == null) + throw new ArgumentNullException(nameof(modRegistry)); if (!Directory.Exists(modDirectory)) throw new InvalidOperationException("The specified mod directory does not exist."); // initialise + this.JsonHelper = jsonHelper; this.DirectoryPath = modDirectory; this.ModRegistry = modRegistry; + this.ConsoleCommands = new CommandHelper(modName, commandManager); } /**** @@ -65,7 +69,7 @@ namespace StardewModdingAPI public TConfig ReadConfig<TConfig>() where TConfig : class, new() { - var config = this.ReadJsonFile<TConfig>("config.json") ?? new TConfig(); + TConfig config = this.ReadJsonFile<TConfig>("config.json") ?? new TConfig(); this.WriteConfig(config); // create file or fill in missing fields return config; } @@ -89,28 +93,8 @@ namespace StardewModdingAPI public TModel ReadJsonFile<TModel>(string path) where TModel : class { - // read file - string fullPath = Path.Combine(this.DirectoryPath, path); - string json; - try - { - json = File.ReadAllText(fullPath); - } - catch (Exception ex) when (ex is DirectoryNotFoundException || ex is FileNotFoundException) - { - return null; - } - - // deserialise model - TModel model = JsonConvert.DeserializeObject<TModel>(json, this.JsonSettings); - if (model is IConfigFile) - { - var wrapper = (IConfigFile)model; - wrapper.ModHelper = this; - wrapper.FilePath = path; - } - - return model; + path = Path.Combine(this.DirectoryPath, path); + return this.JsonHelper.ReadJsonFile<TModel>(path); } /// <summary>Save to a JSON file.</summary> @@ -121,15 +105,7 @@ namespace StardewModdingAPI where TModel : class { path = Path.Combine(this.DirectoryPath, path); - - // create directory if needed - string dir = Path.GetDirectoryName(path); - if (!Directory.Exists(dir)) - Directory.CreateDirectory(dir); - - // write file - string json = JsonConvert.SerializeObject(model, this.JsonSettings); - File.WriteAllText(path, json); + this.JsonHelper.WriteJsonFile(path, model); } } } diff --git a/src/StardewModdingAPI/Framework/ModRegistry.cs b/src/StardewModdingAPI/Framework/ModRegistry.cs index 209f1928..f015b7ba 100644 --- a/src/StardewModdingAPI/Framework/ModRegistry.cs +++ b/src/StardewModdingAPI/Framework/ModRegistry.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Reflection; +using StardewModdingAPI.Framework.Models; namespace StardewModdingAPI.Framework { @@ -18,10 +19,21 @@ namespace StardewModdingAPI.Framework /// <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>(); + /// <summary>Metadata about mods that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code.</summary> + private readonly ModCompatibility[] CompatibilityRecords; + /********* ** Public methods *********/ + /// <summary>Construct an instance.</summary> + /// <param name="compatibilityRecords">Metadata about mods that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code.</param> + public ModRegistry(IEnumerable<ModCompatibility> compatibilityRecords) + { + this.CompatibilityRecords = compatibilityRecords.ToArray(); + } + + /**** ** IModRegistry ****/ @@ -113,5 +125,21 @@ namespace StardewModdingAPI.Framework // no known assembly found return null; } + + /// <summary>Get metadata that indicates whether SMAPI should assume the mod is compatible or broken, regardless of whether it detects incompatible code.</summary> + /// <param name="manifest">The mod manifest.</param> + /// <returns>Returns the incompatibility record if applicable, else <c>null</c>.</returns> + internal ModCompatibility GetCompatibilityRecord(IManifest manifest) + { + string key = !string.IsNullOrWhiteSpace(manifest.UniqueID) ? manifest.UniqueID : manifest.EntryDll; + return ( + from mod in this.CompatibilityRecords + where + mod.ID == key + && (mod.LowerSemanticVersion == null || !manifest.Version.IsOlderThan(mod.LowerSemanticVersion)) + && !manifest.Version.IsNewerThan(mod.UpperSemanticVersion) + select mod + ).FirstOrDefault(); + } } -}
\ No newline at end of file +} diff --git a/src/StardewModdingAPI/Framework/Models/IncompatibleMod.cs b/src/StardewModdingAPI/Framework/Models/IncompatibleMod.cs deleted file mode 100644 index bcf5639c..00000000 --- a/src/StardewModdingAPI/Framework/Models/IncompatibleMod.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Text.RegularExpressions; - -namespace StardewModdingAPI.Framework.Models -{ - /// <summary>Contains abstract metadata about an incompatible mod.</summary> - internal class IncompatibleMod - { - /********* - ** Accessors - *********/ - /// <summary>The unique mod ID.</summary> - public string ID { get; set; } - - /// <summary>The mod name.</summary> - public string Name { get; set; } - - /// <summary>The oldest incompatible mod version, or <c>null</c> for all past versions.</summary> - public string LowerVersion { get; set; } - - /// <summary>The most recent incompatible mod version.</summary> - public string UpperVersion { get; set; } - - /// <summary>The URL the user can check for an official updated version.</summary> - public string UpdateUrl { get; set; } - - /// <summary>The URL the user can check for an unofficial updated version.</summary> - public string UnofficialUpdateUrl { get; set; } - - /// <summary>A regular expression matching version strings to consider compatible, even if they technically precede <see cref="UpperVersion"/>.</summary> - public string ForceCompatibleVersion { get; set; } - - /// <summary>The reason phrase to show in the warning, or <c>null</c> to use the default value.</summary> - /// <example>"this version is incompatible with the latest version of the game"</example> - public string ReasonPhrase { get; set; } - - - /********* - ** Public methods - *********/ - /// <summary>Get whether the specified version is compatible according to this metadata.</summary> - /// <param name="version">The current version of the matching mod.</param> - public bool IsCompatible(ISemanticVersion version) - { - ISemanticVersion lowerVersion = this.LowerVersion != null ? new SemanticVersion(this.LowerVersion) : null; - ISemanticVersion upperVersion = new SemanticVersion(this.UpperVersion); - - // ignore versions not in range - if (lowerVersion != null && version.IsOlderThan(lowerVersion)) - return true; - if (version.IsNewerThan(upperVersion)) - return true; - - // allow versions matching override - return !string.IsNullOrWhiteSpace(this.ForceCompatibleVersion) && Regex.IsMatch(version.ToString(), this.ForceCompatibleVersion, RegexOptions.IgnoreCase); - } - } -} diff --git a/src/StardewModdingAPI/Framework/Models/ModCompatibility.cs b/src/StardewModdingAPI/Framework/Models/ModCompatibility.cs new file mode 100644 index 00000000..1e71dae0 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Models/ModCompatibility.cs @@ -0,0 +1,65 @@ +using System.Runtime.Serialization; +using Newtonsoft.Json; + +namespace StardewModdingAPI.Framework.Models +{ + /// <summary>Metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code.</summary> + internal class ModCompatibility + { + /********* + ** Accessors + *********/ + /**** + ** From config + ****/ + /// <summary>The unique mod ID.</summary> + public string ID { get; set; } + + /// <summary>The mod name.</summary> + public string Name { get; set; } + + /// <summary>The oldest incompatible mod version, or <c>null</c> for all past versions.</summary> + public string LowerVersion { get; set; } + + /// <summary>The most recent incompatible mod version.</summary> + public string UpperVersion { get; set; } + + /// <summary>The URL the user can check for an official updated version.</summary> + public string UpdateUrl { get; set; } + + /// <summary>The URL the user can check for an unofficial updated version.</summary> + public string UnofficialUpdateUrl { get; set; } + + /// <summary>The reason phrase to show in the warning, or <c>null</c> to use the default value.</summary> + /// <example>"this version is incompatible with the latest version of the game"</example> + public string ReasonPhrase { get; set; } + + /// <summary>Indicates how SMAPI should consider the mod.</summary> + public ModCompatibilityType Compatibility { get; set; } + + + /**** + ** Injected + ****/ + /// <summary>The semantic version corresponding to <see cref="LowerVersion"/>.</summary> + [JsonIgnore] + public ISemanticVersion LowerSemanticVersion { get; set; } + + /// <summary>The semantic version corresponding to <see cref="UpperVersion"/>.</summary> + [JsonIgnore] + public ISemanticVersion UpperSemanticVersion { get; set; } + + + /********* + ** Private methods + *********/ + /// <summary>The method called when the model finishes deserialising.</summary> + /// <param name="context">The deserialisation context.</param> + [OnDeserialized] + private void OnDeserialized(StreamingContext context) + { + this.LowerSemanticVersion = this.LowerVersion != null ? new SemanticVersion(this.LowerVersion) : null; + this.UpperSemanticVersion = this.UpperVersion != null ? new SemanticVersion(this.UpperVersion) : null; + } + } +} diff --git a/src/StardewModdingAPI/Framework/Models/ModCompatibilityType.cs b/src/StardewModdingAPI/Framework/Models/ModCompatibilityType.cs new file mode 100644 index 00000000..35edec5e --- /dev/null +++ b/src/StardewModdingAPI/Framework/Models/ModCompatibilityType.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI.Framework.Models +{ + /// <summary>Indicates how SMAPI should consider a mod.</summary> + internal enum ModCompatibilityType + { + /// <summary>Assume the mod is not compatible, even if SMAPI doesn't detect any incompatible code.</summary> + AssumeBroken = 0, + + /// <summary>Assume the mod is compatible, even if SMAPI detects incompatible code.</summary> + AssumeCompatible = 1 + } +} diff --git a/src/StardewModdingAPI/Framework/Models/UserSettings.cs b/src/StardewModdingAPI/Framework/Models/SConfig.cs index a0074f77..0de96297 100644 --- a/src/StardewModdingAPI/Framework/Models/UserSettings.cs +++ b/src/StardewModdingAPI/Framework/Models/SConfig.cs @@ -1,15 +1,18 @@ namespace StardewModdingAPI.Framework.Models { - /// <summary>Contains user settings from SMAPI's JSON configuration file.</summary> - internal class UserSettings + /// <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 if a newer version of SMAPI is available on startup.</summary> public bool CheckForUpdates { get; set; } = true; + + /// <summary>A list of mod versions which should be considered compatible or incompatible regardless of whether SMAPI detects incompatible code.</summary> + public ModCompatibility[] ModCompatibility { get; set; } } } diff --git a/src/StardewModdingAPI/Framework/Monitor.cs b/src/StardewModdingAPI/Framework/Monitor.cs index 39b567d8..64075f2f 100644 --- a/src/StardewModdingAPI/Framework/Monitor.cs +++ b/src/StardewModdingAPI/Framework/Monitor.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using StardewModdingAPI.Framework.Logging; namespace StardewModdingAPI.Framework { @@ -13,6 +14,9 @@ namespace StardewModdingAPI.Framework /// <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; @@ -30,27 +34,32 @@ namespace StardewModdingAPI.Framework [LogLevel.Alert] = ConsoleColor.Magenta }; + /// <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> + private RequestExitDelegate RequestExit; + /********* ** Accessors *********/ - /// <summary>Whether the current console supports color codes.</summary> - internal static readonly bool ConsoleSupportsColor = Monitor.GetConsoleSupportsColor(); - /// <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> - public Monitor(string source, LogFileManager logFile) + /// <param name="requestExitDelegate">A delegate which requests that SMAPI immediately exit the game.</param> + public Monitor(string source, ConsoleInterceptionManager consoleManager, LogFileManager logFile, RequestExitDelegate requestExitDelegate) { // validate if (string.IsNullOrWhiteSpace(source)) @@ -61,6 +70,7 @@ namespace StardewModdingAPI.Framework // initialise this.Source = source; this.LogFile = logFile; + this.ConsoleManager = consoleManager; } /// <summary>Log a message for the player or developer.</summary> @@ -68,23 +78,21 @@ namespace StardewModdingAPI.Framework /// <param name="level">The log severity level.</param> public void Log(string message, LogLevel level = LogLevel.Debug) { - this.LogImpl(this.Source, message, Monitor.Colors[level], level); + 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) { - Program.ExitGameImmediately(this.Source, reason); - Program.gamePtr.Exit(); + this.RequestExit(this.Source, reason); } /// <summary>Log a fatal error message.</summary> /// <param name="message">The message to log.</param> internal void LogFatal(string message) { - Console.BackgroundColor = ConsoleColor.Red; - this.LogImpl(this.Source, message, ConsoleColor.White, LogLevel.Error); + this.LogImpl(this.Source, message, LogLevel.Error, ConsoleColor.White, background: ConsoleColor.Red); } /// <summary>Log a message for the player or developer, using the specified console color.</summary> @@ -95,7 +103,7 @@ namespace StardewModdingAPI.Framework [Obsolete("This method is provided for backwards compatibility and otherwise should not be used. Use " + nameof(Monitor) + "." + nameof(Monitor.Log) + " instead.")] internal void LegacyLog(string source, string message, ConsoleColor color, LogLevel level = LogLevel.Debug) { - this.LogImpl(source, message, color, level); + this.LogImpl(source, message, level, color); } @@ -105,41 +113,34 @@ namespace StardewModdingAPI.Framework /// <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="color">The console color.</param> /// <param name="level">The log level.</param> - private void LogImpl(string source, string message, ConsoleColor color, LogLevel level) + /// <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); message = $"[{DateTime.Now:HH:mm:ss} {levelStr} {source}] {message}"; - // log + // write to console if (this.WriteToConsole && (this.ShowTraceInConsole || level != LogLevel.Trace)) { - if (Monitor.ConsoleSupportsColor) + this.ConsoleManager.ExclusiveWriteWithoutInterception(() => { - Console.ForegroundColor = color; - Console.WriteLine(message); - Console.ResetColor(); - } - else - Console.WriteLine(message); + if (this.ConsoleManager.SupportsColor) + { + Console.ForegroundColor = color; + Console.WriteLine(message); + Console.ResetColor(); + } + else + Console.WriteLine(message); + }); } - this.LogFile.WriteLine(message); - } - /// <summary>Test whether the current console supports color formatting.</summary> - private static bool GetConsoleSupportsColor() - { - try - { - Console.ForegroundColor = Console.ForegroundColor; - return true; - } - catch (Exception) - { - return false; // Mono bug - } + // write to log file + if (this.WriteToFile) + this.LogFile.WriteLine(message); } } -}
\ 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/ReflectionHelper.cs b/src/StardewModdingAPI/Framework/Reflection/ReflectionHelper.cs index edf59b81..7a5789dc 100644 --- a/src/StardewModdingAPI/Framework/Reflection/ReflectionHelper.cs +++ b/src/StardewModdingAPI/Framework/Reflection/ReflectionHelper.cs @@ -59,6 +59,41 @@ namespace StardewModdingAPI.Framework.Reflection } /**** + ** 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; + } + + /**** ** Field values ** (shorthand since this is the most common case) ****/ @@ -192,6 +227,28 @@ namespace StardewModdingAPI.Framework.Reflection : 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> 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..ef5855b2 --- /dev/null +++ b/src/StardewModdingAPI/Framework/SContentManager.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using Microsoft.Xna.Framework; +using StardewModdingAPI.AssemblyRewriters; +using StardewModdingAPI.Events; +using StardewModdingAPI.Framework.Content; +using StardewModdingAPI.Framework.Reflection; +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 + { + /********* + ** Accessors + *********/ + /// <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; + + + /********* + ** 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="monitor">Encapsulates monitoring and logging.</param> + public SContentManager(IServiceProvider serviceProvider, string rootDirectory, IMonitor monitor) + : this(serviceProvider, rootDirectory, Thread.CurrentThread.CurrentUICulture, null, monitor) { } + + /// <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) + { + // initialise + this.Monitor = monitor; + IReflectionHelper reflection = new ReflectionHelper(); + + // 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 + } + + /// <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) + { + // get normalised metadata + assetName = this.NormaliseAssetName(assetName); + string cacheLocale = this.GetCacheLocale(assetName); + + // skip if already loaded + if (this.IsLoaded(assetName)) + return base.Load<T>(assetName); + + // load data + T data = base.Load<T>(assetName); + + // let mods intercept content + IContentEventHelper helper = new ContentEventHelper(cacheLocale, assetName, data, this.NormaliseAssetName); + ContentEvents.InvokeAfterAssetLoaded(this.Monitor, helper); + this.Cache[assetName] = helper.Data; + return (T)helper.Data; + } + + + /********* + ** Private methods + *********/ + /// <summary>Normalise an asset name so it's consistent with the underlying cache.</summary> + /// <param name="assetName">The asset key.</param> + private string NormaliseAssetName(string assetName) + { + // ensure name format is consistent + string[] parts = assetName.Split(SContentManager.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries); + assetName = string.Join(SContentManager.PreferredPathSeparator, parts); + + // apply platform normalisation logic + return this.NormaliseAssetNameForPlatform(assetName); + } + + /// <summary>Get whether an asset has already been loaded.</summary> + /// <param name="normalisedAssetName">The normalised asset name.</param> + private bool IsLoaded(string normalisedAssetName) + { + return this.Cache.ContainsKey(normalisedAssetName) + || this.Cache.ContainsKey($"{normalisedAssetName}.{this.GetKeyLocale.Invoke<string>()}"); // translated asset + } + + /// <summary>Get the locale for which the asset name was saved, if any.</summary> + /// <param name="normalisedAssetName">The normalised asset name.</param> + private string GetCacheLocale(string normalisedAssetName) + { + string locale = this.GetKeyLocale.Invoke<string>(); + return this.Cache.ContainsKey($"{normalisedAssetName}.{locale}") + ? locale + : null; + } + } +} diff --git a/src/StardewModdingAPI/Inheritance/SGame.cs b/src/StardewModdingAPI/Framework/SGame.cs index 69c20244..5f265139 100644 --- a/src/StardewModdingAPI/Inheritance/SGame.cs +++ b/src/StardewModdingAPI/Framework/SGame.cs @@ -1,13 +1,13 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Reflection; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; using StardewModdingAPI.Events; -using StardewModdingAPI.Framework; +using StardewModdingAPI.Framework.Reflection; using StardewValley; using StardewValley.BellsAndWhistles; using StardewValley.Locations; @@ -15,24 +15,28 @@ using StardewValley.Menus; using StardewValley.Tools; using xTile.Dimensions; using Rectangle = Microsoft.Xna.Framework.Rectangle; +using SFarmer = StardewValley.Farmer; -namespace StardewModdingAPI.Inheritance +namespace StardewModdingAPI.Framework { /// <summary>SMAPI's extension of the game's core <see cref="Game1"/>, used to inject events.</summary> - public class SGame : Game1 + internal class SGame : Game1 { /********* ** Properties *********/ - /// <summary>The number of ticks until SMAPI should notify mods when <see cref="Game1.hasLoadedGame"/> is set.</summary> + /**** + ** SMAPI state + ****/ + /// <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 player has loaded a save and the world has finished initialising.</summary> private bool IsWorldReady => this.AfterLoadTimer < 0; - /// <summary>The debug messages to add to the next debug output.</summary> - internal static Queue<string> DebugMessageQueue { get; private set; } + /// <summary>Whether the game is returning to the menu.</summary> + private bool IsExiting; /// <summary>Whether the game's zoom level is at 100% (i.e. nothing should be scaled).</summary> public bool ZoomLevelIsOne => Game1.options.zoomLevel.Equals(1.0f); @@ -40,246 +44,118 @@ namespace StardewModdingAPI.Inheritance /// <summary>Encapsulates monitoring and logging.</summary> private readonly IMonitor Monitor; - - /********* - ** Accessors - *********/ + /**** + ** Game state + ****/ /// <summary>Arrays of pressed controller buttons indexed by <see cref="PlayerIndex"/>.</summary> - public Buttons[][] PreviouslyPressedButtons; + private Buttons[][] PreviouslyPressedButtons; /// <summary>A record of the keyboard state (i.e. the up/down state for each button) as of the latest tick.</summary> - public KeyboardState KStateNow { get; private set; } + private KeyboardState KStateNow; /// <summary>A record of the keyboard state (i.e. the up/down state for each button) as of the previous tick.</summary> - public KeyboardState KStatePrior { get; private set; } + private KeyboardState KStatePrior; /// <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 latest tick.</summary> - public MouseState MStateNow { get; private set; } + private MouseState MStateNow; /// <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> - public MouseState MStatePrior { get; private set; } + private MouseState MStatePrior; /// <summary>The current mouse position on the screen adjusted for the zoom level.</summary> - public Point MPositionNow { get; private set; } + private Point MPositionNow; /// <summary>The previous mouse position on the screen adjusted for the zoom level.</summary> - public Point MPositionPrior { get; private set; } + private Point MPositionPrior; /// <summary>The keys that were pressed as of the latest tick.</summary> - public Keys[] CurrentlyPressedKeys => this.KStateNow.GetPressedKeys(); + private Keys[] CurrentlyPressedKeys => this.KStateNow.GetPressedKeys(); /// <summary>The keys that were pressed as of the previous tick.</summary> - public Keys[] PreviouslyPressedKeys => this.KStatePrior.GetPressedKeys(); + private Keys[] PreviouslyPressedKeys => this.KStatePrior.GetPressedKeys(); /// <summary>The keys that just entered the down state.</summary> - public Keys[] FramePressedKeys => this.CurrentlyPressedKeys.Except(this.PreviouslyPressedKeys).ToArray(); + private Keys[] FramePressedKeys => this.CurrentlyPressedKeys.Except(this.PreviouslyPressedKeys).ToArray(); /// <summary>The keys that just entered the up state.</summary> - public Keys[] FrameReleasedKeys => this.PreviouslyPressedKeys.Except(this.CurrentlyPressedKeys).ToArray(); - - /// <summary>Whether a save is currently loaded at last check.</summary> - public bool PreviouslyLoadedGame { get; private set; } + private Keys[] FrameReleasedKeys => this.PreviouslyPressedKeys.Except(this.CurrentlyPressedKeys).ToArray(); /// <summary>A hash of <see cref="Game1.locations"/> at last check.</summary> - public int PreviousGameLocations { get; private set; } + private int PreviousGameLocations; /// <summary>A hash of the current location's <see cref="GameLocation.objects"/> at last check.</summary> - public int PreviousLocationObjects { get; private set; } + private int PreviousLocationObjects; /// <summary>The player's inventory at last check.</summary> - public Dictionary<Item, int> PreviousItems { get; private set; } + private IDictionary<Item, int> PreviousItems; /// <summary>The player's combat skill level at last check.</summary> - public int PreviousCombatLevel { get; private set; } + private int PreviousCombatLevel; /// <summary>The player's farming skill level at last check.</summary> - public int PreviousFarmingLevel { get; private set; } + private int PreviousFarmingLevel; /// <summary>The player's fishing skill level at last check.</summary> - public int PreviousFishingLevel { get; private set; } + private int PreviousFishingLevel; /// <summary>The player's foraging skill level at last check.</summary> - public int PreviousForagingLevel { get; private set; } + private int PreviousForagingLevel; /// <summary>The player's mining skill level at last check.</summary> - public int PreviousMiningLevel { get; private set; } + private int PreviousMiningLevel; /// <summary>The player's luck skill level at last check.</summary> - public int PreviousLuckLevel { get; private set; } + private int PreviousLuckLevel; /// <summary>The player's location at last check.</summary> - public GameLocation PreviousGameLocation { get; private set; } + private GameLocation PreviousGameLocation; /// <summary>The active game menu at last check.</summary> - public IClickableMenu PreviousActiveMenu { get; private set; } + private IClickableMenu PreviousActiveMenu; /// <summary>The mine level at last check.</summary> - public int PreviousMineLevel { get; private set; } + private int PreviousMineLevel; /// <summary>The time of day (in 24-hour military format) at last check.</summary> - public int PreviousTimeOfDay { get; private set; } + private int PreviousTime; /// <summary>The day of month (1–28) at last check.</summary> - public int PreviousDayOfMonth { get; private set; } + private int PreviousDay; /// <summary>The season name (winter, spring, summer, or fall) at last check.</summary> - public string PreviousSeasonOfYear { get; private set; } + private string PreviousSeason; /// <summary>The year number at last check.</summary> - public int PreviousYearOfGame { get; private set; } + private int PreviousYear; /// <summary>Whether the game was transitioning to a new day at last check.</summary> - public bool PreviousIsNewDay { get; private set; } + private bool PreviousIsNewDay; /// <summary>The player character at last check.</summary> - public Farmer PreviousFarmer { get; private set; } + private SFarmer PreviousFarmer; /// <summary>An index incremented on every tick and reset every 60th tick (0–59).</summary> - public int CurrentUpdateTick { get; private set; } + private int CurrentUpdateTick; /// <summary>Whether this is the very first update tick since the game started.</summary> - public bool FirstUpdate { get; private set; } - - /// <summary>The game's current render target.</summary> - public RenderTarget2D Screen - { - get { return this.GetBaseFieldValue<RenderTarget2D>("screen"); } - set { this.SetBaseFieldValue<RenderTarget2D>("screen", value); } - } - - /// <summary>The game's current background color.</summary> - public Color BgColour - { - get { return (Color)this.GetBaseFieldValue<object>("bgColor"); } - set { this.SetBaseFieldValue<object>("bgColor", value); } - } + private bool FirstUpdate; /// <summary>The current game instance.</summary> - public static SGame Instance { get; private set; } - - /// <summary>The game's current frame rate, recalculated on each draw update.</summary> - public static float FramesPerSecond { get; private set; } - - /// <summary>Whether we're in pseudo-debug mode, which shows information like FPS.</summary> - public static bool Debug { get; private set; } - - /// <summary>The current player.</summary> - [Obsolete("Use Game1.player instead")] - public Farmer CurrentFarmer => Game1.player; - - /// <summary>The game method which draws the farm buildings.</summary> - public static MethodInfo DrawFarmBuildings = typeof(Game1).GetMethod("drawFarmBuildings", BindingFlags.NonPublic | BindingFlags.Instance); - - /// <summary>The game method which draws the game HUD.</summary> - public static MethodInfo DrawHUD = typeof(Game1).GetMethod("drawHUD", BindingFlags.NonPublic | BindingFlags.Instance); - - /// <summary>The game method which draws the current dialogue box, if any.</summary> - public static MethodInfo DrawDialogueBox = typeof(Game1).GetMethod("drawDialogueBox", BindingFlags.NonPublic | BindingFlags.Instance); - - - /********* - ** Public methods - *********/ - /// <summary>Get the controller buttons which are currently pressed.</summary> - /// <param name="index">The controller to check.</param> - public Buttons[] GetButtonsDown(PlayerIndex index) - { - var state = GamePad.GetState(index); - var buttons = new List<Buttons>(); - if (state.IsConnected) - { - if (state.Buttons.A == ButtonState.Pressed) buttons.Add(Buttons.A); - if (state.Buttons.B == ButtonState.Pressed) buttons.Add(Buttons.B); - if (state.Buttons.Back == ButtonState.Pressed) buttons.Add(Buttons.Back); - if (state.Buttons.BigButton == ButtonState.Pressed) buttons.Add(Buttons.BigButton); - if (state.Buttons.LeftShoulder == ButtonState.Pressed) buttons.Add(Buttons.LeftShoulder); - if (state.Buttons.LeftStick == ButtonState.Pressed) buttons.Add(Buttons.LeftStick); - if (state.Buttons.RightShoulder == ButtonState.Pressed) buttons.Add(Buttons.RightShoulder); - if (state.Buttons.RightStick == ButtonState.Pressed) buttons.Add(Buttons.RightStick); - if (state.Buttons.Start == ButtonState.Pressed) buttons.Add(Buttons.Start); - if (state.Buttons.X == ButtonState.Pressed) buttons.Add(Buttons.X); - if (state.Buttons.Y == ButtonState.Pressed) buttons.Add(Buttons.Y); - if (state.DPad.Up == ButtonState.Pressed) buttons.Add(Buttons.DPadUp); - if (state.DPad.Down == ButtonState.Pressed) buttons.Add(Buttons.DPadDown); - if (state.DPad.Left == ButtonState.Pressed) buttons.Add(Buttons.DPadLeft); - if (state.DPad.Right == ButtonState.Pressed) buttons.Add(Buttons.DPadRight); - if (state.Triggers.Left > 0.2f) buttons.Add(Buttons.LeftTrigger); - if (state.Triggers.Right > 0.2f) buttons.Add(Buttons.RightTrigger); - } - return buttons.ToArray(); - } - - /// <summary>Get the controller buttons which were pressed after the last update.</summary> - /// <param name="index">The controller to check.</param> - public Buttons[] GetFramePressedButtons(PlayerIndex index) - { - var state = GamePad.GetState(index); - var buttons = new List<Buttons>(); - if (state.IsConnected) - { - if (this.WasButtonJustPressed(Buttons.A, state.Buttons.A, index)) buttons.Add(Buttons.A); - if (this.WasButtonJustPressed(Buttons.B, state.Buttons.B, index)) buttons.Add(Buttons.B); - if (this.WasButtonJustPressed(Buttons.Back, state.Buttons.Back, index)) buttons.Add(Buttons.Back); - if (this.WasButtonJustPressed(Buttons.BigButton, state.Buttons.BigButton, index)) buttons.Add(Buttons.BigButton); - if (this.WasButtonJustPressed(Buttons.LeftShoulder, state.Buttons.LeftShoulder, index)) buttons.Add(Buttons.LeftShoulder); - if (this.WasButtonJustPressed(Buttons.LeftStick, state.Buttons.LeftStick, index)) buttons.Add(Buttons.LeftStick); - if (this.WasButtonJustPressed(Buttons.RightShoulder, state.Buttons.RightShoulder, index)) buttons.Add(Buttons.RightShoulder); - if (this.WasButtonJustPressed(Buttons.RightStick, state.Buttons.RightStick, index)) buttons.Add(Buttons.RightStick); - if (this.WasButtonJustPressed(Buttons.Start, state.Buttons.Start, index)) buttons.Add(Buttons.Start); - if (this.WasButtonJustPressed(Buttons.X, state.Buttons.X, index)) buttons.Add(Buttons.X); - if (this.WasButtonJustPressed(Buttons.Y, state.Buttons.Y, index)) buttons.Add(Buttons.Y); - if (this.WasButtonJustPressed(Buttons.DPadUp, state.DPad.Up, index)) buttons.Add(Buttons.DPadUp); - if (this.WasButtonJustPressed(Buttons.DPadDown, state.DPad.Down, index)) buttons.Add(Buttons.DPadDown); - if (this.WasButtonJustPressed(Buttons.DPadLeft, state.DPad.Left, index)) buttons.Add(Buttons.DPadLeft); - if (this.WasButtonJustPressed(Buttons.DPadRight, state.DPad.Right, index)) buttons.Add(Buttons.DPadRight); - if (this.WasButtonJustPressed(Buttons.LeftTrigger, state.Triggers.Left, index)) buttons.Add(Buttons.LeftTrigger); - if (this.WasButtonJustPressed(Buttons.RightTrigger, state.Triggers.Right, index)) buttons.Add(Buttons.RightTrigger); - } - return buttons.ToArray(); - } - - /// <summary>Get the controller buttons which were released after the last update.</summary> - /// <param name="index">The controller to check.</param> - public Buttons[] GetFrameReleasedButtons(PlayerIndex index) - { - var state = GamePad.GetState(index); - var buttons = new List<Buttons>(); - if (state.IsConnected) - { - if (this.WasButtonJustReleased(Buttons.A, state.Buttons.A, index)) buttons.Add(Buttons.A); - if (this.WasButtonJustReleased(Buttons.B, state.Buttons.B, index)) buttons.Add(Buttons.B); - if (this.WasButtonJustReleased(Buttons.Back, state.Buttons.Back, index)) buttons.Add(Buttons.Back); - if (this.WasButtonJustReleased(Buttons.BigButton, state.Buttons.BigButton, index)) buttons.Add(Buttons.BigButton); - if (this.WasButtonJustReleased(Buttons.LeftShoulder, state.Buttons.LeftShoulder, index)) buttons.Add(Buttons.LeftShoulder); - if (this.WasButtonJustReleased(Buttons.LeftStick, state.Buttons.LeftStick, index)) buttons.Add(Buttons.LeftStick); - if (this.WasButtonJustReleased(Buttons.RightShoulder, state.Buttons.RightShoulder, index)) buttons.Add(Buttons.RightShoulder); - if (this.WasButtonJustReleased(Buttons.RightStick, state.Buttons.RightStick, index)) buttons.Add(Buttons.RightStick); - if (this.WasButtonJustReleased(Buttons.Start, state.Buttons.Start, index)) buttons.Add(Buttons.Start); - if (this.WasButtonJustReleased(Buttons.X, state.Buttons.X, index)) buttons.Add(Buttons.X); - if (this.WasButtonJustReleased(Buttons.Y, state.Buttons.Y, index)) buttons.Add(Buttons.Y); - if (this.WasButtonJustReleased(Buttons.DPadUp, state.DPad.Up, index)) buttons.Add(Buttons.DPadUp); - if (this.WasButtonJustReleased(Buttons.DPadDown, state.DPad.Down, index)) buttons.Add(Buttons.DPadDown); - if (this.WasButtonJustReleased(Buttons.DPadLeft, state.DPad.Left, index)) buttons.Add(Buttons.DPadLeft); - if (this.WasButtonJustReleased(Buttons.DPadRight, state.DPad.Right, index)) buttons.Add(Buttons.DPadRight); - if (this.WasButtonJustReleased(Buttons.LeftTrigger, state.Triggers.Left, index)) buttons.Add(Buttons.LeftTrigger); - if (this.WasButtonJustReleased(Buttons.RightTrigger, state.Triggers.Right, index)) buttons.Add(Buttons.RightTrigger); - } - return buttons.ToArray(); - } - - /// <summary>Queue a message to be added to the debug output.</summary> - /// <param name="message">The message to add.</param> - /// <returns>Returns whether the message was successfully queued.</returns> - public static bool QueueDebugMessage(string message) - { - if (!SGame.Debug) - return false; - if (SGame.DebugMessageQueue.Count > 32) - return false; - - SGame.DebugMessageQueue.Enqueue(message); - return true; - } + private static SGame Instance; + + /**** + ** Private wrappers + ****/ + // ReSharper disable ArrangeStaticMemberQualifier, ArrangeThisQualifier, InconsistentNaming + /// <summary>Used to access private fields and methods.</summary> + private static readonly IReflectionHelper Reflection = new ReflectionHelper(); + private Color bgColor => SGame.Reflection.GetPrivateField<Color>(this, nameof(bgColor)).GetValue(); + public RenderTarget2D screenWrapper => SGame.Reflection.GetPrivateField<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(new object[0]); + private readonly Action drawHUD = () => SGame.Reflection.GetPrivateMethod(SGame.Instance, nameof(drawHUD)).Invoke(new object[0]); + private readonly Action drawDialogueBox = () => SGame.Reflection.GetPrivateMethod(SGame.Instance, nameof(drawDialogueBox)).Invoke(new object[0]); + // ReSharper restore ArrangeStaticMemberQualifier, ArrangeThisQualifier, InconsistentNaming /********* @@ -294,11 +170,12 @@ namespace StardewModdingAPI.Inheritance SGame.Instance = this; } + /**** + ** Intercepted methods & events + ****/ /// <summary>The method called during game launch after configuring XNA or MonoGame. The game window hasn't been opened by this point.</summary> protected override void Initialize() { - //ModItems = new Dictionary<int, SObject>(); - SGame.DebugMessageQueue = new Queue<string>(); this.PreviouslyPressedButtons = new Buttons[4][]; for (var i = 0; i < 4; ++i) this.PreviouslyPressedButtons[i] = new Buttons[0]; @@ -318,9 +195,6 @@ namespace StardewModdingAPI.Inheritance /// <param name="gameTime">A snapshot of the game timing state.</param> protected override void Update(GameTime gameTime) { - // add FPS to debug output - SGame.QueueDebugMessage($"FPS: {SGame.FramesPerSecond}"); - // raise game loaded if (this.FirstUpdate) GameEvents.InvokeGameLoaded(this.Monitor); @@ -328,10 +202,6 @@ namespace StardewModdingAPI.Inheritance // update SMAPI events this.UpdateEventCalls(); - // toggle debug output - if (this.FramePressedKeys.Contains(Keys.F3)) - SGame.Debug = !SGame.Debug; - // let game update try { @@ -367,8 +237,7 @@ namespace StardewModdingAPI.Inheritance this.CurrentUpdateTick = 0; // track keyboard state - if (this.KStatePrior != this.KStateNow) - this.KStatePrior = this.KStateNow; + this.KStatePrior = this.KStateNow; // track controller button state for (var i = PlayerIndex.One; i <= PlayerIndex.Four; i++) @@ -377,18 +246,22 @@ namespace StardewModdingAPI.Inheritance /// <summary>The method called to draw everything to the screen.</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, minor formatting, and added events.</remarks> + /// <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")] protected override void Draw(GameTime gameTime) { - // track frame rate - SGame.FramesPerSecond = 1 / (float)gameTime.ElapsedGameTime.TotalSeconds; - try { if (!this.ZoomLevelIsOne) - this.GraphicsDevice.SetRenderTarget(this.Screen); + this.GraphicsDevice.SetRenderTarget(this.screenWrapper); - this.GraphicsDevice.Clear(this.BgColour); + this.GraphicsDevice.Clear(this.bgColor); if (Game1.options.showMenuBackground && Game1.activeClickableMenu != null && Game1.activeClickableMenu.showWithoutTransparencyIfOptionIsSet()) { Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); @@ -416,9 +289,9 @@ namespace StardewModdingAPI.Inheritance if (!this.ZoomLevelIsOne) { this.GraphicsDevice.SetRenderTarget(null); - this.GraphicsDevice.Clear(this.BgColour); + this.GraphicsDevice.Clear(this.bgColor); Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); - Game1.spriteBatch.Draw(this.Screen, Vector2.Zero, this.Screen.Bounds, Color.White, 0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.Draw(this.screenWrapper, Vector2.Zero, this.screenWrapper.Bounds, Color.White, 0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); Game1.spriteBatch.End(); } return; @@ -444,9 +317,9 @@ namespace StardewModdingAPI.Inheritance if (!this.ZoomLevelIsOne) { this.GraphicsDevice.SetRenderTarget(null); - this.GraphicsDevice.Clear(this.BgColour); + this.GraphicsDevice.Clear(this.bgColor); Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); - Game1.spriteBatch.Draw(this.Screen, Vector2.Zero, this.Screen.Bounds, Color.White, 0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.Draw(this.screenWrapper, Vector2.Zero, this.screenWrapper.Bounds, Color.White, 0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); Game1.spriteBatch.End(); } return; @@ -467,9 +340,9 @@ namespace StardewModdingAPI.Inheritance if (!this.ZoomLevelIsOne) { this.GraphicsDevice.SetRenderTarget(null); - this.GraphicsDevice.Clear(this.BgColour); + this.GraphicsDevice.Clear(this.bgColor); Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); - Game1.spriteBatch.Draw(this.Screen, Vector2.Zero, this.Screen.Bounds, Color.White, 0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.Draw(this.screenWrapper, Vector2.Zero, this.screenWrapper.Bounds, Color.White, 0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); Game1.spriteBatch.End(); } return; @@ -489,9 +362,9 @@ namespace StardewModdingAPI.Inheritance if (!this.ZoomLevelIsOne) { this.GraphicsDevice.SetRenderTarget(null); - this.GraphicsDevice.Clear(this.BgColour); + this.GraphicsDevice.Clear(this.bgColor); Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); - Game1.spriteBatch.Draw(this.Screen, Vector2.Zero, this.Screen.Bounds, Color.White, 0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.Draw(this.screenWrapper, Vector2.Zero, this.screenWrapper.Bounds, Color.White, 0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); Game1.spriteBatch.End(); } return; @@ -512,11 +385,11 @@ namespace StardewModdingAPI.Inheritance Game1.spriteBatch.Draw(Game1.currentLightSources.ElementAt(i).lightTexture, Game1.GlobalToLocal(Game1.viewport, Game1.currentLightSources.ElementAt(i).position) / Game1.options.lightingQuality, Game1.currentLightSources.ElementAt(i).lightTexture.Bounds, Game1.currentLightSources.ElementAt(i).color, 0f, new Vector2(Game1.currentLightSources.ElementAt(i).lightTexture.Bounds.Center.X, Game1.currentLightSources.ElementAt(i).lightTexture.Bounds.Center.Y), Game1.currentLightSources.ElementAt(i).radius / Game1.options.lightingQuality, SpriteEffects.None, 0.9f); } Game1.spriteBatch.End(); - this.GraphicsDevice.SetRenderTarget(this.ZoomLevelIsOne ? null : this.Screen); + this.GraphicsDevice.SetRenderTarget(this.ZoomLevelIsOne ? null : this.screenWrapper); } if (Game1.bloomDay) Game1.bloom?.BeginDraw(); - this.GraphicsDevice.Clear(this.BgColour); + this.GraphicsDevice.Clear(this.bgColor); Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); GraphicsEvents.InvokeOnPreRenderEvent(this.Monitor); Game1.background?.draw(Game1.spriteBatch); @@ -581,7 +454,7 @@ namespace StardewModdingAPI.Inheritance 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")) - SGame.DrawFarmBuildings.Invoke(Program.gamePtr, null); + this.drawFarmBuildings(); if (Game1.tvStation >= 0) Game1.spriteBatch.Draw(Game1.tvStationTexture, Game1.GlobalToLocal(Game1.viewport, new Vector2(6 * Game1.tileSize + Game1.tileSize / 4, 2 * Game1.tileSize + Game1.tileSize / 2)), new Rectangle(Game1.tvStation * 24, 0, 24, 15), Color.White, 0f, Vector2.Zero, 4f, SpriteEffects.None, 1E-08f); if (Game1.panMode) @@ -708,16 +581,14 @@ namespace StardewModdingAPI.Inheritance if (Game1.currentBillboard != 0) this.drawBillboard(); - GraphicsEvents.InvokeOnPreRenderHudEventNoCheck(this.Monitor); if ((Game1.displayHUD || Game1.eventUp) && Game1.currentBillboard == 0 && Game1.gameMode == 3 && !Game1.freezeControls && !Game1.panMode) { GraphicsEvents.InvokeOnPreRenderHudEvent(this.Monitor); - SGame.DrawHUD.Invoke(Program.gamePtr, null); + this.drawHUD(); GraphicsEvents.InvokeOnPostRenderHudEvent(this.Monitor); } else if (Game1.activeClickableMenu == null && Game1.farmEvent == null) Game1.spriteBatch.Draw(Game1.mouseCursors, new Vector2(Game1.getOldMouseX(), Game1.getOldMouseY()), Game1.getSourceRectForStandardTileSheet(Game1.mouseCursors, 0, 16, 16), Color.White, 0f, Vector2.Zero, 4f + Game1.dialogueButtonScale / 150f, SpriteEffects.None, 1f); - GraphicsEvents.InvokeOnPostRenderHudEventNoCheck(this.Monitor); if (Game1.hudMessages.Any() && (!Game1.eventUp || Game1.isFestival())) { @@ -727,7 +598,7 @@ namespace StardewModdingAPI.Inheritance } Game1.farmEvent?.draw(Game1.spriteBatch); if (Game1.dialogueUp && !Game1.nameSelectUp && !Game1.messagePause && !(Game1.activeClickableMenu is DialogueBox)) - SGame.DrawDialogueBox.Invoke(Program.gamePtr, null); + this.drawDialogueBox(); if (Game1.progressBar) { Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Rectangle((Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Width - Game1.dialogueWidth) / 2, Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Bottom - Game1.tileSize * 2, Game1.dialogueWidth, Game1.tileSize / 2), Color.LightGray); @@ -746,7 +617,7 @@ namespace StardewModdingAPI.Inheritance Game1.flashAlpha -= 0.1f; } if ((Game1.messagePause || Game1.globalFade) && Game1.dialogueUp) - SGame.DrawDialogueBox.Invoke(Program.gamePtr, null); + this.drawDialogueBox(); foreach (TemporaryAnimatedSprite current8 in Game1.screenOverlayTempSprites) current8.draw(Game1.spriteBatch, true); if (Game1.debugMode) @@ -766,7 +637,6 @@ namespace StardewModdingAPI.Inheritance if (Game1.showKeyHelp) Game1.spriteBatch.DrawString(Game1.smallFont, Game1.keyHelpString, new Vector2(Game1.tileSize, 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, 0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f); - GraphicsEvents.InvokeOnPreRenderGuiEventNoCheck(this.Monitor); if (Game1.activeClickableMenu != null) { GraphicsEvents.InvokeOnPreRenderGuiEvent(this.Monitor); @@ -783,46 +653,113 @@ namespace StardewModdingAPI.Inheritance } else Game1.farmEvent?.drawAboveEverything(Game1.spriteBatch); - GraphicsEvents.InvokeOnPostRenderGuiEventNoCheck(this.Monitor); GraphicsEvents.InvokeOnPostRenderEvent(this.Monitor); Game1.spriteBatch.End(); - GraphicsEvents.InvokeDrawInRenderTargetTick(this.Monitor); - if (!this.ZoomLevelIsOne) { this.GraphicsDevice.SetRenderTarget(null); - this.GraphicsDevice.Clear(this.BgColour); + this.GraphicsDevice.Clear(this.bgColor); Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.Opaque, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); - Game1.spriteBatch.Draw(this.Screen, Vector2.Zero, this.Screen.Bounds, Color.White, 0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.Draw(this.screenWrapper, Vector2.Zero, this.screenWrapper.Bounds, Color.White, 0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); Game1.spriteBatch.End(); } - - GraphicsEvents.InvokeDrawTick(this.Monitor); } catch (Exception ex) { this.Monitor.Log($"An error occured in the overridden draw loop: {ex.GetLogSummary()}", LogLevel.Error); } + } - if (SGame.Debug) + /**** + ** Methods + ****/ + /// <summary>Get the controller buttons which are currently pressed.</summary> + /// <param name="index">The controller to check.</param> + private Buttons[] GetButtonsDown(PlayerIndex index) + { + var state = GamePad.GetState(index); + var buttons = new List<Buttons>(); + if (state.IsConnected) { - Game1.spriteBatch.Begin(); + if (state.Buttons.A == ButtonState.Pressed) buttons.Add(Buttons.A); + if (state.Buttons.B == ButtonState.Pressed) buttons.Add(Buttons.B); + if (state.Buttons.Back == ButtonState.Pressed) buttons.Add(Buttons.Back); + if (state.Buttons.BigButton == ButtonState.Pressed) buttons.Add(Buttons.BigButton); + if (state.Buttons.LeftShoulder == ButtonState.Pressed) buttons.Add(Buttons.LeftShoulder); + if (state.Buttons.LeftStick == ButtonState.Pressed) buttons.Add(Buttons.LeftStick); + if (state.Buttons.RightShoulder == ButtonState.Pressed) buttons.Add(Buttons.RightShoulder); + if (state.Buttons.RightStick == ButtonState.Pressed) buttons.Add(Buttons.RightStick); + if (state.Buttons.Start == ButtonState.Pressed) buttons.Add(Buttons.Start); + if (state.Buttons.X == ButtonState.Pressed) buttons.Add(Buttons.X); + if (state.Buttons.Y == ButtonState.Pressed) buttons.Add(Buttons.Y); + if (state.DPad.Up == ButtonState.Pressed) buttons.Add(Buttons.DPadUp); + if (state.DPad.Down == ButtonState.Pressed) buttons.Add(Buttons.DPadDown); + if (state.DPad.Left == ButtonState.Pressed) buttons.Add(Buttons.DPadLeft); + if (state.DPad.Right == ButtonState.Pressed) buttons.Add(Buttons.DPadRight); + if (state.Triggers.Left > 0.2f) buttons.Add(Buttons.LeftTrigger); + if (state.Triggers.Right > 0.2f) buttons.Add(Buttons.RightTrigger); + } + return buttons.ToArray(); + } - int i = 0; - while (SGame.DebugMessageQueue.Any()) - { - string message = SGame.DebugMessageQueue.Dequeue(); - Game1.spriteBatch.DrawString(Game1.smoothFont, message, new Vector2(0, i * 14), Color.CornflowerBlue); - i++; - } - GraphicsEvents.InvokeDrawDebug(this.Monitor); + /// <summary>Get the controller buttons which were pressed after the last update.</summary> + /// <param name="index">The controller to check.</param> + private Buttons[] GetFramePressedButtons(PlayerIndex index) + { + var state = GamePad.GetState(index); + var buttons = new List<Buttons>(); + if (state.IsConnected) + { + if (this.WasButtonJustPressed(Buttons.A, state.Buttons.A, index)) buttons.Add(Buttons.A); + if (this.WasButtonJustPressed(Buttons.B, state.Buttons.B, index)) buttons.Add(Buttons.B); + if (this.WasButtonJustPressed(Buttons.Back, state.Buttons.Back, index)) buttons.Add(Buttons.Back); + if (this.WasButtonJustPressed(Buttons.BigButton, state.Buttons.BigButton, index)) buttons.Add(Buttons.BigButton); + if (this.WasButtonJustPressed(Buttons.LeftShoulder, state.Buttons.LeftShoulder, index)) buttons.Add(Buttons.LeftShoulder); + if (this.WasButtonJustPressed(Buttons.LeftStick, state.Buttons.LeftStick, index)) buttons.Add(Buttons.LeftStick); + if (this.WasButtonJustPressed(Buttons.RightShoulder, state.Buttons.RightShoulder, index)) buttons.Add(Buttons.RightShoulder); + if (this.WasButtonJustPressed(Buttons.RightStick, state.Buttons.RightStick, index)) buttons.Add(Buttons.RightStick); + if (this.WasButtonJustPressed(Buttons.Start, state.Buttons.Start, index)) buttons.Add(Buttons.Start); + if (this.WasButtonJustPressed(Buttons.X, state.Buttons.X, index)) buttons.Add(Buttons.X); + if (this.WasButtonJustPressed(Buttons.Y, state.Buttons.Y, index)) buttons.Add(Buttons.Y); + if (this.WasButtonJustPressed(Buttons.DPadUp, state.DPad.Up, index)) buttons.Add(Buttons.DPadUp); + if (this.WasButtonJustPressed(Buttons.DPadDown, state.DPad.Down, index)) buttons.Add(Buttons.DPadDown); + if (this.WasButtonJustPressed(Buttons.DPadLeft, state.DPad.Left, index)) buttons.Add(Buttons.DPadLeft); + if (this.WasButtonJustPressed(Buttons.DPadRight, state.DPad.Right, index)) buttons.Add(Buttons.DPadRight); + if (this.WasButtonJustPressed(Buttons.LeftTrigger, state.Triggers.Left, index)) buttons.Add(Buttons.LeftTrigger); + if (this.WasButtonJustPressed(Buttons.RightTrigger, state.Triggers.Right, index)) buttons.Add(Buttons.RightTrigger); + } + return buttons.ToArray(); + } - Game1.spriteBatch.End(); + /// <summary>Get the controller buttons which were released after the last update.</summary> + /// <param name="index">The controller to check.</param> + private Buttons[] GetFrameReleasedButtons(PlayerIndex index) + { + var state = GamePad.GetState(index); + var buttons = new List<Buttons>(); + if (state.IsConnected) + { + if (this.WasButtonJustReleased(Buttons.A, state.Buttons.A, index)) buttons.Add(Buttons.A); + if (this.WasButtonJustReleased(Buttons.B, state.Buttons.B, index)) buttons.Add(Buttons.B); + if (this.WasButtonJustReleased(Buttons.Back, state.Buttons.Back, index)) buttons.Add(Buttons.Back); + if (this.WasButtonJustReleased(Buttons.BigButton, state.Buttons.BigButton, index)) buttons.Add(Buttons.BigButton); + if (this.WasButtonJustReleased(Buttons.LeftShoulder, state.Buttons.LeftShoulder, index)) buttons.Add(Buttons.LeftShoulder); + if (this.WasButtonJustReleased(Buttons.LeftStick, state.Buttons.LeftStick, index)) buttons.Add(Buttons.LeftStick); + if (this.WasButtonJustReleased(Buttons.RightShoulder, state.Buttons.RightShoulder, index)) buttons.Add(Buttons.RightShoulder); + if (this.WasButtonJustReleased(Buttons.RightStick, state.Buttons.RightStick, index)) buttons.Add(Buttons.RightStick); + if (this.WasButtonJustReleased(Buttons.Start, state.Buttons.Start, index)) buttons.Add(Buttons.Start); + if (this.WasButtonJustReleased(Buttons.X, state.Buttons.X, index)) buttons.Add(Buttons.X); + if (this.WasButtonJustReleased(Buttons.Y, state.Buttons.Y, index)) buttons.Add(Buttons.Y); + if (this.WasButtonJustReleased(Buttons.DPadUp, state.DPad.Up, index)) buttons.Add(Buttons.DPadUp); + if (this.WasButtonJustReleased(Buttons.DPadDown, state.DPad.Down, index)) buttons.Add(Buttons.DPadDown); + if (this.WasButtonJustReleased(Buttons.DPadLeft, state.DPad.Left, index)) buttons.Add(Buttons.DPadLeft); + if (this.WasButtonJustReleased(Buttons.DPadRight, state.DPad.Right, index)) buttons.Add(Buttons.DPadRight); + if (this.WasButtonJustReleased(Buttons.LeftTrigger, state.Triggers.Left, index)) buttons.Add(Buttons.LeftTrigger); + if (this.WasButtonJustReleased(Buttons.RightTrigger, state.Triggers.Right, index)) buttons.Add(Buttons.RightTrigger); } - else - SGame.DebugMessageQueue.Clear(); + return buttons.ToArray(); } /// <summary>Get whether a controller button was pressed since the last check.</summary> @@ -865,16 +802,29 @@ namespace StardewModdingAPI.Inheritance private void UpdateEventCalls() { // save loaded event - if (Game1.hasLoadedGame && this.AfterLoadTimer >= 0) + if (Constants.IsSaveLoaded && this.AfterLoadTimer >= 0) { if (this.AfterLoadTimer == 0) { SaveEvents.InvokeAfterLoad(this.Monitor); PlayerEvents.InvokeLoadedGame(this.Monitor, new EventArgsLoadedGameChanged(Game1.hasLoadedGame)); + TimeEvents.InvokeAfterDayStarted(this.Monitor); } this.AfterLoadTimer--; } + // before exit to title + if (Game1.exitToTitle) + this.IsExiting = true; + + // after exit to title + if (this.IsWorldReady && this.IsExiting && Game1.activeClickableMenu is TitleMenu) + { + SaveEvents.InvokeAfterReturnToTitle(this.Monitor); + this.AfterLoadTimer = 5; + this.IsExiting = false; + } + // input events { // get latest state @@ -939,7 +889,10 @@ namespace StardewModdingAPI.Inheritance if (newMenu is SaveGameMenu || newMenu is ShippingMenu) SaveEvents.InvokeBeforeSave(this.Monitor); else if (previousMenu is SaveGameMenu || previousMenu is ShippingMenu) + { SaveEvents.InvokeAfterSave(this.Monitor); + TimeEvents.InvokeAfterDayStarted(this.Monitor); + } // raise menu events if (newMenu != null) @@ -1027,25 +980,25 @@ namespace StardewModdingAPI.Inheritance } // raise time changed - if (Game1.timeOfDay != this.PreviousTimeOfDay) + if (Game1.timeOfDay != this.PreviousTime) { - TimeEvents.InvokeTimeOfDayChanged(this.Monitor, this.PreviousTimeOfDay, Game1.timeOfDay); - this.PreviousTimeOfDay = Game1.timeOfDay; + TimeEvents.InvokeTimeOfDayChanged(this.Monitor, this.PreviousTime, Game1.timeOfDay); + this.PreviousTime = Game1.timeOfDay; } - if (Game1.dayOfMonth != this.PreviousDayOfMonth) + if (Game1.dayOfMonth != this.PreviousDay) { - TimeEvents.InvokeDayOfMonthChanged(this.Monitor, this.PreviousDayOfMonth, Game1.dayOfMonth); - this.PreviousDayOfMonth = Game1.dayOfMonth; + TimeEvents.InvokeDayOfMonthChanged(this.Monitor, this.PreviousDay, Game1.dayOfMonth); + this.PreviousDay = Game1.dayOfMonth; } - if (Game1.currentSeason != this.PreviousSeasonOfYear) + if (Game1.currentSeason != this.PreviousSeason) { - TimeEvents.InvokeSeasonOfYearChanged(this.Monitor, this.PreviousSeasonOfYear, Game1.currentSeason); - this.PreviousSeasonOfYear = Game1.currentSeason; + TimeEvents.InvokeSeasonOfYearChanged(this.Monitor, this.PreviousSeason, Game1.currentSeason); + this.PreviousSeason = Game1.currentSeason; } - if (Game1.year != this.PreviousYearOfGame) + if (Game1.year != this.PreviousYear) { - TimeEvents.InvokeYearOfGameChanged(this.Monitor, this.PreviousYearOfGame, Game1.year); - this.PreviousYearOfGame = Game1.year; + TimeEvents.InvokeYearOfGameChanged(this.Monitor, this.PreviousYear, Game1.year); + this.PreviousYear = Game1.year; } // raise mine level changed @@ -1059,7 +1012,7 @@ namespace StardewModdingAPI.Inheritance // raise game day transition event (obsolete) if (Game1.newDay != this.PreviousIsNewDay) { - TimeEvents.InvokeOnNewDay(this.Monitor, this.PreviousDayOfMonth, Game1.dayOfMonth, Game1.newDay); + TimeEvents.InvokeOnNewDay(this.Monitor, this.PreviousDay, Game1.dayOfMonth, Game1.newDay); this.PreviousIsNewDay = Game1.newDay; } } @@ -1106,29 +1059,5 @@ namespace StardewModdingAPI.Inheritance hash ^= v.GetHashCode(); return hash; } - - /// <summary>Get reflection metadata for a private <see cref="Game1"/> field.</summary> - /// <param name="name">The field name.</param> - private FieldInfo GetBaseFieldInfo(string name) - { - return typeof(Game1).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Static); - } - - /// <summary>Get the value of a private <see cref="Game1"/> field.</summary> - /// <typeparam name="TValue">The expected value type.</typeparam> - /// <param name="name">The field name.</param> - private TValue GetBaseFieldValue<TValue>(string name) where TValue : class - { - return this.GetBaseFieldInfo(name).GetValue(Program.gamePtr) as TValue; - } - - /// <summary>Set the value of a private <see cref="Game1"/> field.</summary> - /// <typeparam name="TValue">The expected value type.</typeparam> - /// <param name="name">The field name.</param> - /// <param name="value">The value to set.</param> - public void SetBaseFieldValue<TValue>(string name, object value) where TValue : class - { - this.GetBaseFieldInfo(name).SetValue(Program.gamePtr, value as TValue); - } } -} +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Framework/Serialisation/JsonHelper.cs b/src/StardewModdingAPI/Framework/Serialisation/JsonHelper.cs new file mode 100644 index 00000000..bd15c7bb --- /dev/null +++ b/src/StardewModdingAPI/Framework/Serialisation/JsonHelper.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Microsoft.Xna.Framework.Input; +using Newtonsoft.Json; + +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)) + } + }; + + + /********* + ** 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> + public TModel ReadJsonFile<TModel>(string fullPath) + where TModel : class + { + // read file + string json; + try + { + json = File.ReadAllText(fullPath); + } + catch (Exception ex) when (ex is DirectoryNotFoundException || ex is FileNotFoundException) + { + return null; + } + + // deserialise model + return JsonConvert.DeserializeObject<TModel>(json, this.JsonSettings); + } + + /// <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> + public void WriteJsonFile<TModel>(string fullPath, TModel model) + where TModel : class + { + // create directory if needed + string dir = Path.GetDirectoryName(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/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/Serialisation/SemanticVersionConverter.cs b/src/StardewModdingAPI/Framework/Serialisation/SemanticVersionConverter.cs new file mode 100644 index 00000000..52ec999e --- /dev/null +++ b/src/StardewModdingAPI/Framework/Serialisation/SemanticVersionConverter.cs @@ -0,0 +1,51 @@ +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace StardewModdingAPI.Framework.Serialisation +{ + /// <summary>Overrides how SMAPI reads and writes <see cref="ISemanticVersion"/>.</summary> + internal class SemanticVersionConverter : 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); + } + + /// <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) + { + JObject obj = JObject.Load(reader); + int major = obj.Value<int>("MajorVersion"); + int minor = obj.Value<int>("MinorVersion"); + int patch = obj.Value<int>("PatchVersion"); + string build = obj.Value<string>("Build"); + return new SemanticVersion(major, minor, patch, build); + } + + /// <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/ICommandHelper.cs b/src/StardewModdingAPI/ICommandHelper.cs new file mode 100644 index 00000000..3a51ffb4 --- /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 + { + /********* + ** 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/IContentEventData.cs b/src/StardewModdingAPI/IContentEventData.cs new file mode 100644 index 00000000..7e2d4df1 --- /dev/null +++ b/src/StardewModdingAPI/IContentEventData.cs @@ -0,0 +1,38 @@ +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 IContentEventData<TValue> + { + /********* + ** 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="IsAssetName"/> to compare with a known path.</summary> + string AssetName { get; } + + /// <summary>The content data being read.</summary> + TValue Data { 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 IsAssetName(string path); + + /// <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); + } +} diff --git a/src/StardewModdingAPI/IContentEventHelper.cs b/src/StardewModdingAPI/IContentEventHelper.cs new file mode 100644 index 00000000..421a1e06 --- /dev/null +++ b/src/StardewModdingAPI/IContentEventHelper.cs @@ -0,0 +1,26 @@ +using System; + +namespace StardewModdingAPI +{ + /// <summary>Encapsulates access and changes to content being read from a data file.</summary> + public interface IContentEventHelper : IContentEventData<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 balue.</typeparam> + /// <exception cref="InvalidOperationException">The content being read isn't a dictionary.</exception> + IContentEventHelperForDictionary<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> + IContentEventHelperForImage 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/IContentEventHelperForDictionary.cs b/src/StardewModdingAPI/IContentEventHelperForDictionary.cs new file mode 100644 index 00000000..2f9d5a65 --- /dev/null +++ b/src/StardewModdingAPI/IContentEventHelperForDictionary.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 IContentEventHelperForDictionary<TKey, TValue> : IContentEventData<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/IContentEventHelperForImage.cs b/src/StardewModdingAPI/IContentEventHelperForImage.cs new file mode 100644 index 00000000..1158c868 --- /dev/null +++ b/src/StardewModdingAPI/IContentEventHelperForImage.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 IContentEventHelperForImage : IContentEventData<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/IModHelper.cs b/src/StardewModdingAPI/IModHelper.cs index 02f9c038..ef67cd1c 100644 --- a/src/StardewModdingAPI/IModHelper.cs +++ b/src/StardewModdingAPI/IModHelper.cs @@ -15,6 +15,9 @@ /// <summary>Metadata about loaded mods.</summary> IModRegistry ModRegistry { get; } + /// <summary>An API for managing console commands.</summary> + ICommandHelper ConsoleCommands { get; } + /********* ** Public methods 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 index 5d747eda..77943c6c 100644 --- a/src/StardewModdingAPI/IReflectionHelper.cs +++ b/src/StardewModdingAPI/IReflectionHelper.cs @@ -22,6 +22,20 @@ namespace StardewModdingAPI /// <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> diff --git a/src/StardewModdingAPI/Inheritance/SObject.cs b/src/StardewModdingAPI/Inheritance/SObject.cs deleted file mode 100644 index 0b0a7ec9..00000000 --- a/src/StardewModdingAPI/Inheritance/SObject.cs +++ /dev/null @@ -1,249 +0,0 @@ -using System; -using System.Xml.Serialization; -using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Graphics; -using StardewModdingAPI.Framework; -using StardewValley; -using Object = StardewValley.Object; - -#pragma warning disable 1591 -namespace StardewModdingAPI.Inheritance -{ - /// <summary>Provides access to the game's <see cref="Object"/> internals.</summary> - [Obsolete("This class is deprecated and will be removed in a future version.")] - public class SObject : Object - { - /********* - ** Accessors - *********/ - public string Description { get; set; } - public Texture2D Texture { get; set; } - public string CategoryName { get; set; } - public Color CategoryColour { get; set; } - public bool IsPassable { get; set; } - public bool IsPlaceable { get; set; } - public bool HasBeenRegistered { get; set; } - public int RegisteredId { get; set; } - - public int MaxStackSize { get; set; } - - public bool WallMounted { get; set; } - public Vector2 DrawPosition { get; set; } - - public bool FlaggedForPickup { get; set; } - - [XmlIgnore] - public Vector2 CurrentMouse { get; protected set; } - - [XmlIgnore] - public Vector2 PlacedAt { get; protected set; } - - public override int Stack - { - get { return this.stack; } - set { this.stack = value; } - } - - /********* - ** Public methods - *********/ - public SObject() - { - Program.DeprecationManager.Warn(nameof(SObject), "0.39.3", DeprecationLevel.PendingRemoval); - - this.name = "Modded Item Name"; - this.Description = "Modded Item Description"; - this.CategoryName = "Modded Item Category"; - this.Category = 4163; - this.CategoryColour = Color.White; - this.IsPassable = false; - this.IsPlaceable = false; - this.boundingBox = new Rectangle(0, 0, 64, 64); - this.MaxStackSize = 999; - - this.type = "interactive"; - } - - public override string Name - { - get { return this.name; } - set { this.name = value; } - } - - public override string getDescription() - { - return this.Description; - } - - public override void draw(SpriteBatch spriteBatch, int x, int y, float alpha = 1) - { - if (this.Texture != null) - { - spriteBatch.Draw(this.Texture, Game1.GlobalToLocal(Game1.viewport, new Vector2(x * Game1.tileSize + Game1.tileSize / 2 + (this.shakeTimer > 0 ? Game1.random.Next(-1, 2) : 0), y * Game1.tileSize + Game1.tileSize / 2 + (this.shakeTimer > 0 ? Game1.random.Next(-1, 2) : 0))), Game1.currentLocation.getSourceRectForObject(this.ParentSheetIndex), Color.White * alpha, 0f, new Vector2(8f, 8f), this.scale.Y > 1f ? this.getScale().Y : Game1.pixelZoom, this.flipped ? SpriteEffects.FlipHorizontally : SpriteEffects.None, (this.isPassable() ? this.getBoundingBox(new Vector2(x, y)).Top : this.getBoundingBox(new Vector2(x, y)).Bottom) / 10000f); - } - } - - public new void drawAsProp(SpriteBatch b) - { - } - - public override void drawInMenu(SpriteBatch spriteBatch, Vector2 location, float scaleSize, float transparency, float layerDepth, bool drawStackNumber) - { - if (this.isRecipe) - { - transparency = 0.5f; - scaleSize *= 0.75f; - } - - if (this.Texture != null) - { - var targSize = (int) (64 * scaleSize * 0.9f); - var midX = (int) (location.X + 32); - var midY = (int) (location.Y + 32); - - var targX = midX - targSize / 2; - var targY = midY - targSize / 2; - - spriteBatch.Draw(this.Texture, new Rectangle(targX, targY, targSize, targSize), null, new Color(255, 255, 255, transparency), 0, Vector2.Zero, SpriteEffects.None, layerDepth); - } - if (drawStackNumber) - { - var _scale = 0.5f + scaleSize; - Game1.drawWithBorder(this.stack.ToString(), Color.Black, Color.White, location + new Vector2(Game1.tileSize - Game1.tinyFont.MeasureString(string.Concat(this.stack.ToString())).X * _scale, Game1.tileSize - (float) ((double) Game1.tinyFont.MeasureString(string.Concat(this.stack.ToString())).Y * 3.0f / 4.0f) * _scale), 0.0f, _scale, 1f, true); - } - } - - public override void drawWhenHeld(SpriteBatch spriteBatch, Vector2 objectPosition, Farmer f) - { - if (this.Texture != null) - { - var targSize = 64; - var midX = (int) (objectPosition.X + 32); - var midY = (int) (objectPosition.Y + 32); - - var targX = midX - targSize / 2; - var targY = midY - targSize / 2; - - spriteBatch.Draw(this.Texture, new Rectangle(targX, targY, targSize, targSize), null, Color.White, 0, Vector2.Zero, SpriteEffects.None, (f.getStandingY() + 2) / 10000f); - } - } - - public override Color getCategoryColor() - { - return this.CategoryColour; - } - - public override string getCategoryName() - { - if (string.IsNullOrEmpty(this.CategoryName)) - return "Modded Item"; - return this.CategoryName; - } - - public override bool isPassable() - { - return this.IsPassable; - } - - public override bool isPlaceable() - { - return this.IsPlaceable; - } - - public override int maximumStackSize() - { - return this.MaxStackSize; - } - - public SObject Clone() - { - var toRet = new SObject - { - Name = this.Name, - CategoryName = this.CategoryName, - Description = this.Description, - Texture = this.Texture, - IsPassable = this.IsPassable, - IsPlaceable = this.IsPlaceable, - quality = this.quality, - scale = this.scale, - isSpawnedObject = this.isSpawnedObject, - isRecipe = this.isRecipe, - questItem = this.questItem, - stack = 1, - HasBeenRegistered = this.HasBeenRegistered, - RegisteredId = this.RegisteredId - }; - - - return toRet; - } - - public override Item getOne() - { - return this.Clone(); - } - - public override void actionWhenBeingHeld(Farmer who) - { - var x = Game1.oldMouseState.X + Game1.viewport.X; - var y = Game1.oldMouseState.Y + Game1.viewport.Y; - - x = x / Game1.tileSize; - y = y / Game1.tileSize; - - this.CurrentMouse = new Vector2(x, y); - //Program.LogDebug(canBePlacedHere(Game1.currentLocation, CurrentMouse)); - base.actionWhenBeingHeld(who); - } - - public override bool canBePlacedHere(GameLocation l, Vector2 tile) - { - //Program.LogDebug(CurrentMouse.ToString().Replace("{", "").Replace("}", "")); - if (!l.objects.ContainsKey(tile)) - return true; - - return false; - } - - public override bool placementAction(GameLocation location, int x, int y, Farmer who = null) - { - if (Game1.didPlayerJustRightClick()) - return false; - - x = x / Game1.tileSize; - y = y / Game1.tileSize; - - var key = new Vector2(x, y); - - if (!this.canBePlacedHere(location, key)) - return false; - - var s = this.Clone(); - - s.PlacedAt = key; - s.boundingBox = new Rectangle(x / Game1.tileSize * Game1.tileSize, y / Game1.tileSize * Game1.tileSize, this.boundingBox.Width, this.boundingBox.Height); - - location.objects.Add(key, s); - - return true; - } - - public override void actionOnPlayerEntry() - { - //base.actionOnPlayerEntry(); - } - - public override void drawPlacementBounds(SpriteBatch spriteBatch, GameLocation location) - { - if (this.canBePlacedHere(location, this.CurrentMouse)) - { - var targSize = Game1.tileSize; - - var x = Game1.oldMouseState.X + Game1.viewport.X; - var y = Game1.oldMouseState.Y + Game1.viewport.Y; - spriteBatch.Draw(Game1.mouseCursors, new Vector2(x / Game1.tileSize * Game1.tileSize - Game1.viewport.X, y / Game1.tileSize * Game1.tileSize - Game1.viewport.Y), new Rectangle(Utility.playerCanPlaceItemHere(location, this, x, y, Game1.player) ? 194 : 210, 388, 16, 16), Color.White, 0.0f, Vector2.Zero, Game1.pixelZoom, SpriteEffects.None, 0.01f); - } - } - } -}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Log.cs b/src/StardewModdingAPI/Log.cs index 5cb794f9..d58cebfe 100644 --- a/src/StardewModdingAPI/Log.cs +++ b/src/StardewModdingAPI/Log.cs @@ -10,18 +10,32 @@ namespace StardewModdingAPI public static class Log { /********* - ** Accessors + ** Properties *********/ + /// <summary>Manages deprecation warnings.</summary> + private static DeprecationManager DeprecationManager; + /// <summary>The underlying logger.</summary> - internal static Monitor Monitor; + private static Monitor Monitor; /// <summary>Tracks the installed mods.</summary> - internal static ModRegistry ModRegistry; + private static ModRegistry ModRegistry; /********* ** Public methods *********/ + /// <summary>Injects types required for backwards compatibility.</summary> + /// <param name="deprecationManager">Manages deprecation warnings.</param> + /// <param name="monitor">The underlying logger.</param> + /// <param name="modRegistry">Tracks the installed mods.</param> + internal static void Shim(DeprecationManager deprecationManager, Monitor monitor, ModRegistry modRegistry) + { + Log.DeprecationManager = deprecationManager; + Log.Monitor = monitor; + Log.ModRegistry = modRegistry; + } + /**** ** Exceptions ****/ @@ -292,7 +306,7 @@ namespace StardewModdingAPI /// <summary>Raise a deprecation warning.</summary> private static void WarnDeprecated() { - Program.DeprecationManager.Warn($"the {nameof(Log)} class", "1.1", DeprecationLevel.Notice); + Log.DeprecationManager.Warn($"the {nameof(Log)} class", "1.1", DeprecationLevel.Info); } /// <summary>Get the name of the mod logging a message from the stack.</summary> diff --git a/src/StardewModdingAPI/LogInfo.cs b/src/StardewModdingAPI/LogInfo.cs deleted file mode 100644 index ffef7cef..00000000 --- a/src/StardewModdingAPI/LogInfo.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; - -namespace StardewModdingAPI -{ - /// <summary>A message queued for log output.</summary> - public struct LogInfo - { - /********* - ** Accessors - *********/ - /// <summary>The message to log.</summary> - public string Message { get; set; } - - /// <summary>The log date.</summary> - public string LogDate { get; set; } - - /// <summary>The log time.</summary> - public string LogTime { get; set; } - - /// <summary>The message color.</summary> - public ConsoleColor Colour { get; set; } - - /// <summary>Whether the message should be printed to the console.</summary> - internal bool PrintConsole { get; set; } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="message">The message to log.</param> - /// <param name="color">The message color.</param> - public LogInfo(string message, ConsoleColor color = ConsoleColor.Gray) - { - if (string.IsNullOrEmpty(message)) - message = "[null]"; - this.Message = message; - this.LogDate = DateTime.Now.ToString("yyyy-MM-dd"); - this.LogTime = DateTime.Now.ToString("HH:mm:ss"); - this.Colour = color; - this.PrintConsole = true; - } - } -} diff --git a/src/StardewModdingAPI/LogWriter.cs b/src/StardewModdingAPI/LogWriter.cs deleted file mode 100644 index e22759a7..00000000 --- a/src/StardewModdingAPI/LogWriter.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using StardewModdingAPI.Framework; - -namespace StardewModdingAPI -{ - /// <summary>A log writer which queues messages for output, and periodically flushes them to the console and log file.</summary> - /// <remarks>Only one instance should be created.</remarks> - [Obsolete("This class is internal and should not be referenced outside SMAPI. It will no longer be exposed in a future version.")] - public class LogWriter - { - /********* - ** Properties - *********/ - /// <summary>Manages reading and writing to the log file.</summary> - private readonly LogFileManager LogFile; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="logFile">Manages reading and writing to the log file.</param> - internal LogWriter(LogFileManager logFile) - { - this.WarnDeprecated(); - this.LogFile = logFile; - } - - /// <summary>Queue a message for output.</summary> - /// <param name="message">The message to log.</param> - public void WriteToLog(string message) - { - this.WarnDeprecated(); - this.WriteToLog(new LogInfo(message)); - } - - /// <summary>Queue a message for output.</summary> - /// <param name="message">The message to log.</param> - public void WriteToLog(LogInfo message) - { - this.WarnDeprecated(); - string output = $"[{message.LogTime}] {message.Message}"; - if (message.PrintConsole) - { - if (Monitor.ConsoleSupportsColor) - { - Console.ForegroundColor = message.Colour; - Console.WriteLine(message); - Console.ResetColor(); - } - else - Console.WriteLine(message); - } - this.LogFile.WriteLine(output); - } - - /********* - ** Private methods - *********/ - /// <summary>Raise a deprecation warning.</summary> - private void WarnDeprecated() - { - Program.DeprecationManager.Warn($"the {nameof(LogWriter)} class", "1.0", DeprecationLevel.PendingRemoval); - } - } -}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Manifest.cs b/src/StardewModdingAPI/Manifest.cs deleted file mode 100644 index 07dd3541..00000000 --- a/src/StardewModdingAPI/Manifest.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System; -using Newtonsoft.Json; - -namespace StardewModdingAPI -{ - /// <summary>Wraps <see cref="Manifest"/> so it can implement <see cref="IManifest"/> without breaking backwards compatibility.</summary> - [Obsolete("Use " + nameof(IManifest) + " or " + nameof(Mod) + "." + nameof(Mod.ModManifest) + " instead")] - internal class ManifestImpl : Manifest, IManifest - { - /// <summary>The mod version.</summary> - [JsonProperty("Version", ObjectCreationHandling = ObjectCreationHandling.Auto/* avoids issue where Json.NET can't determine concrete type for interface */)] - public new ISemanticVersion Version - { - get { return base.Version; } - set { base.Version = (Version)value; } - } - } - - /// <summary>A manifest which describes a mod for SMAPI.</summary> - public class Manifest - { - /********* - ** 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> - public Version Version { get; set; } = new Version(0, 0, 0, "", suppressDeprecationWarning: true); - - /// <summary>The minimum SMAPI version required by this mod, if any.</summary> - public string MinimumApiVersion { get; set; } - - /// <summary>The name of the DLL in the directory that has the <see cref="Mod.Entry"/> method.</summary> - public string EntryDll { get; set; } - - /// <summary>The unique mod ID.</summary> - public string UniqueID { get; set; } - - - /**** - ** Obsolete - ****/ - /// <summary>Whether the manifest defined the deprecated <see cref="Authour"/> field.</summary> - [JsonIgnore] - internal bool UsedAuthourField { get; private set; } - - /// <summary>Obsolete.</summary> - [Obsolete("Use " + nameof(Manifest) + "." + nameof(Manifest.Author) + ".")] - public virtual string Authour - { - get { return this.Author; } - set - { - this.UsedAuthourField = true; - this.Author = value; - } - } - - /// <summary>Whether the mod uses per-save config files.</summary> - [Obsolete("Use " + nameof(Mod) + "." + nameof(Mod.Helper) + "." + nameof(IModHelper.ReadConfig) + " instead")] - public bool PerSaveConfigs { get; set; } - } -} diff --git a/src/StardewModdingAPI/Mod.cs b/src/StardewModdingAPI/Mod.cs index 0d35939d..caa20774 100644 --- a/src/StardewModdingAPI/Mod.cs +++ b/src/StardewModdingAPI/Mod.cs @@ -10,6 +10,9 @@ namespace StardewModdingAPI /********* ** Properties *********/ + /// <summary>Manages deprecation warnings.</summary> + private static DeprecationManager DeprecationManager; + /// <summary>The backing field for <see cref="Mod.PathOnDisk"/>.</summary> private string _pathOnDisk; @@ -24,17 +27,6 @@ namespace StardewModdingAPI public IMonitor Monitor { get; internal set; } /// <summary>The mod's manifest.</summary> - [Obsolete("Use " + nameof(Mod) + "." + nameof(ModManifest))] - public Manifest Manifest - { - get - { - Program.DeprecationManager.Warn($"{nameof(Mod)}.{nameof(Manifest)}", "1.5", DeprecationLevel.Notice); - return (Manifest)this.ModManifest; - } - } - - /// <summary>The mod's manifest.</summary> public IManifest ModManifest { get; internal set; } /// <summary>The full path to the mod's directory on the disk.</summary> @@ -43,7 +35,7 @@ namespace StardewModdingAPI { get { - Program.DeprecationManager.Warn($"{nameof(Mod)}.{nameof(Mod.PathOnDisk)}", "1.0", DeprecationLevel.Notice); + Mod.DeprecationManager.Warn($"{nameof(Mod)}.{nameof(Mod.PathOnDisk)}", "1.0", DeprecationLevel.Info); return this._pathOnDisk; } internal set { this._pathOnDisk = value; } @@ -55,25 +47,25 @@ namespace StardewModdingAPI { get { - Program.DeprecationManager.Warn($"{nameof(Mod)}.{nameof(Mod.BaseConfigPath)}", "1.0", DeprecationLevel.Notice); - Program.DeprecationManager.MarkWarned($"{nameof(Mod)}.{nameof(Mod.PathOnDisk)}", "1.0"); // avoid redundant warnings + Mod.DeprecationManager.Warn($"{nameof(Mod)}.{nameof(Mod.BaseConfigPath)}", "1.0", DeprecationLevel.Info); + Mod.DeprecationManager.MarkWarned($"{nameof(Mod)}.{nameof(Mod.PathOnDisk)}", "1.0"); // avoid redundant warnings return Path.Combine(this.PathOnDisk, "config.json"); } } - /// <summary>The full path to the per-save configs folder (if <see cref="StardewModdingAPI.Manifest.PerSaveConfigs"/> is <c>true</c>).</summary> + /// <summary>The full path to the per-save configs folder (if <see cref="Manifest.PerSaveConfigs"/> is <c>true</c>).</summary> [Obsolete("Use " + nameof(Mod.Helper) + "." + nameof(IModHelper.ReadJsonFile) + " instead")] public string PerSaveConfigFolder => this.GetPerSaveConfigFolder(); - /// <summary>The full path to the per-save configuration file for the current save (if <see cref="StardewModdingAPI.Manifest.PerSaveConfigs"/> is <c>true</c>).</summary> + /// <summary>The full path to the per-save configuration file for the current save (if <see cref="Manifest.PerSaveConfigs"/> is <c>true</c>).</summary> [Obsolete("Use " + nameof(Mod.Helper) + "." + nameof(IModHelper.ReadJsonFile) + " instead")] public string PerSaveConfigPath { get { - Program.DeprecationManager.Warn($"{nameof(Mod)}.{nameof(Mod.PerSaveConfigPath)}", "1.0", DeprecationLevel.Info); - Program.DeprecationManager.MarkWarned($"{nameof(Mod)}.{nameof(Mod.PerSaveConfigFolder)}", "1.0"); // avoid redundant warnings - return Constants.CurrentSavePathExists ? Path.Combine(this.PerSaveConfigFolder, Constants.SaveFolderName + ".json") : ""; + Mod.DeprecationManager.Warn($"{nameof(Mod)}.{nameof(Mod.PerSaveConfigPath)}", "1.0", DeprecationLevel.Info); + Mod.DeprecationManager.MarkWarned($"{nameof(Mod)}.{nameof(Mod.PerSaveConfigFolder)}", "1.0"); // avoid redundant warnings + return Constants.IsSaveLoaded ? Path.Combine(this.PerSaveConfigFolder, $"{Constants.SaveFolderName}.json") : ""; } } @@ -81,31 +73,33 @@ namespace StardewModdingAPI /********* ** Public methods *********/ + /// <summary>Injects types required for backwards compatibility.</summary> + /// <param name="deprecationManager">Manages deprecation warnings.</param> + internal static void Shim(DeprecationManager deprecationManager) + { + Mod.DeprecationManager = deprecationManager; + } + /// <summary>The mod entry point, called after the mod is first loaded.</summary> [Obsolete("This overload is obsolete since SMAPI 1.0.")] public virtual void Entry(params object[] objects) { } /// <summary>The mod entry point, called after the mod is first loaded.</summary> /// <param name="helper">Provides simplified APIs for writing mods.</param> - [Obsolete("This overload is obsolete since SMAPI 1.1.")] - public virtual void Entry(ModHelper helper) { } - - /// <summary>The mod entry point, called after the mod is first loaded.</summary> - /// <param name="helper">Provides simplified APIs for writing mods.</param> public virtual void Entry(IModHelper helper) { } /********* ** Private methods *********/ - /// <summary>Get the full path to the per-save configuration file for the current save (if <see cref="StardewModdingAPI.Manifest.PerSaveConfigs"/> is <c>true</c>).</summary> + /// <summary>Get the full path to the per-save configuration file for the current save (if <see cref="Manifest.PerSaveConfigs"/> is <c>true</c>).</summary> [Obsolete] private string GetPerSaveConfigFolder() { - Program.DeprecationManager.Warn($"{nameof(Mod)}.{nameof(Mod.PerSaveConfigFolder)}", "1.0", DeprecationLevel.Notice); - Program.DeprecationManager.MarkWarned($"{nameof(Mod)}.{nameof(Mod.PathOnDisk)}", "1.0"); // avoid redundant warnings + Mod.DeprecationManager.Warn($"{nameof(Mod)}.{nameof(Mod.PerSaveConfigFolder)}", "1.0", DeprecationLevel.Info); + Mod.DeprecationManager.MarkWarned($"{nameof(Mod)}.{nameof(Mod.PathOnDisk)}", "1.0"); // avoid redundant warnings - if (!((Manifest)this.Manifest).PerSaveConfigs) + if (!((Manifest)this.ModManifest).PerSaveConfigs) { this.Monitor.Log("Tried to fetch the per-save config folder, but this mod isn't configured to use per-save config files.", LogLevel.Error); return ""; 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 index 45bf1238..58850dc3 100644 --- a/src/StardewModdingAPI/Program.cs +++ b/src/StardewModdingAPI/Program.cs @@ -1,11 +1,13 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Threading; #if SMAPI_FOR_WINDOWS +using System.Management; using System.Windows.Forms; #endif using Microsoft.Xna.Framework.Graphics; @@ -13,76 +15,49 @@ using Newtonsoft.Json; using StardewModdingAPI.AssemblyRewriters; using StardewModdingAPI.Events; using StardewModdingAPI.Framework; +using StardewModdingAPI.Framework.Logging; using StardewModdingAPI.Framework.Models; -using StardewModdingAPI.Inheritance; +using StardewModdingAPI.Framework.Serialisation; using StardewValley; using Monitor = StardewModdingAPI.Framework.Monitor; namespace StardewModdingAPI { /// <summary>The main entry point for SMAPI, responsible for hooking into and launching the game.</summary> - public class Program + internal class Program { /********* ** Properties *********/ - /// <summary>The target game platform.</summary> - private static readonly Platform TargetPlatform = -#if SMAPI_FOR_WINDOWS - Platform.Windows; -#else - Platform.Mono; -#endif - - /// <summary>The full path to the Stardew Valley executable.</summary> - private static readonly string GameExecutablePath = Path.Combine(Constants.ExecutionPath, Program.TargetPlatform == Platform.Windows ? "Stardew Valley.exe" : "StardewValley.exe"); - - /// <summary>The full path to the folder containing mods.</summary> - private static readonly string ModPath = Path.Combine(Constants.ExecutionPath, "Mods"); - /// <summary>The log file to which to write messages.</summary> - private static readonly LogFileManager LogFile = new LogFileManager(Constants.LogPath); + private readonly LogFileManager LogFile; + + /// <summary>Manages console output interception.</summary> + private readonly ConsoleInterceptionManager ConsoleManager = new ConsoleInterceptionManager(); /// <summary>The core logger for SMAPI.</summary> - private static readonly Monitor Monitor = new Monitor("SMAPI", Program.LogFile); + private readonly Monitor Monitor; - /// <summary>The user settings for SMAPI.</summary> - private static UserSettings Settings; + /// <summary>The SMAPI configuration settings.</summary> + private readonly SConfig Settings; /// <summary>Tracks whether the game should exit immediately and any pending initialisation should be cancelled.</summary> - private static readonly CancellationTokenSource CancellationTokenSource = new CancellationTokenSource(); - - - /********* - ** Accessors - *********/ - /// <summary>The number of mods currently loaded by SMAPI.</summary> - public static int ModsLoaded; - - /// <summary>The underlying game instance.</summary> - public static SGame gamePtr; + private readonly CancellationTokenSource CancellationTokenSource = new CancellationTokenSource(); /// <summary>Whether the game is currently running.</summary> - public static bool ready; - - /// <summary>The underlying game assembly.</summary> - public static Assembly StardewAssembly; - - /// <summary>The underlying <see cref="StardewValley.Program"/> type.</summary> - public static Type StardewProgramType; + private bool IsGameRunning; - /// <summary>The field containing game's main instance.</summary> - public static FieldInfo StardewGameInfo; - - // ReSharper disable once PossibleNullReferenceException - /// <summary>The game's build type (i.e. GOG vs Steam).</summary> - public static int BuildType => (int)Program.StardewProgramType.GetField("buildType", BindingFlags.Public | BindingFlags.Static).GetValue(null); + /// <summary>The underlying game instance.</summary> + private SGame GameInstance; /// <summary>Tracks the installed mods.</summary> - internal static readonly ModRegistry ModRegistry = new ModRegistry(); + private readonly ModRegistry ModRegistry; /// <summary>Manages deprecation warnings.</summary> - internal static readonly DeprecationManager DeprecationManager = new DeprecationManager(Program.Monitor, Program.ModRegistry); + private readonly DeprecationManager DeprecationManager; + + /// <summary>Manages console commands.</summary> + private readonly CommandManager CommandManager = new CommandManager(); /********* @@ -92,101 +67,141 @@ namespace StardewModdingAPI /// <param name="args">The command-line arguments.</param> private static void Main(string[] args) { - // set log options - Program.Monitor.WriteToConsole = !args.Contains("--no-terminal"); - Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("en-GB"); // for consistent log formatting - - // add info header - Program.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Game1.version} on {Environment.OSVersion}", LogLevel.Info); + // get flags from arguments + bool writeToConsole = !args.Contains("--no-terminal"); - // initialise user settings + // get log path from arguments + string logPath = null; { - string settingsPath = Constants.ApiConfigPath; - if (File.Exists(settingsPath)) + int pathIndex = Array.LastIndexOf(args, "--log-path") + 1; + if (pathIndex >= 1 && args.Length >= pathIndex) { - string json = File.ReadAllText(settingsPath); - Program.Settings = JsonConvert.DeserializeObject<UserSettings>(json); + logPath = args[pathIndex]; + if (!Path.IsPathRooted(logPath)) + logPath = Path.Combine(Constants.LogDir, logPath); } - else - Program.Settings = new UserSettings(); + } + if (string.IsNullOrWhiteSpace(logPath)) + logPath = Constants.DefaultLogPath; - File.WriteAllText(settingsPath, JsonConvert.SerializeObject(Program.Settings, Formatting.Indented)); + // load SMAPI + new Program(writeToConsole, logPath) + .LaunchInteractively(); + } + + /// <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> + internal Program(bool writeToConsole, string logPath) + { + // load settings + this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath)); + + // initialise + this.LogFile = new LogFileManager(logPath); + this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.ExitGameImmediately) { WriteToConsole = writeToConsole }; + this.ModRegistry = new ModRegistry(this.Settings.ModCompatibility); + this.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry); + } + + /// <summary>Launch SMAPI.</summary> + internal void LaunchInteractively() + { + // initialise logging + Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("en-GB"); // for consistent log formatting + this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GetGameDisplayVersion(Constants.GameVersion)} on {this.GetFriendlyPlatformName()}", LogLevel.Info); + Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GetGameDisplayVersion(Constants.GameVersion)}"; + + // inject compatibility shims +#pragma warning disable 618 + Command.Shim(this.CommandManager, this.DeprecationManager, this.ModRegistry); + Config.Shim(this.DeprecationManager); + InternalExtensions.Shim(this.ModRegistry); + Log.Shim(this.DeprecationManager, this.GetSecondaryMonitor("legacy mod"), this.ModRegistry); + Mod.Shim(this.DeprecationManager); + ContentEvents.Shim(this.ModRegistry, this.Monitor); + PlayerEvents.Shim(this.DeprecationManager); + TimeEvents.Shim(this.DeprecationManager); +#pragma warning restore 618 + + // redirect direct console output + { + Monitor monitor = this.GetSecondaryMonitor("Console.Out"); + monitor.WriteToFile = false; // not useful for troubleshooting mods per discussion + if (monitor.WriteToConsole) + this.ConsoleManager.OnLineIntercepted += line => monitor.Log(line, LogLevel.Trace); } // add warning headers - if (Program.Settings.DeveloperMode) + if (this.Settings.DeveloperMode) { - Program.Monitor.ShowTraceInConsole = true; - Program.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 or deleting {Constants.ApiConfigPath}.", LogLevel.Warn); + this.Monitor.ShowTraceInConsole = 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.Warn); } - if (!Program.Settings.CheckForUpdates) - Program.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 editing or deleting {Constants.ApiConfigPath}.", LogLevel.Warn); - if (!Program.Monitor.WriteToConsole) - Program.Monitor.Log("Writing to the terminal is disabled because the --no-terminal argument was received. This usually means launching the terminal failed.", LogLevel.Warn); + 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); // print file paths - Program.Monitor.Log($"Mods go here: {Program.ModPath}"); - - // initialise legacy log - Log.Monitor = Program.GetSecondaryMonitor("legacy mod"); - Log.ModRegistry = Program.ModRegistry; + this.Monitor.Log($"Mods go here: {Constants.ModPath}"); // hook into & launch the game try { // verify version - if (String.Compare(Game1.version, Constants.MinimumGameVersion, StringComparison.InvariantCultureIgnoreCase) < 0) + if (Constants.GameVersion.IsOlderThan(Constants.MinimumGameVersion)) { - Program.Monitor.Log($"Oops! You're running Stardew Valley {Game1.version}, but the oldest supported version is {Constants.MinimumGameVersion}. Please update your game before using SMAPI. If you're on the Steam beta channel, note that the beta channel may not receive the latest updates.", LogLevel.Error); + this.Monitor.Log($"Oops! You're running Stardew Valley {Constants.GetGameDisplayVersion(Constants.GameVersion)}, but the oldest supported version is {Constants.GetGameDisplayVersion(Constants.MinimumGameVersion)}. Please update your game before using SMAPI. If you have the beta version on Steam, you may need to opt out to get the latest non-beta updates.", LogLevel.Error); + this.PressAnyKeyToExit(); return; } - - // initialise - Program.Monitor.Log("Loading SMAPI..."); - Console.Title = Constants.ConsoleTitle; - Program.VerifyPath(Program.ModPath); - Program.VerifyPath(Constants.LogDir); - if (!File.Exists(Program.GameExecutablePath)) + if (Constants.MaximumGameVersion != null && Constants.GameVersion.IsNewerThan(Constants.MaximumGameVersion)) { - Program.Monitor.Log($"Couldn't find executable: {Program.GameExecutablePath}", LogLevel.Error); - Program.PressAnyKeyToExit(); + this.Monitor.Log($"Oops! You're running Stardew Valley {Constants.GetGameDisplayVersion(Constants.GameVersion)}, but this version of SMAPI is only compatible up to Stardew Valley {Constants.GetGameDisplayVersion(Constants.MaximumGameVersion)}. Please check for a newer version of SMAPI.", LogLevel.Error); + this.PressAnyKeyToExit(); return; } + // initialise folders + this.Monitor.Log("Loading SMAPI..."); + this.VerifyPath(Constants.ModPath); + this.VerifyPath(Constants.LogDir); + // check for update when game loads - if (Program.Settings.CheckForUpdates) - GameEvents.GameLoaded += (sender, e) => Program.CheckForUpdateAsync(); + if (this.Settings.CheckForUpdates) + GameEvents.GameLoaded += (sender, e) => this.CheckForUpdateAsync(); // launch game - Program.StartGame(); + this.StartGame(); } catch (Exception ex) { - Program.Monitor.Log($"Critical error: {ex.GetLogSummary()}", LogLevel.Error); + this.Monitor.Log($"Critical error: {ex.GetLogSummary()}", LogLevel.Error); } - Program.PressAnyKeyToExit(); + this.PressAnyKeyToExit(); } /// <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="module">The module which requested an immediate exit.</param> /// <param name="reason">The reason provided for the shutdown.</param> - internal static void ExitGameImmediately(string module, string reason) + internal void ExitGameImmediately(string module, string reason) { - Program.Monitor.LogFatal($"{module} requested an immediate game shutdown: {reason}"); - Program.CancellationTokenSource.Cancel(); - if (Program.ready) + this.Monitor.LogFatal($"{module} requested an immediate game shutdown: {reason}"); + this.CancellationTokenSource.Cancel(); + if (this.IsGameRunning) { - Program.gamePtr.Exiting += (sender, e) => Program.PressAnyKeyToExit(); - Program.gamePtr.Exit(); + this.GameInstance.Exiting += (sender, e) => this.PressAnyKeyToExit(); + this.GameInstance.Exit(); } } /// <summary>Get a monitor for legacy code which doesn't have one passed in.</summary> [Obsolete("This method should only be used when needed for backwards compatibility.")] - internal static IMonitor GetLegacyMonitorForMod() + internal IMonitor GetLegacyMonitorForMod() { - string modName = Program.ModRegistry.GetModFromStack() ?? "unknown"; - return Program.GetSecondaryMonitor(modName); + string modName = this.ModRegistry.GetModFromStack() ?? "unknown"; + return this.GetSecondaryMonitor(modName); } @@ -194,7 +209,7 @@ namespace StardewModdingAPI ** Private methods *********/ /// <summary>Asynchronously check for a new version of SMAPI, and print a message to the console if an update is available.</summary> - private static void CheckForUpdateAsync() + private void CheckForUpdateAsync() { new Thread(() => { @@ -203,49 +218,47 @@ namespace StardewModdingAPI GitRelease release = UpdateHelper.GetLatestVersionAsync(Constants.GitHubRepository).Result; ISemanticVersion latestVersion = new SemanticVersion(release.Tag); if (latestVersion.IsNewerThan(Constants.ApiVersion)) - Program.Monitor.Log($"You can update SMAPI from version {Constants.ApiVersion} to {latestVersion}", LogLevel.Alert); + this.Monitor.Log($"You can update SMAPI from version {Constants.ApiVersion} to {latestVersion}", LogLevel.Alert); } catch (Exception ex) { - Program.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.\n{ex.GetLogSummary()}"); + 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.\n{ex.GetLogSummary()}"); } }).Start(); } /// <summary>Hook into Stardew Valley and launch the game.</summary> - private static void StartGame() + private void StartGame() { try { - // load the game assembly - Program.Monitor.Log("Loading game..."); - Program.StardewAssembly = Assembly.UnsafeLoadFrom(Program.GameExecutablePath); - Program.StardewProgramType = Program.StardewAssembly.GetType("StardewValley.Program", true); - Program.StardewGameInfo = Program.StardewProgramType.GetField("gamePtr"); - Game1.version += $" | SMAPI {Constants.ApiVersion}"; - - // add error interceptors + this.Monitor.Log("Loading game..."); + + // add error handlers #if SMAPI_FOR_WINDOWS - Application.ThreadException += (sender, e) => Program.Monitor.Log($"Critical thread exception: {e.Exception.GetLogSummary()}", LogLevel.Error); + 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) => Program.Monitor.Log($"Critical app domain exception: {e.ExceptionObject}", LogLevel.Error); + AppDomain.CurrentDomain.UnhandledException += (sender, e) => this.Monitor.Log($"Critical app domain exception: {e.ExceptionObject}", LogLevel.Error); - // initialise game instance - Program.gamePtr = new SGame(Program.Monitor) { IsMouseVisible = false }; - Program.gamePtr.Exiting += (sender, e) => Program.ready = false; - Program.gamePtr.Window.ClientSizeChanged += (sender, e) => GraphicsEvents.InvokeResize(Program.Monitor, sender, e); - Program.gamePtr.Window.Title = $"Stardew Valley - Version {Game1.version}"; - Program.StardewGameInfo.SetValue(Program.StardewProgramType, Program.gamePtr); + // override Game1 instance + this.GameInstance = new SGame(this.Monitor); + this.GameInstance.Exiting += (sender, e) => this.IsGameRunning = false; + this.GameInstance.Window.ClientSizeChanged += (sender, e) => GraphicsEvents.InvokeResize(this.Monitor, sender, e); + this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} with SMAPI {Constants.ApiVersion}"; + { + Type type = typeof(Game1).Assembly.GetType("StardewValley.Program", true); + type.GetField("gamePtr").SetValue(null, this.GameInstance); + } - // patch graphics + // configure Game1.graphics.GraphicsProfile = GraphicsProfile.HiDef; // load mods - Program.LoadMods(); - if (Program.CancellationTokenSource.IsCancellationRequested) + this.LoadMods(); + if (this.CancellationTokenSource.IsCancellationRequested) { - Program.Monitor.Log("Shutdown requested; interrupting initialisation.", LogLevel.Error); + this.Monitor.Log("Shutdown requested; interrupting initialisation.", LogLevel.Error); return; } @@ -253,17 +266,18 @@ namespace StardewModdingAPI new Thread(() => { // wait for the game to load up - while (!Program.ready) Thread.Sleep(1000); + while (!this.IsGameRunning) + Thread.Sleep(1000); // register help command - Command.RegisterCommand("help", "Lists all commands | 'help <cmd>' returns command description").CommandFired += Program.help_CommandFired; + this.CommandManager.Add("SMAPI", "help", "Lists all commands | 'help <cmd>' returns command description", this.HandleHelpCommand); // listen for command line input - Program.Monitor.Log("Starting console..."); - Program.Monitor.Log("Type 'help' for help, or 'help <cmd>' for a command's usage", LogLevel.Info); - Thread consoleInputThread = new Thread(Program.ConsoleInputLoop); + this.Monitor.Log("Starting console..."); + this.Monitor.Log("Type 'help' for help, or 'help <cmd>' for a command's usage", LogLevel.Info); + Thread consoleInputThread = new Thread(this.ConsoleInputLoop); consoleInputThread.Start(); - while (Program.ready) + while (this.IsGameRunning) Thread.Sleep(1000 / 10); // Check if the game is still running 10 times a second // abort the console thread, we're closing @@ -272,31 +286,31 @@ namespace StardewModdingAPI }).Start(); // start game loop - Program.Monitor.Log("Starting game..."); - if (Program.CancellationTokenSource.IsCancellationRequested) + this.Monitor.Log("Starting game..."); + if (this.CancellationTokenSource.IsCancellationRequested) { - Program.Monitor.Log("Shutdown requested; interrupting initialisation.", LogLevel.Error); + this.Monitor.Log("Shutdown requested; interrupting initialisation.", LogLevel.Error); return; } try { - Program.ready = true; - Program.gamePtr.Run(); + this.IsGameRunning = true; + this.GameInstance.Run(); } finally { - Program.ready = false; + this.IsGameRunning = false; } } catch (Exception ex) { - Program.Monitor.Log($"The game encountered a fatal error:\n{ex.GetLogSummary()}", LogLevel.Error); + this.Monitor.Log($"The game encountered a fatal error:\n{ex.GetLogSummary()}", LogLevel.Error); } } /// <summary>Create a directory path if it doesn't exist.</summary> /// <param name="path">The directory path.</param> - private static void VerifyPath(string path) + private void VerifyPath(string path) { try { @@ -305,109 +319,97 @@ namespace StardewModdingAPI } catch (Exception ex) { - Program.Monitor.Log($"Couldn't create a path: {path}\n\n{ex.GetLogSummary()}", LogLevel.Error); + this.Monitor.Log($"Couldn't create a path: {path}\n\n{ex.GetLogSummary()}", LogLevel.Error); } } /// <summary>Load and hook up all mods in the mod directory.</summary> - private static void LoadMods() + private void LoadMods() { - Program.Monitor.Log("Loading mods..."); + this.Monitor.Log("Loading mods..."); + + // get JSON helper + JsonHelper jsonHelper = new JsonHelper(); // get assembly loader - AssemblyLoader modAssemblyLoader = new AssemblyLoader(Program.TargetPlatform, Program.Monitor); + AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.TargetPlatform, this.Monitor); AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => modAssemblyLoader.ResolveAssembly(e.Name); - // get known incompatible mods - IDictionary<string, IncompatibleMod> incompatibleMods; - try - { - incompatibleMods = File.Exists(Constants.ApiModMetadataPath) - ? JsonConvert.DeserializeObject<IncompatibleMod[]>(File.ReadAllText(Constants.ApiModMetadataPath)).ToDictionary(p => p.ID, p => p) - : new Dictionary<string, IncompatibleMod>(0); - } - catch (Exception ex) - { - incompatibleMods = new Dictionary<string, IncompatibleMod>(); - Program.Monitor.Log($"Couldn't read metadata file at {Constants.ApiModMetadataPath}. SMAPI will still run, but some features may be disabled.\n{ex}", LogLevel.Warn); - } - // load mod assemblies + int modsLoaded = 0; List<Action> deprecationWarnings = new List<Action>(); // queue up deprecation warnings to show after mod list - foreach (string directory in Directory.GetDirectories(Program.ModPath)) + foreach (string directoryPath in Directory.GetDirectories(Constants.ModPath)) { - string directoryName = new DirectoryInfo(directory).Name; + // passthrough empty directories + DirectoryInfo directory = new DirectoryInfo(directoryPath); + while (!directory.GetFiles().Any() && directory.GetDirectories().Length == 1) + directory = directory.GetDirectories().First(); // check for cancellation - if (Program.CancellationTokenSource.IsCancellationRequested) + if (this.CancellationTokenSource.IsCancellationRequested) { - Program.Monitor.Log("Shutdown requested; interrupting mod loading.", LogLevel.Error); + this.Monitor.Log("Shutdown requested; interrupting mod loading.", LogLevel.Error); return; } - // get helper - IModHelper helper = new ModHelper(directory, Program.ModRegistry); - // get manifest path - string manifestPath = Path.Combine(directory, "manifest.json"); + string manifestPath = Path.Combine(directory.FullName, "manifest.json"); if (!File.Exists(manifestPath)) { - Program.Monitor.Log($"Ignored folder \"{directoryName}\" which doesn't have a manifest.json.", LogLevel.Warn); + this.Monitor.Log($"Ignored folder \"{directory.Name}\" which doesn't have a manifest.json.", LogLevel.Warn); continue; } - string errorPrefix = $"Couldn't load mod for manifest '{manifestPath}'"; + string skippedPrefix = $"Skipped {manifestPath.Replace(Constants.ModPath, "").Trim('/', '\\')}"; // read manifest - ManifestImpl manifest; + Manifest manifest; try { // read manifest text string json = File.ReadAllText(manifestPath); if (string.IsNullOrEmpty(json)) { - Program.Monitor.Log($"{errorPrefix}: manifest is empty.", LogLevel.Error); + this.Monitor.Log($"{skippedPrefix} because the manifest is empty.", LogLevel.Error); continue; } // deserialise manifest - manifest = helper.ReadJsonFile<ManifestImpl>("manifest.json"); + manifest = jsonHelper.ReadJsonFile<Manifest>(Path.Combine(directory.FullName, "manifest.json")); if (manifest == null) { - Program.Monitor.Log($"{errorPrefix}: the manifest file does not exist.", LogLevel.Error); + this.Monitor.Log($"{skippedPrefix} because its manifest is invalid.", LogLevel.Error); continue; } if (string.IsNullOrEmpty(manifest.EntryDll)) { - Program.Monitor.Log($"{errorPrefix}: manifest doesn't specify an entry DLL.", LogLevel.Error); + this.Monitor.Log($"{skippedPrefix} because its manifest doesn't specify an entry DLL.", LogLevel.Error); continue; } - - // log deprecated fields - if (manifest.UsedAuthourField) - deprecationWarnings.Add(() => Program.DeprecationManager.Warn(manifest.Name, $"{nameof(Manifest)}.{nameof(Manifest.Authour)}", "1.0", DeprecationLevel.Notice)); } catch (Exception ex) { - Program.Monitor.Log($"{errorPrefix}: manifest parsing failed.\n{ex.GetLogSummary()}", LogLevel.Error); + this.Monitor.Log($"{skippedPrefix} because manifest parsing failed.\n{ex.GetLogSummary()}", LogLevel.Error); continue; } + if (!string.IsNullOrWhiteSpace(manifest.Name)) + skippedPrefix = $"Skipped {manifest.Name}"; - // validate known incompatible mods - IncompatibleMod compatibility; - if (incompatibleMods.TryGetValue(manifest.UniqueID ?? $"{manifest.Name}|{manifest.Author}|{manifest.EntryDll}", out compatibility)) + // validate compatibility + ModCompatibility compatibility = this.ModRegistry.GetCompatibilityRecord(manifest); + if (compatibility?.Compatibility == ModCompatibilityType.AssumeBroken) { - if (!compatibility.IsCompatible(manifest.Version)) - { - string reasonPhrase = compatibility.ReasonPhrase ?? "this version is not compatible with the latest version of the game"; - string warning = $"Skipped {compatibility.Name} {manifest.Version} because {reasonPhrase}. Please check for a newer version of the mod here:"; - if (!string.IsNullOrWhiteSpace(compatibility.UpdateUrl)) - warning += $"{Environment.NewLine}- official mod: {compatibility.UpdateUrl}"; - if (!string.IsNullOrWhiteSpace(compatibility.UnofficialUpdateUrl)) - warning += $"{Environment.NewLine}- unofficial update: {compatibility.UnofficialUpdateUrl}"; - - Program.Monitor.Log(warning, LogLevel.Error); - continue; - } + bool hasOfficialUrl = !string.IsNullOrWhiteSpace(compatibility.UpdateUrl); + bool hasUnofficialUrl = !string.IsNullOrWhiteSpace(compatibility.UnofficialUpdateUrl); + + string reasonPhrase = compatibility.ReasonPhrase ?? "it's not compatible with the latest version of the game"; + string warning = $"{skippedPrefix} because {reasonPhrase}. Please check for a version newer than {compatibility.UpperVersion} here:"; + if (hasOfficialUrl) + warning += !hasUnofficialUrl ? $" {compatibility.UpdateUrl}" : $"{Environment.NewLine}- official mod: {compatibility.UpdateUrl}"; + if (hasUnofficialUrl) + warning += $"{Environment.NewLine}- unofficial update: {compatibility.UnofficialUpdateUrl}"; + + this.Monitor.Log(warning, LogLevel.Error); + continue; } // validate SMAPI version @@ -418,13 +420,13 @@ namespace StardewModdingAPI ISemanticVersion minVersion = new SemanticVersion(manifest.MinimumApiVersion); if (minVersion.IsNewerThan(Constants.ApiVersion)) { - Program.Monitor.Log($"{errorPrefix}: this mod requires SMAPI {minVersion} or later. Please update SMAPI to the latest version to use this mod.", LogLevel.Error); + this.Monitor.Log($"{skippedPrefix} because it needs SMAPI {minVersion} or later. Please update SMAPI to the latest version to use this mod.", LogLevel.Error); continue; } } catch (FormatException ex) when (ex.Message.Contains("not a valid semantic version")) { - Program.Monitor.Log($"{errorPrefix}: the mod specified an invalid minimum SMAPI version '{manifest.MinimumApiVersion}'. This should be a semantic version number like {Constants.Version}.", LogLevel.Error); + this.Monitor.Log($"{skippedPrefix} because it has an invalid minimum SMAPI version '{manifest.MinimumApiVersion}'. This should be a semantic version number like {Constants.ApiVersion}.", LogLevel.Error); continue; } } @@ -432,29 +434,29 @@ namespace StardewModdingAPI // create per-save directory if (manifest.PerSaveConfigs) { - deprecationWarnings.Add(() => Program.DeprecationManager.Warn(manifest.Name, $"{nameof(Manifest)}.{nameof(Manifest.PerSaveConfigs)}", "1.0", DeprecationLevel.Info)); + deprecationWarnings.Add(() => this.DeprecationManager.Warn(manifest.Name, $"{nameof(Manifest)}.{nameof(Manifest.PerSaveConfigs)}", "1.0", DeprecationLevel.Info)); try { - string psDir = Path.Combine(directory, "psconfigs"); + string psDir = Path.Combine(directory.FullName, "psconfigs"); Directory.CreateDirectory(psDir); if (!Directory.Exists(psDir)) { - Program.Monitor.Log($"{errorPrefix}: couldn't create the per-save configuration directory ('psconfigs') requested by this mod. The failure reason is unknown.", LogLevel.Error); + this.Monitor.Log($"{skippedPrefix} because it requires per-save configuration files ('psconfigs') which couldn't be created for some reason.", LogLevel.Error); continue; } } catch (Exception ex) { - Program.Monitor.Log($"{errorPrefix}: couldn't create the per-save configuration directory ('psconfigs') requested by this mod.\n{ex.GetLogSummary()}", LogLevel.Error); + this.Monitor.Log($"{skippedPrefix} because it requires per-save configuration files ('psconfigs') which couldn't be created:\n{ex.GetLogSummary()}", LogLevel.Error); continue; } } // validate mod path to simplify errors - string assemblyPath = Path.Combine(directory, manifest.EntryDll); + string assemblyPath = Path.Combine(directory.FullName, manifest.EntryDll); if (!File.Exists(assemblyPath)) { - Program.Monitor.Log($"{errorPrefix}: the entry DLL '{manifest.EntryDll}' does not exist.", LogLevel.Error); + this.Monitor.Log($"{skippedPrefix} because its DLL '{manifest.EntryDll}' doesn't exist.", LogLevel.Error); continue; } @@ -462,121 +464,139 @@ namespace StardewModdingAPI Assembly modAssembly; try { - modAssembly = modAssemblyLoader.Load(assemblyPath); + modAssembly = modAssemblyLoader.Load(assemblyPath, assumeCompatible: compatibility?.Compatibility == ModCompatibilityType.AssumeCompatible); + } + catch (IncompatibleInstructionException ex) + { + this.Monitor.Log($"{skippedPrefix} because it's not compatible with the latest version of the game (detected {ex.NounPhrase}). Please check for a newer version of the mod (you have v{manifest.Version}).", LogLevel.Error); + continue; } catch (Exception ex) { - Program.Monitor.Log($"{errorPrefix}: an error occurred while preprocessing '{manifest.EntryDll}'.\n{ex.GetLogSummary()}", LogLevel.Error); + this.Monitor.Log($"{skippedPrefix} because its DLL '{manifest.EntryDll}' couldn't be loaded.\n{ex.GetLogSummary()}", LogLevel.Error); continue; } // validate assembly try { - if (modAssembly.DefinedTypes.Count(x => x.BaseType == typeof(Mod)) == 0) + int modEntries = modAssembly.DefinedTypes.Count(type => typeof(Mod).IsAssignableFrom(type) && !type.IsAbstract); + if (modEntries == 0) + { + this.Monitor.Log($"{skippedPrefix} because its DLL has no '{nameof(Mod)}' subclass.", LogLevel.Error); + continue; + } + if (modEntries > 1) { - Program.Monitor.Log($"{errorPrefix}: the mod DLL does not contain an implementation of the 'Mod' class.", LogLevel.Error); + this.Monitor.Log($"{skippedPrefix} because its DLL contains multiple '{nameof(Mod)}' subclasses.", LogLevel.Error); continue; } } catch (Exception ex) { - Program.Monitor.Log($"{errorPrefix}: an error occurred while reading the mod DLL.\n{ex.GetLogSummary()}", LogLevel.Error); + this.Monitor.Log($"{skippedPrefix} because its DLL couldn't be loaded.\n{ex.GetLogSummary()}", LogLevel.Error); continue; } // initialise mod - Mod mod; try { // get implementation - TypeInfo modEntryType = modAssembly.DefinedTypes.First(x => x.BaseType == typeof(Mod)); - mod = (Mod)modAssembly.CreateInstance(modEntryType.ToString()); + TypeInfo modEntryType = modAssembly.DefinedTypes.First(type => typeof(Mod).IsAssignableFrom(type) && !type.IsAbstract); + Mod mod = (Mod)modAssembly.CreateInstance(modEntryType.ToString()); if (mod == null) { - Program.Monitor.Log($"{errorPrefix}: the mod's entry class could not be instantiated."); + this.Monitor.Log($"{skippedPrefix} because its entry class couldn't be instantiated."); continue; } // inject data + // get helper mod.ModManifest = manifest; - mod.Helper = helper; - mod.Monitor = Program.GetSecondaryMonitor(manifest.Name); - mod.PathOnDisk = directory; + mod.Helper = new ModHelper(manifest.Name, directory.FullName, jsonHelper, this.ModRegistry, this.CommandManager); + mod.Monitor = this.GetSecondaryMonitor(manifest.Name); + mod.PathOnDisk = directory.FullName; // track mod - Program.ModRegistry.Add(mod); - Program.ModsLoaded += 1; - Program.Monitor.Log($"Loaded mod: {manifest.Name} by {manifest.Author}, v{manifest.Version} | {manifest.Description}", LogLevel.Info); + this.ModRegistry.Add(mod); + modsLoaded += 1; + this.Monitor.Log($"Loaded {manifest.Name} by {manifest.Author}, v{manifest.Version} | {manifest.Description}", LogLevel.Info); } catch (Exception ex) { - Program.Monitor.Log($"{errorPrefix}: an error occurred while loading the target DLL.\n{ex.GetLogSummary()}", LogLevel.Error); - continue; + this.Monitor.Log($"{skippedPrefix} because initialisation failed:\n{ex.GetLogSummary()}", LogLevel.Error); } } - // log deprecation warnings - foreach (Action warning in deprecationWarnings) - warning(); - deprecationWarnings = null; - // initialise mods - foreach (Mod mod in Program.ModRegistry.GetMods()) + foreach (Mod mod in this.ModRegistry.GetMods()) { try { // call entry methods mod.Entry(); // deprecated since 1.0 - mod.Entry((ModHelper)mod.Helper); // deprecated since 1.1 mod.Entry(mod.Helper); // raise deprecation warning for old Entry() methods - if (Program.DeprecationManager.IsVirtualMethodImplemented(mod.GetType(), typeof(Mod), nameof(Mod.Entry), new[] { typeof(object[]) })) - Program.DeprecationManager.Warn(mod.ModManifest.Name, $"{nameof(Mod)}.{nameof(Mod.Entry)}(object[]) instead of {nameof(Mod)}.{nameof(Mod.Entry)}({nameof(IModHelper)})", "1.0", DeprecationLevel.Notice); - if (Program.DeprecationManager.IsVirtualMethodImplemented(mod.GetType(), typeof(Mod), nameof(Mod.Entry), new[] { typeof(ModHelper) })) - Program.DeprecationManager.Warn(mod.ModManifest.Name, $"{nameof(Mod)}.{nameof(Mod.Entry)}({nameof(ModHelper)}) instead of {nameof(Mod)}.{nameof(Mod.Entry)}({nameof(IModHelper)})", "1.1", DeprecationLevel.PendingRemoval); + if (this.DeprecationManager.IsVirtualMethodImplemented(mod.GetType(), typeof(Mod), nameof(Mod.Entry), new[] { typeof(object[]) })) + deprecationWarnings.Add(() => this.DeprecationManager.Warn(mod.ModManifest.Name, $"{nameof(Mod)}.{nameof(Mod.Entry)}(object[]) instead of {nameof(Mod)}.{nameof(Mod.Entry)}({nameof(IModHelper)})", "1.0", DeprecationLevel.Info)); } catch (Exception ex) { - Program.Monitor.Log($"The {mod.ModManifest.Name} mod failed on entry initialisation. It will still be loaded, but may not function correctly.\n{ex.GetLogSummary()}", LogLevel.Warn); + this.Monitor.Log($"The {mod.ModManifest.Name} mod failed on entry initialisation. It will still be loaded, but may not function correctly.\n{ex.GetLogSummary()}", LogLevel.Warn); } } // print result - Program.Monitor.Log($"Loaded {Program.ModsLoaded} mods."); - Console.Title = Constants.ConsoleTitle; + this.Monitor.Log($"Loaded {modsLoaded} mods."); + foreach (Action warning in deprecationWarnings) + warning(); + Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GetGameDisplayVersion(Constants.GameVersion)} with {modsLoaded} mods"; } - // ReSharper disable once FunctionNeverReturns /// <summary>Run a loop handling console input.</summary> - private static void ConsoleInputLoop() + [SuppressMessage("ReSharper", "FunctionNeverReturns", Justification = "The thread is aborted when the game exits.")] + private void ConsoleInputLoop() { while (true) - Command.CallCommand(Console.ReadLine(), Program.Monitor); + { + string input = Console.ReadLine(); + try + { + if (!string.IsNullOrWhiteSpace(input) && !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); + } + } } /// <summary>The method called when the user submits the help command in the console.</summary> - /// <param name="sender">The event sender.</param> - /// <param name="e">The event data.</param> - private static void help_CommandFired(object sender, EventArgsCommand e) + /// <param name="name">The command name.</param> + /// <param name="arguments">The command arguments.</param> + private void HandleHelpCommand(string name, string[] arguments) { - if (e.Command.CalledArgs.Length > 0) + if (arguments.Any()) { - var command = Command.FindCommand(e.Command.CalledArgs[0]); - if (command == null) - Program.Monitor.Log("The specified command could't be found", LogLevel.Error); + Framework.Command result = this.CommandManager.Get(arguments[0]); + if (result == null) + this.Monitor.Log("There's no command with that name.", LogLevel.Error); else - Program.Monitor.Log(command.CommandArgs.Length > 0 ? $"{command.CommandName}: {command.CommandDesc} - {string.Join(", ", command.CommandArgs)}" : $"{command.CommandName}: {command.CommandDesc}", LogLevel.Info); + this.Monitor.Log($"{result.Name}: {result.Documentation}\n(Added by {result.ModName}.)", LogLevel.Info); } else - Program.Monitor.Log("Commands: " + string.Join(", ", Command.RegisteredCommands.Select(x => x.CommandName)), LogLevel.Info); + { + this.Monitor.Log("The following commands are registered: " + string.Join(", ", this.CommandManager.GetAll().Select(p => p.Name)) + ".", LogLevel.Info); + this.Monitor.Log("For more information about a command, type 'help command_name'.", LogLevel.Info); + } } /// <summary>Show a 'press any key to exit' message, and exit when they press a key.</summary> - private static void PressAnyKeyToExit() + private void PressAnyKeyToExit() { - Program.Monitor.Log("Game has ended. Press any key to exit.", LogLevel.Info); + this.Monitor.Log("Game has ended. Press any key to exit.", LogLevel.Info); Thread.Sleep(100); Console.ReadKey(); Environment.Exit(0); @@ -584,9 +604,27 @@ namespace StardewModdingAPI /// <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 static Monitor GetSecondaryMonitor(string name) + private Monitor GetSecondaryMonitor(string name) + { + return new Monitor(name, this.ConsoleManager, this.LogFile, this.ExitGameImmediately) { WriteToConsole = this.Monitor.WriteToConsole, ShowTraceInConsole = 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() { - return new Monitor(name, Program.LogFile) { WriteToConsole = Program.Monitor.WriteToConsole, ShowTraceInConsole = Program.Settings.DeveloperMode }; +#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(); } } } diff --git a/src/StardewModdingAPI/SemanticVersion.cs b/src/StardewModdingAPI/SemanticVersion.cs index c29f2cf7..9610562f 100644 --- a/src/StardewModdingAPI/SemanticVersion.cs +++ b/src/StardewModdingAPI/SemanticVersion.cs @@ -13,28 +13,21 @@ namespace StardewModdingAPI /// <remarks>Derived from https://github.com/maxhauser/semver.</remarks> private static readonly Regex Regex = new Regex(@"^(?<major>\d+)(\.(?<minor>\d+))?(\.(?<patch>\d+))?(?<build>.*)$", RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture); - /// <summary>The backing field for <see cref="Build"/>.</summary> - private string _build; - /********* ** Accessors *********/ /// <summary>The major version incremented for major API changes.</summary> - public int MajorVersion { get; set; } + public int MajorVersion { get; } /// <summary>The minor version incremented for backwards-compatible changes.</summary> - public int MinorVersion { get; set; } + public int MinorVersion { get; } /// <summary>The patch version for backwards-compatible bug fixes.</summary> - public int PatchVersion { get; set; } + public int PatchVersion { get; } /// <summary>An optional build tag.</summary> - public string Build - { - get { return this._build; } - set { this._build = this.GetNormalisedTag(value); } - } + public string Build { get; } /********* @@ -50,7 +43,7 @@ namespace StardewModdingAPI this.MajorVersion = major; this.MinorVersion = minor; this.PatchVersion = patch; - this.Build = build; + this.Build = this.GetNormalisedTag(build); } /// <summary>Construct an instance.</summary> @@ -65,14 +58,18 @@ namespace StardewModdingAPI 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["build"].Success ? match.Groups["build"].Value : null; + this.Build = match.Groups["build"].Success ? this.GetNormalisedTag(match.Groups["build"].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; diff --git a/src/StardewModdingAPI/StardewModdingAPI.config.json b/src/StardewModdingAPI/StardewModdingAPI.config.json index 2abaf73a..0b6f3a37 100644 --- a/src/StardewModdingAPI/StardewModdingAPI.config.json +++ b/src/StardewModdingAPI/StardewModdingAPI.config.json @@ -1,4 +1,198 @@ -{ +/* + + + +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, - "CheckForUpdates": true + + /** + * Whether SMAPI should check for a newer version when you load the game. If a new version is + * available, a small message will appear 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, + + /** + * A list of mod versions SMAPI should consider compatible or broken regardless of whether it + * detects incompatible code. Each record can be set to `AssumeCompatible` or `AssumeBroken`. + * Changing this field is not recommended and may destabilise your game. + */ + "ModCompatibility": [ + { + "Name": "Almighty Tool", + "ID": "AlmightyTool.dll", + "UpperVersion": "1.1.1", + "Compatibility": "AssumeBroken", + "UpdateUrl": "http://www.nexusmods.com/stardewvalley/mods/439", + "Notes": "Uses obsolete StardewModdingAPI.Extensions." + }, + { + "Name": "Better Sprinklers", + "ID": "SPDSprinklersMod", + "UpperVersion": "2.3", + "Compatibility": "AssumeBroken", + "UpdateUrl": "http://www.nexusmods.com/stardewvalley/mods/41", + "Notes": "Uses obsolete StardewModdingAPI.Extensions." + }, + { + "Name": "Better Sprinklers", + "ID": "Speeder.BetterSprinklers", + "UpperVersion": "2.3", + "Compatibility": "AssumeBroken", + "UpdateUrl": "http://www.nexusmods.com/stardewvalley/mods/41", + "Notes": "Uses obsolete StardewModdingAPI.Extensions." + }, + { + "Name": "Chest Label System", + "ID": "SPDChestLabel", + "UpperVersion": "1.5", + "Compatibility": "AssumeBroken", + "UpdateUrl": "http://www.nexusmods.com/stardewvalley/mods/242", + "Notes": "Not compatible with Stardew Valley 1.1+" + }, + { + "Name": "CJB Cheats Menu", + "ID": "CJBCheatsMenu", + "UpperVersion": "1.12", + "UpdateUrl": "http://www.nexusmods.com/stardewvalley/mods/4", + "Notes": "Not compatible with Stardew Valley 1.1+" + }, + { + "Name": "CJB Item Spawner", + "ID": "CJBItemSpawner", + "UpperVersion": "1.5", + "UpdateUrl": "http://www.nexusmods.com/stardewvalley/mods/93", + "Notes": "Not compatible with Stardew Valley 1.1+" + }, + { + "Name": "CJB Show Item Sell Price", + "ID": "CJBShowItemSellPrice", + "UpperVersion": "1.6", + "Compatibility": "AssumeBroken", + "UpdateUrl": "http://www.nexusmods.com/stardewvalley/mods/93", + "Notes": "Uses SMAPI's internal SGame class." + }, + { + "Name": "Enemy Health Bars", + "ID": "SPDHealthBar", + "UpperVersion": "1.7", + "Compatibility": "AssumeBroken", + "UpdateUrl": "http://www.nexusmods.com/stardewvalley/mods/193", + "Notes": "Uses obsolete GraphicsEvents.DrawTick." + }, + { + "Name": "Entoarox Framework", + "ID": "eacdb74b-4080-4452-b16b-93773cda5cf9", + "UpperVersion": "1.6.5", + "Compatibility": "AssumeBroken", + "UpdateUrl": "http://community.playstarbound.com/resources/4228", + "Notes": "Uses obsolete StardewModdingAPI.Inheritance.SObject until 1.6.1; then crashes until 1.6.4 ('Entoarox Framework requested an immediate game shutdown: Fatal error attempting to update player tick properties System.NullReferenceException: Object reference not set to an instance of an object. at Entoarox.Framework.PlayerHelper.Update(Object s, EventArgs e)')." + }, + { + "Name": "Makeshift Multiplayer", + "ID": "StardewValleyMP", + "Compatibility": "AssumeBroken", + "UpperVersion": "0.2.10", + "UpdateUrl": "http://www.nexusmods.com/stardewvalley/mods/501", + "Notes": "Uses obsolete GraphicsEvents.OnPreRenderHudEventNoCheck." + }, + { + "Name": "NoSoilDecay", + "ID": "289dee03-5f38-4d8e-8ffc-e440198e8610", + "UpperVersion": "0.5", + "Compatibility": "AssumeBroken", + "UpdateUrl": "http://www.nexusmods.com/stardewvalley/mods/237", + "Notes": "Uses obsolete StardewModdingAPI.Extensions." + }, + { + "Name": "NPC Map Locations", + "ID": "NPCMapLocationsMod", + "LowerVersion": "1.42", + "UpperVersion": "1.43", + "Compatibility": "AssumeBroken", + "UpdateUrl": "http://www.nexusmods.com/stardewvalley/mods/239", + "ReasonPhrase": "this version has an update check error which crashes the game" + }, + { + "Name": "Point-and-Plant", + "ID": "PointAndPlant.dll", + "UpperVersion": "1.0.2", + "Compatibility": "AssumeBroken", + "UpdateUrl": "http://www.nexusmods.com/stardewvalley/mods/572", + "Notes": "Uses obsolete StardewModdingAPI.Extensions." + }, + { + "Name": "Save Anywhere", + "ID": "SaveAnywhere", + "UpperVersion": "2.0", + "Compatibility": "AssumeBroken", + "UpdateUrl": "http://www.nexusmods.com/stardewvalley/mods/444", + "Notes": "Depends on StarDustCore." + }, + { + "Name": "StackSplitX", + "ID": "StackSplitX.dll", + "UpperVersion": "1.0", + "Compatibility": "AssumeBroken", + "UpdateUrl": "http://www.nexusmods.com/stardewvalley/mods/798", + "Notes": "Uses SMAPI's internal SGame class." + }, + { + "Name": "StarDustCore", + "ID": "StarDustCore", + "UpperVersion": "1.0", + "Compatibility": "AssumeBroken", + "UpdateUrl": "http://www.nexusmods.com/stardewvalley/mods/683", + "Notes": "Crashes with 'Method not found: Void StardewModdingAPI.Command.CallCommand(System.String)'." + }, + { + "Name": "Zoryn's Better RNG", + "ID": "76b6d1e1-f7ba-4d72-8c32-5a1e6d2716f6", + "UpperVersion": "1.5", + "Compatibility": "AssumeBroken", + "UpdateUrl": "http://community.playstarbound.com/threads/108756", + "Notes": "Uses SMAPI's internal SGame class." + }, + { + "Name": "Zoryn's Calendar Anywhere", + "ID": "a41c01cd-0437-43eb-944f-78cb5a53002a", + "UpperVersion": "1.5", + "Compatibility": "AssumeBroken", + "UpdateUrl": "http://community.playstarbound.com/threads/108756", + "Notes": "Uses SMAPI's internal SGame class." + }, + { + "Name": "Zoryn's Health Bars", + "ID": "HealthBars.dll", + "UpperVersion": "1.5", + "Compatibility": "AssumeBroken", + "UpdateUrl": "http://community.playstarbound.com/threads/108756", + "Notes": "Uses SMAPI's internal SGame class." + }, + { + "Name": "Zoryn's Movement Mod", + "ID": "8a632929-8335-484f-87dd-c29d2ba3215d", + "UpperVersion": "1.5", + "Compatibility": "AssumeBroken", + "UpdateUrl": "http://community.playstarbound.com/threads/108756", + "Notes": "Uses SMAPI's internal SGame class." + }, + { + "Name": "Zoryn's Regen Mod", + "ID": "dfac4383-1b6b-4f33-ae4e-37fc23e5252e", + "UpperVersion": "1.5", + "Compatibility": "AssumeBroken", + "UpdateUrl": "http://community.playstarbound.com/threads/108756", + "Notes": "Uses SMAPI's internal SGame class." + } + ] } diff --git a/src/StardewModdingAPI/StardewModdingAPI.csproj b/src/StardewModdingAPI/StardewModdingAPI.csproj index 337929e2..bcd0c390 100644 --- a/src/StardewModdingAPI/StardewModdingAPI.csproj +++ b/src/StardewModdingAPI/StardewModdingAPI.csproj @@ -60,7 +60,6 @@ <OutputPath>$(SolutionDir)\..\bin\Debug\SMAPI</OutputPath> <DocumentationFile>$(SolutionDir)\..\bin\Debug\SMAPI\StardewModdingAPI.xml</DocumentationFile> <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> - <LangVersion>6</LangVersion> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x86'"> <PlatformTarget>x86</PlatformTarget> @@ -70,7 +69,6 @@ <DefineConstants>TRACE</DefineConstants> <Optimize>true</Optimize> <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> - <LangVersion>6</LangVersion> <DebugType>pdbonly</DebugType> <DebugSymbols>true</DebugSymbols> </PropertyGroup> @@ -90,10 +88,6 @@ <HintPath>..\packages\Mono.Cecil.0.9.6.4\lib\net45\Mono.Cecil.Pdb.dll</HintPath> <Private>True</Private> </Reference> - <Reference Include="Mono.Cecil.Rocks, Version=0.9.6.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL"> - <HintPath>..\packages\Mono.Cecil.0.9.6.4\lib\net45\Mono.Cecil.Rocks.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> @@ -101,6 +95,7 @@ <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> @@ -118,12 +113,12 @@ <Compile Include="..\GlobalAssemblyInfo.cs"> <Link>Properties\GlobalAssemblyInfo.cs</Link> </Compile> - <Compile Include="Advanced\ConfigFile.cs" /> - <Compile Include="Advanced\IConfigFile.cs" /> <Compile Include="Command.cs" /> + <Compile Include="Events\ContentEvents.cs" /> + <Compile Include="Events\EventArgsValueChanged.cs" /> + <Compile Include="Framework\Command.cs" /> <Compile Include="Config.cs" /> <Compile Include="Constants.cs" /> - <Compile Include="Entities\SPlayer.cs" /> <Compile Include="Events\ControlEvents.cs" /> <Compile Include="Events\EventArgsCommand.cs" /> <Compile Include="Events\EventArgsClickableMenuChanged.cs" /> @@ -150,6 +145,27 @@ <Compile Include="Events\GraphicsEvents.cs" /> <Compile Include="Framework\AssemblyDefinitionResolver.cs" /> <Compile Include="Framework\AssemblyParseResult.cs" /> + <Compile Include="Framework\CommandManager.cs" /> + <Compile Include="Framework\Content\ContentEventData.cs" /> + <Compile Include="Framework\Content\ContentEventHelper.cs" /> + <Compile Include="Framework\Content\ContentEventHelperForDictionary.cs" /> + <Compile Include="Framework\Content\ContentEventHelperForImage.cs" /> + <Compile Include="Framework\Logging\ConsoleInterceptionManager.cs" /> + <Compile Include="Framework\Logging\InterceptingTextWriter.cs" /> + <Compile Include="Framework\CommandHelper.cs" /> + <Compile Include="Framework\Models\ModCompatibilityType.cs" /> + <Compile Include="Framework\Models\SConfig.cs" /> + <Compile Include="Framework\Reflection\PrivateProperty.cs" /> + <Compile Include="Framework\RequestExitDelegate.cs" /> + <Compile Include="Framework\SContentManager.cs" /> + <Compile Include="Framework\Serialisation\JsonHelper.cs" /> + <Compile Include="Framework\Serialisation\SelectiveStringEnumConverter.cs" /> + <Compile Include="Framework\Serialisation\SemanticVersionConverter.cs" /> + <Compile Include="ICommandHelper.cs" /> + <Compile Include="IContentEventData.cs" /> + <Compile Include="IContentEventHelper.cs" /> + <Compile Include="IContentEventHelperForDictionary.cs" /> + <Compile Include="IContentEventHelperForImage.cs" /> <Compile Include="IModRegistry.cs" /> <Compile Include="Events\LocationEvents.cs" /> <Compile Include="Events\MenuEvents.cs" /> @@ -157,11 +173,10 @@ <Compile Include="Events\PlayerEvents.cs" /> <Compile Include="Events\SaveEvents.cs" /> <Compile Include="Events\TimeEvents.cs" /> - <Compile Include="Extensions.cs" /> <Compile Include="Framework\DeprecationLevel.cs" /> <Compile Include="Framework\DeprecationManager.cs" /> <Compile Include="Framework\InternalExtensions.cs" /> - <Compile Include="Framework\Models\IncompatibleMod.cs" /> + <Compile Include="Framework\Models\ModCompatibility.cs" /> <Compile Include="Framework\AssemblyLoader.cs" /> <Compile Include="Framework\Reflection\CacheEntry.cs" /> <Compile Include="Framework\Reflection\PrivateField.cs" /> @@ -170,32 +185,29 @@ <Compile Include="IManifest.cs" /> <Compile Include="IMod.cs" /> <Compile Include="IModHelper.cs" /> - <Compile Include="Framework\LogFileManager.cs" /> + <Compile Include="Framework\Logging\LogFileManager.cs" /> + <Compile Include="IPrivateProperty.cs" /> <Compile Include="ISemanticVersion.cs" /> <Compile Include="LogLevel.cs" /> <Compile Include="Framework\ModRegistry.cs" /> <Compile Include="Framework\UpdateHelper.cs" /> <Compile Include="Framework\Models\GitRelease.cs" /> - <Compile Include="Framework\Models\UserSettings.cs" /> <Compile Include="IMonitor.cs" /> - <Compile Include="Inheritance\ChangeType.cs" /> - <Compile Include="Inheritance\ItemStackChange.cs" /> - <Compile Include="Inheritance\SObject.cs" /> + <Compile Include="Events\ChangeType.cs" /> + <Compile Include="Events\ItemStackChange.cs" /> <Compile Include="Log.cs" /> <Compile Include="Framework\Monitor.cs" /> - <Compile Include="LogInfo.cs" /> - <Compile Include="LogWriter.cs" /> - <Compile Include="Manifest.cs" /> + <Compile Include="Framework\Manifest.cs" /> <Compile Include="Mod.cs" /> - <Compile Include="ModHelper.cs" /> + <Compile Include="Framework\ModHelper.cs" /> + <Compile Include="PatchMode.cs" /> <Compile Include="Program.cs" /> <Compile Include="Properties\AssemblyInfo.cs" /> - <Compile Include="Inheritance\SGame.cs" /> + <Compile Include="Framework\SGame.cs" /> <Compile Include="IPrivateField.cs" /> <Compile Include="IPrivateMethod.cs" /> <Compile Include="IReflectionHelper.cs" /> <Compile Include="SemanticVersion.cs" /> - <Compile Include="Version.cs" /> </ItemGroup> <ItemGroup> <None Include="App.config"> @@ -207,9 +219,6 @@ <Content Include="StardewModdingAPI.config.json"> <CopyToOutputDirectory>Always</CopyToOutputDirectory> </Content> - <Content Include="StardewModdingAPI.data.json"> - <CopyToOutputDirectory>Always</CopyToOutputDirectory> - </Content> <None Include="unix-launcher.sh"> <CopyToOutputDirectory>Always</CopyToOutputDirectory> </None> @@ -248,22 +257,27 @@ <Target Name="BeforeBuild"> <Error Condition="!Exists('$(GamePath)')" Text="Failed to find the game install path automatically; edit the *.csproj file and manually add a <GamePath> setting with the full directory path containing the Stardew Valley executable." /> </Target> - <!-- copy files into game directory and enable debugging (only in debug mode, and only in Windows because I haven't tried it with Linux/Mac) --> - <PropertyGroup Condition="$(Configuration) == 'Debug' AND $(OS) == 'Windows_NT'"> - <StartAction>Program</StartAction> - <StartProgram>$(GamePath)\StardewModdingAPI.exe</StartProgram> - <StartWorkingDirectory>$(GamePath)</StartWorkingDirectory> - </PropertyGroup> + + <!-- copy files into game directory and enable debugging (only in debug mode) --> <Target Name="AfterBuild" Condition="$(Configuration) == 'Debug'"> <Copy SourceFiles="$(TargetDir)\$(TargetName).exe" DestinationFolder="$(GamePath)" /> <Copy SourceFiles="$(TargetDir)\$(TargetName).config.json" DestinationFolder="$(GamePath)" /> - <Copy SourceFiles="$(TargetDir)\$(TargetName).data.json" DestinationFolder="$(GamePath)" /> <Copy SourceFiles="$(TargetDir)\StardewModdingAPI.AssemblyRewriters.dll" DestinationFolder="$(GamePath)" /> <Copy SourceFiles="$(TargetDir)\$(TargetName).exe.mdb" DestinationFolder="$(GamePath)" Condition="$(OS) != 'Windows_NT'" /> <Copy SourceFiles="$(TargetDir)\$(TargetName).pdb" DestinationFolder="$(GamePath)" Condition="$(OS) == 'Windows_NT'" /> <Copy SourceFiles="$(TargetDir)\$(TargetName).xml" DestinationFolder="$(GamePath)" Condition="$(OS) == 'Windows_NT'" /> <Copy SourceFiles="$(TargetDir)\Newtonsoft.Json.dll" DestinationFolder="$(GamePath)" /> <Copy SourceFiles="$(TargetDir)\Mono.Cecil.dll" DestinationFolder="$(GamePath)" /> - <Copy SourceFiles="$(TargetDir)\Mono.Cecil.Rocks.dll" DestinationFolder="$(GamePath)" /> </Target> -</Project>
\ No newline at end of file + + <!-- launch SMAPI on debug --> + <PropertyGroup Condition="$(Configuration) == 'Debug'"> + <StartAction>Program</StartAction> + <StartProgram>$(GamePath)\StardewModdingAPI.exe</StartProgram> + <StartWorkingDirectory>$(GamePath)</StartWorkingDirectory> + </PropertyGroup> + + <!-- Somehow this makes Visual Studio for Mac recognise the previous section. --> + <!-- Nobody knows why. --> + <PropertyGroup Condition="'$(RunConfiguration)' == 'Default'" /> +</Project> diff --git a/src/StardewModdingAPI/StardewModdingAPI.data.json b/src/StardewModdingAPI/StardewModdingAPI.data.json deleted file mode 100644 index 3295336f..00000000 --- a/src/StardewModdingAPI/StardewModdingAPI.data.json +++ /dev/null @@ -1,50 +0,0 @@ -/* - - -This file contains advanced metadata for SMAPI. You shouldn't change this file. - - -*/ -[ - /* versions not compatible with Stardew Valley 1.1+ */ - { - "ID": "SPDSprinklersMod", - "Name": "Better Sprinklers", - "UpperVersion": "2.1", - "UpdateUrl": "http://www.nexusmods.com/stardewvalley/mods/41", - "UnofficialUpdateUrl": "http://community.playstarbound.com/threads/125031", - "ForceCompatibleVersion": "^2.1-EntoPatch" - }, - { - "ID": "SPDChestLabel", - "Name": "Chest Label System", - "UpperVersion": "1.5", - "UpdateUrl": "http://www.nexusmods.com/stardewvalley/mods/242", - "UnofficialUpdateUrl": "http://community.playstarbound.com/threads/125031", - "ForceCompatibleVersion": "^1.5-EntoPatch" - }, - { - "ID": "CJBCheatsMenu", - "Name": "CJB Cheats Menu", - "UpperVersion": "1.12", - "UpdateUrl": "http://www.nexusmods.com/stardewvalley/mods/4", - "ForceCompatibleVersion": "^1.12-EntoPatch" - }, - { - "ID": "CJBItemSpawner", - "Name": "CJB Item Spawner", - "UpperVersion": "1.5", - "UpdateUrl": "http://www.nexusmods.com/stardewvalley/mods/93", - "ForceCompatibleVersion": "^1.5-EntoPatch" - }, - - /* versions which crash the game */ - { - "ID": "NPCMapLocationsMod", - "Name": "NPC Map Locations", - "LowerVersion": "1.42", - "UpperVersion": "1.43", - "UpdateUrl": "http://www.nexusmods.com/stardewvalley/mods/239", - "ReasonPhrase": "this version has an update check error which crashes the game" - } -] diff --git a/src/StardewModdingAPI/Version.cs b/src/StardewModdingAPI/Version.cs deleted file mode 100644 index e66d7be5..00000000 --- a/src/StardewModdingAPI/Version.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System; -using Newtonsoft.Json; -using StardewModdingAPI.Framework; - -namespace StardewModdingAPI -{ - /// <summary>A semantic version with an optional release tag.</summary> - [Obsolete("Use " + nameof(SemanticVersion) + " or " + nameof(Manifest) + "." + nameof(Manifest.Version) + " instead")] - public struct Version : ISemanticVersion - { - /********* - ** Accessors - *********/ - /// <summary>The major version incremented for major API changes.</summary> - public int MajorVersion { get; set; } - - /// <summary>The minor version incremented for backwards-compatible changes.</summary> - public int MinorVersion { get; set; } - - /// <summary>The patch version for backwards-compatible bug fixes.</summary> - public int PatchVersion { get; set; } - - /// <summary>An optional build tag.</summary> - public string Build { get; set; } - - /// <summary>Obsolete.</summary> - [JsonIgnore] - [Obsolete("Use " + nameof(Version) + "." + nameof(Version.ToString) + " instead.")] - public string VersionString - { - get - { - Program.DeprecationManager.Warn($"{nameof(Version)}.{nameof(Version.VersionString)}", "1.0", DeprecationLevel.Info); - return this.GetSemanticVersion().ToString(); - } - } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="major">The major version incremented for major API changes.</param> - /// <param name="minor">The minor version incremented for backwards-compatible changes.</param> - /// <param name="patch">The patch version for backwards-compatible bug fixes.</param> - /// <param name="build">An optional build tag.</param> - public Version(int major, int minor, int patch, string build) - : this(major, minor, patch, build, suppressDeprecationWarning: false) - { } - - /// <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> - public int CompareTo(Version other) - { - return this.GetSemanticVersion().CompareTo(other); - } - - /// <summary>Get whether this version is newer than the specified version.</summary> - /// <param name="other">The version to compare with this instance.</param> - [Obsolete("Use " + nameof(ISemanticVersion) + "." + nameof(ISemanticVersion.IsNewerThan) + " instead")] - public bool IsNewerThan(Version other) - { - return this.GetSemanticVersion().IsNewerThan(other); - } - - /// <summary>Compares the current instance with another object of the same type and returns an integer that indicates whether the current instance precedes, follows, or occurs in the same position in the sort order as the other object. </summary> - /// <returns>A value that indicates the relative order of the objects being compared. The return value has these meanings: Value Meaning Less than zero This instance precedes <paramref name="other" /> in the sort order. Zero This instance occurs in the same position in the sort order as <paramref name="other" />. Greater than zero This instance follows <paramref name="other" /> in the sort order. </returns> - /// <param name="other">An object to compare with this instance. </param> - int IComparable<ISemanticVersion>.CompareTo(ISemanticVersion other) - { - return this.GetSemanticVersion().CompareTo(other); - } - - /// <summary>Get whether this version is older than the specified version.</summary> - /// <param name="other">The version to compare with this instance.</param> - bool ISemanticVersion.IsOlderThan(ISemanticVersion other) - { - return this.GetSemanticVersion().IsOlderThan(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 ISemanticVersion.IsNewerThan(ISemanticVersion other) - { - return this.GetSemanticVersion().IsNewerThan(other); - } - - /// <summary>Get a string representation of the version.</summary> - public override string ToString() - { - return this.GetSemanticVersion().ToString(); - } - - /********* - ** Private methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="major">The major version incremented for major API changes.</param> - /// <param name="minor">The minor version incremented for backwards-compatible changes.</param> - /// <param name="patch">The patch version for backwards-compatible bug fixes.</param> - /// <param name="build">An optional build tag.</param> - /// <param name="suppressDeprecationWarning">Whether to suppress the deprecation warning.</param> - internal Version(int major, int minor, int patch, string build, bool suppressDeprecationWarning) - { - if (!suppressDeprecationWarning) - Program.DeprecationManager.Warn($"{nameof(Version)}", "1.5", DeprecationLevel.Notice); - - this.MajorVersion = major; - this.MinorVersion = minor; - this.PatchVersion = patch; - this.Build = build; - } - - /// <summary>Get the equivalent semantic version.</summary> - /// <remarks>This is a hack so the struct can wrap <see cref="SemanticVersion"/> without a mutable backing field, which would cause a <see cref="StackOverflowException"/> due to recreating the struct value on each change.</remarks> - private SemanticVersion GetSemanticVersion() - { - return new SemanticVersion(this.MajorVersion, this.MinorVersion, this.PatchVersion, this.Build); - } - } -} |