diff options
Diffstat (limited to 'src/StardewModdingAPI/Framework')
29 files changed, 2571 insertions, 145 deletions
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/Framework/ModHelper.cs b/src/StardewModdingAPI/Framework/ModHelper.cs new file mode 100644 index 00000000..c8c44dba --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModHelper.cs @@ -0,0 +1,111 @@ +using System; +using System.IO; +using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Framework.Serialisation; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Provides simplified APIs for writing mods.</summary> + internal class ModHelper : IModHelper + { + /********* + ** Properties + *********/ + /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> + private readonly JsonHelper JsonHelper; + + + /********* + ** Accessors + *********/ + /// <summary>The mod directory path.</summary> + public string DirectoryPath { get; } + + /// <summary>Simplifies access to private game code.</summary> + public IReflectionHelper Reflection { get; } = new ReflectionHelper(); + + /// <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> + /// <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 modName, string modDirectory, JsonHelper jsonHelper, IModRegistry modRegistry, CommandManager commandManager) + { + // validate + if (string.IsNullOrWhiteSpace(modDirectory)) + 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); + } + + /**** + ** Mod config file + ****/ + /// <summary>Read the mod's configuration file (and create it if needed).</summary> + /// <typeparam name="TConfig">The config class type. This should be a plain class that has public properties for the settings you want. These can be complex types.</typeparam> + public TConfig ReadConfig<TConfig>() + where TConfig : class, new() + { + TConfig config = this.ReadJsonFile<TConfig>("config.json") ?? new TConfig(); + this.WriteConfig(config); // create file or fill in missing fields + return config; + } + + /// <summary>Save to the mod's configuration file.</summary> + /// <typeparam name="TConfig">The config class type.</typeparam> + /// <param name="config">The config settings to save.</param> + public void WriteConfig<TConfig>(TConfig config) + where TConfig : class, new() + { + this.WriteJsonFile("config.json", config); + } + + /**** + ** Generic JSON files + ****/ + /// <summary>Read a JSON file.</summary> + /// <typeparam name="TModel">The model type.</typeparam> + /// <param name="path">The file path relative to the mod directory.</param> + /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + public TModel ReadJsonFile<TModel>(string path) + where TModel : class + { + path = Path.Combine(this.DirectoryPath, path); + return this.JsonHelper.ReadJsonFile<TModel>(path); + } + + /// <summary>Save to a JSON file.</summary> + /// <typeparam name="TModel">The model type.</typeparam> + /// <param name="path">The file path relative to the mod directory.</param> + /// <param name="model">The model to save.</param> + public void WriteJsonFile<TModel>(string path, TModel model) + where TModel : class + { + path = Path.Combine(this.DirectoryPath, path); + this.JsonHelper.WriteJsonFile(path, model); + } + } +} 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/Framework/SGame.cs b/src/StardewModdingAPI/Framework/SGame.cs new file mode 100644 index 00000000..5f265139 --- /dev/null +++ b/src/StardewModdingAPI/Framework/SGame.cs @@ -0,0 +1,1063 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using StardewModdingAPI.Events; +using StardewModdingAPI.Framework.Reflection; +using StardewValley; +using StardewValley.BellsAndWhistles; +using StardewValley.Locations; +using StardewValley.Menus; +using StardewValley.Tools; +using xTile.Dimensions; +using Rectangle = Microsoft.Xna.Framework.Rectangle; +using SFarmer = StardewValley.Farmer; + +namespace StardewModdingAPI.Framework +{ + /// <summary>SMAPI's extension of the game's core <see cref="Game1"/>, used to inject events.</summary> + internal class SGame : Game1 + { + /********* + ** Properties + *********/ + /**** + ** SMAPI state + ****/ + /// <summary>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>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); + + /// <summary>Encapsulates monitoring and logging.</summary> + private readonly IMonitor Monitor; + + /**** + ** Game state + ****/ + /// <summary>Arrays of pressed controller buttons indexed by <see cref="PlayerIndex"/>.</summary> + 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> + 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> + 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> + 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> + private MouseState MStatePrior; + + /// <summary>The current mouse position on the screen adjusted for the zoom level.</summary> + private Point MPositionNow; + + /// <summary>The previous mouse position on the screen adjusted for the zoom level.</summary> + private Point MPositionPrior; + + /// <summary>The keys that were pressed as of the latest tick.</summary> + private Keys[] CurrentlyPressedKeys => this.KStateNow.GetPressedKeys(); + + /// <summary>The keys that were pressed as of the previous tick.</summary> + private Keys[] PreviouslyPressedKeys => this.KStatePrior.GetPressedKeys(); + + /// <summary>The keys that just entered the down state.</summary> + private Keys[] FramePressedKeys => this.CurrentlyPressedKeys.Except(this.PreviouslyPressedKeys).ToArray(); + + /// <summary>The keys that just entered the up state.</summary> + private Keys[] FrameReleasedKeys => this.PreviouslyPressedKeys.Except(this.CurrentlyPressedKeys).ToArray(); + + /// <summary>A hash of <see cref="Game1.locations"/> at last check.</summary> + private int PreviousGameLocations; + + /// <summary>A hash of the current location's <see cref="GameLocation.objects"/> at last check.</summary> + private int PreviousLocationObjects; + + /// <summary>The player's inventory at last check.</summary> + private IDictionary<Item, int> PreviousItems; + + /// <summary>The player's combat skill level at last check.</summary> + private int PreviousCombatLevel; + + /// <summary>The player's farming skill level at last check.</summary> + private int PreviousFarmingLevel; + + /// <summary>The player's fishing skill level at last check.</summary> + private int PreviousFishingLevel; + + /// <summary>The player's foraging skill level at last check.</summary> + private int PreviousForagingLevel; + + /// <summary>The player's mining skill level at last check.</summary> + private int PreviousMiningLevel; + + /// <summary>The player's luck skill level at last check.</summary> + private int PreviousLuckLevel; + + /// <summary>The player's location at last check.</summary> + private GameLocation PreviousGameLocation; + + /// <summary>The active game menu at last check.</summary> + private IClickableMenu PreviousActiveMenu; + + /// <summary>The mine level at last check.</summary> + private int PreviousMineLevel; + + /// <summary>The time of day (in 24-hour military format) at last check.</summary> + private int PreviousTime; + + /// <summary>The day of month (1–28) at last check.</summary> + private int PreviousDay; + + /// <summary>The season name (winter, spring, summer, or fall) at last check.</summary> + private string PreviousSeason; + + /// <summary>The year number at last check.</summary> + private int PreviousYear; + + /// <summary>Whether the game was transitioning to a new day at last check.</summary> + private bool PreviousIsNewDay; + + /// <summary>The player character at last check.</summary> + private SFarmer PreviousFarmer; + + /// <summary>An index incremented on every tick and reset every 60th tick (0–59).</summary> + private int CurrentUpdateTick; + + /// <summary>Whether this is the very first update tick since the game started.</summary> + private bool FirstUpdate; + + /// <summary>The current game instance.</summary> + private static SGame Instance; + + /**** + ** Private wrappers + ****/ + // 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 + + + /********* + ** Protected methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal SGame(IMonitor monitor) + { + this.Monitor = monitor; + this.FirstUpdate = true; + 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() + { + this.PreviouslyPressedButtons = new Buttons[4][]; + for (var i = 0; i < 4; ++i) + this.PreviouslyPressedButtons[i] = new Buttons[0]; + + base.Initialize(); + GameEvents.InvokeInitialize(this.Monitor); + } + + /// <summary>The method called before XNA or MonoGame loads or reloads graphics resources.</summary> + protected override void LoadContent() + { + base.LoadContent(); + GameEvents.InvokeLoadContent(this.Monitor); + } + + /// <summary>The method called when the game is updating its state. This happens roughly 60 times per second.</summary> + /// <param name="gameTime">A snapshot of the game timing state.</param> + protected override void Update(GameTime gameTime) + { + // raise game loaded + if (this.FirstUpdate) + GameEvents.InvokeGameLoaded(this.Monitor); + + // update SMAPI events + this.UpdateEventCalls(); + + // let game update + try + { + base.Update(gameTime); + } + catch (Exception ex) + { + this.Monitor.Log($"An error occured in the base update loop: {ex.GetLogSummary()}", LogLevel.Error); + Console.ReadKey(); + } + + // raise update events + GameEvents.InvokeUpdateTick(this.Monitor); + if (this.FirstUpdate) + { + GameEvents.InvokeFirstUpdateTick(this.Monitor); + this.FirstUpdate = false; + } + if (this.CurrentUpdateTick % 2 == 0) + GameEvents.InvokeSecondUpdateTick(this.Monitor); + if (this.CurrentUpdateTick % 4 == 0) + GameEvents.InvokeFourthUpdateTick(this.Monitor); + if (this.CurrentUpdateTick % 8 == 0) + GameEvents.InvokeEighthUpdateTick(this.Monitor); + if (this.CurrentUpdateTick % 15 == 0) + GameEvents.InvokeQuarterSecondTick(this.Monitor); + if (this.CurrentUpdateTick % 30 == 0) + GameEvents.InvokeHalfSecondTick(this.Monitor); + if (this.CurrentUpdateTick % 60 == 0) + GameEvents.InvokeOneSecondTick(this.Monitor); + this.CurrentUpdateTick += 1; + if (this.CurrentUpdateTick >= 60) + this.CurrentUpdateTick = 0; + + // track keyboard state + this.KStatePrior = this.KStateNow; + + // track controller button state + for (var i = PlayerIndex.One; i <= PlayerIndex.Four; i++) + this.PreviouslyPressedButtons[(int)i] = this.GetButtonsDown(i); + } + + /// <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, 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) + { + try + { + if (!this.ZoomLevelIsOne) + this.GraphicsDevice.SetRenderTarget(this.screenWrapper); + + 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); + try + { + Game1.activeClickableMenu.drawBackground(Game1.spriteBatch); + } + catch (Exception ex) + { + this.Monitor.Log($"The {Game1.activeClickableMenu.GetType().FullName} menu crashed while drawing its background. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); + Game1.activeClickableMenu.exitThisMenu(); + } + GraphicsEvents.InvokeOnPreRenderGuiEvent(this.Monitor); + try + { + Game1.activeClickableMenu.draw(Game1.spriteBatch); + } + catch (Exception ex) + { + this.Monitor.Log($"The {Game1.activeClickableMenu.GetType().FullName} menu crashed while drawing itself. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); + Game1.activeClickableMenu.exitThisMenu(); + } + GraphicsEvents.InvokeOnPostRenderGuiEvent(this.Monitor); + Game1.spriteBatch.End(); + if (!this.ZoomLevelIsOne) + { + this.GraphicsDevice.SetRenderTarget(null); + this.GraphicsDevice.Clear(this.bgColor); + Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); + 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; + } + if (Game1.gameMode == 11) + { + Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + Game1.spriteBatch.DrawString(Game1.smoothFont, "Stardew Valley has crashed...", new Vector2(16f, 16f), Color.HotPink); + Game1.spriteBatch.DrawString(Game1.smoothFont, "Please send the error report or a screenshot of this message to @ConcernedApe. (http://stardewvalley.net/contact/)", new Vector2(16f, 32f), new Color(0, 255, 0)); + Game1.spriteBatch.DrawString(Game1.smoothFont, Game1.parseText(Game1.errorMessage, Game1.smoothFont, Game1.graphics.GraphicsDevice.Viewport.Width), new Vector2(16f, 48f), Color.White); + Game1.spriteBatch.End(); + return; + } + if (Game1.currentMinigame != null) + { + Game1.currentMinigame.draw(Game1.spriteBatch); + if (Game1.globalFade && !Game1.menuUp && (!Game1.nameSelectUp || Game1.messagePause)) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * ((Game1.gameMode == 0) ? (1f - Game1.fadeToBlackAlpha) : Game1.fadeToBlackAlpha)); + Game1.spriteBatch.End(); + } + if (!this.ZoomLevelIsOne) + { + this.GraphicsDevice.SetRenderTarget(null); + this.GraphicsDevice.Clear(this.bgColor); + Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); + 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; + } + if (Game1.showingEndOfNightStuff) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + try + { + Game1.activeClickableMenu?.draw(Game1.spriteBatch); + } + catch (Exception ex) + { + this.Monitor.Log($"The {Game1.activeClickableMenu.GetType().FullName} menu crashed while drawing itself. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); + Game1.activeClickableMenu.exitThisMenu(); + } + Game1.spriteBatch.End(); + if (!this.ZoomLevelIsOne) + { + this.GraphicsDevice.SetRenderTarget(null); + this.GraphicsDevice.Clear(this.bgColor); + Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); + 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; + } + if (Game1.gameMode == 6) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + string text = ""; + int num = 0; + while (num < gameTime.TotalGameTime.TotalMilliseconds % 999.0 / 333.0) + { + text += "."; + num++; + } + SpriteText.drawString(Game1.spriteBatch, "Loading" + text, 64, Game1.graphics.GraphicsDevice.Viewport.Height - 64, 999, -1, 999, 1f, 1f, false, 0, "Loading..."); + Game1.spriteBatch.End(); + if (!this.ZoomLevelIsOne) + { + this.GraphicsDevice.SetRenderTarget(null); + this.GraphicsDevice.Clear(this.bgColor); + Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); + 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; + } + if (Game1.gameMode == 0) + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + else + { + if (Game1.drawLighting) + { + this.GraphicsDevice.SetRenderTarget(Game1.lightmap); + this.GraphicsDevice.Clear(Color.White * 0f); + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, null, null); + Game1.spriteBatch.Draw(Game1.staminaRect, Game1.lightmap.Bounds, Game1.currentLocation.name.Equals("UndergroundMine") ? Game1.mine.getLightingColor(gameTime) : ((!Game1.ambientLight.Equals(Color.White) && (!Game1.isRaining || !Game1.currentLocation.isOutdoors)) ? Game1.ambientLight : Game1.outdoorLight)); + for (int i = 0; i < Game1.currentLightSources.Count; i++) + { + if (Utility.isOnScreen(Game1.currentLightSources.ElementAt(i).position, (int)(Game1.currentLightSources.ElementAt(i).radius * Game1.tileSize * 4f))) + 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.screenWrapper); + } + if (Game1.bloomDay) + Game1.bloom?.BeginDraw(); + 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); + Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch); + Game1.currentLocation.Map.GetLayer("Back").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, Game1.pixelZoom); + Game1.currentLocation.drawWater(Game1.spriteBatch); + if (Game1.CurrentEvent == null) + { + using (List<NPC>.Enumerator enumerator = Game1.currentLocation.characters.GetEnumerator()) + { + while (enumerator.MoveNext()) + { + NPC current = enumerator.Current; + if (current != null && !current.swimming && !current.hideShadow && !current.IsMonster && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(current.getTileLocation())) + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, current.position + new Vector2(current.sprite.spriteWidth * Game1.pixelZoom / 2f, current.GetBoundingBox().Height + (current.IsMonster ? 0 : (Game1.pixelZoom * 3)))), Game1.shadowTexture.Bounds, Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), (Game1.pixelZoom + current.yJumpOffset / 40f) * current.scale, SpriteEffects.None, Math.Max(0f, current.getStandingY() / 10000f) - 1E-06f); + } + goto IL_B30; + } + } + foreach (NPC current2 in Game1.CurrentEvent.actors) + { + if (!current2.swimming && !current2.hideShadow && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(current2.getTileLocation())) + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, current2.position + new Vector2(current2.sprite.spriteWidth * Game1.pixelZoom / 2f, current2.GetBoundingBox().Height + (current2.IsMonster ? 0 : (Game1.pixelZoom * 3)))), Game1.shadowTexture.Bounds, Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), (Game1.pixelZoom + current2.yJumpOffset / 40f) * current2.scale, SpriteEffects.None, Math.Max(0f, current2.getStandingY() / 10000f) - 1E-06f); + } + IL_B30: + if (!Game1.player.swimming && !Game1.player.isRidingHorse() && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(Game1.player.getTileLocation())) + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.player.position + new Vector2(32f, 24f)), Game1.shadowTexture.Bounds, Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), 4f - (((Game1.player.running || Game1.player.usingTool) && Game1.player.FarmerSprite.indexInCurrentAnimation > 1) ? (Math.Abs(FarmerRenderer.featureYOffsetPerFrame[Game1.player.FarmerSprite.CurrentFrame]) * 0.5f) : 0f), SpriteEffects.None, 0f); + Game1.currentLocation.Map.GetLayer("Buildings").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, Game1.pixelZoom); + Game1.mapDisplayDevice.EndScene(); + Game1.spriteBatch.End(); + Game1.spriteBatch.Begin(SpriteSortMode.FrontToBack, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + if (Game1.CurrentEvent == null) + { + using (List<NPC>.Enumerator enumerator3 = Game1.currentLocation.characters.GetEnumerator()) + { + while (enumerator3.MoveNext()) + { + NPC current3 = enumerator3.Current; + if (current3 != null && !current3.swimming && !current3.hideShadow && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(current3.getTileLocation())) + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, current3.position + new Vector2(current3.sprite.spriteWidth * Game1.pixelZoom / 2f, current3.GetBoundingBox().Height + (current3.IsMonster ? 0 : (Game1.pixelZoom * 3)))), Game1.shadowTexture.Bounds, Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), (Game1.pixelZoom + current3.yJumpOffset / 40f) * current3.scale, SpriteEffects.None, Math.Max(0f, current3.getStandingY() / 10000f) - 1E-06f); + } + goto IL_F5F; + } + } + foreach (NPC current4 in Game1.CurrentEvent.actors) + { + if (!current4.swimming && !current4.hideShadow && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(current4.getTileLocation())) + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, current4.position + new Vector2(current4.sprite.spriteWidth * Game1.pixelZoom / 2f, current4.GetBoundingBox().Height + (current4.IsMonster ? 0 : (Game1.pixelZoom * 3)))), Game1.shadowTexture.Bounds, Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), (Game1.pixelZoom + current4.yJumpOffset / 40f) * current4.scale, SpriteEffects.None, Math.Max(0f, current4.getStandingY() / 10000f) - 1E-06f); + } + IL_F5F: + if (!Game1.player.swimming && !Game1.player.isRidingHorse() && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(Game1.player.getTileLocation())) + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.player.position + new Vector2(32f, 24f)), Game1.shadowTexture.Bounds, Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), 4f - (((Game1.player.running || Game1.player.usingTool) && Game1.player.FarmerSprite.indexInCurrentAnimation > 1) ? (Math.Abs(FarmerRenderer.featureYOffsetPerFrame[Game1.player.FarmerSprite.CurrentFrame]) * 0.5f) : 0f), SpriteEffects.None, Math.Max(0.0001f, Game1.player.getStandingY() / 10000f + 0.00011f) - 0.0001f); + if (Game1.displayFarmer) + Game1.player.draw(Game1.spriteBatch); + if ((Game1.eventUp || Game1.killScreen) && !Game1.killScreen) + Game1.currentLocation.currentEvent?.draw(Game1.spriteBatch); + if (Game1.player.currentUpgrade != null && Game1.player.currentUpgrade.daysLeftTillUpgradeDone <= 3 && Game1.currentLocation.Name.Equals("Farm")) + Game1.spriteBatch.Draw(Game1.player.currentUpgrade.workerTexture, Game1.GlobalToLocal(Game1.viewport, Game1.player.currentUpgrade.positionOfCarpenter), Game1.player.currentUpgrade.getSourceRectangle(), Color.White, 0f, Vector2.Zero, 1f, SpriteEffects.None, (Game1.player.currentUpgrade.positionOfCarpenter.Y + Game1.tileSize * 3 / 4) / 10000f); + Game1.currentLocation.draw(Game1.spriteBatch); + if (Game1.eventUp && Game1.currentLocation.currentEvent?.messageToScreen != null) + Game1.drawWithBorder(Game1.currentLocation.currentEvent.messageToScreen, Color.Black, Color.White, new Vector2(Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Width / 2 - Game1.borderFont.MeasureString(Game1.currentLocation.currentEvent.messageToScreen).X / 2f, Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Height - Game1.tileSize), 0f, 1f, 0.999f); + if (Game1.player.ActiveObject == null && (Game1.player.UsingTool || Game1.pickingTool) && Game1.player.CurrentTool != null && (!Game1.player.CurrentTool.Name.Equals("Seeds") || Game1.pickingTool)) + Game1.drawTool(Game1.player); + if (Game1.currentLocation.Name.Equals("Farm")) + this.drawFarmBuildings(); + if (Game1.tvStation >= 0) + Game1.spriteBatch.Draw(Game1.tvStationTexture, Game1.GlobalToLocal(Game1.viewport, new Vector2(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) + { + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Rectangle((int)Math.Floor((Game1.getOldMouseX() + Game1.viewport.X) / (double)Game1.tileSize) * Game1.tileSize - Game1.viewport.X, (int)Math.Floor((Game1.getOldMouseY() + Game1.viewport.Y) / (double)Game1.tileSize) * Game1.tileSize - Game1.viewport.Y, Game1.tileSize, Game1.tileSize), Color.Lime * 0.75f); + foreach (Warp current5 in Game1.currentLocation.warps) + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Rectangle(current5.X * Game1.tileSize - Game1.viewport.X, current5.Y * Game1.tileSize - Game1.viewport.Y, Game1.tileSize, Game1.tileSize), Color.Red * 0.75f); + } + Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch); + Game1.currentLocation.Map.GetLayer("Front").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, Game1.pixelZoom); + Game1.mapDisplayDevice.EndScene(); + Game1.currentLocation.drawAboveFrontLayer(Game1.spriteBatch); + Game1.spriteBatch.End(); + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + if (Game1.currentLocation.Name.Equals("Farm") && Game1.stats.SeedsSown >= 200u) + { + Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2(3 * Game1.tileSize + Game1.tileSize / 4, Game1.tileSize + Game1.tileSize / 3)), Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16), Color.White); + Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2(4 * Game1.tileSize + Game1.tileSize, 2 * Game1.tileSize + Game1.tileSize)), Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16), Color.White); + Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2(5 * Game1.tileSize, 2 * Game1.tileSize)), Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16), Color.White); + Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2(3 * Game1.tileSize + Game1.tileSize / 2, 3 * Game1.tileSize)), Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16), Color.White); + Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2(5 * Game1.tileSize - Game1.tileSize / 4, Game1.tileSize)), Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16), Color.White); + Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2(4 * Game1.tileSize, 3 * Game1.tileSize + Game1.tileSize / 6)), Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16), Color.White); + Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2(4 * Game1.tileSize + Game1.tileSize / 5, 2 * Game1.tileSize + Game1.tileSize / 3)), Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16), Color.White); + } + if (Game1.displayFarmer && Game1.player.ActiveObject != null && Game1.player.ActiveObject.bigCraftable && this.checkBigCraftableBoundariesForFrontLayer() && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), Game1.player.getStandingY()), Game1.viewport.Size) == null) + Game1.drawPlayerHeldObject(Game1.player); + else if (Game1.displayFarmer && Game1.player.ActiveObject != null && ((Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location((int)Game1.player.position.X, (int)Game1.player.position.Y - Game1.tileSize * 3 / 5), Game1.viewport.Size) != null && !Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location((int)Game1.player.position.X, (int)Game1.player.position.Y - Game1.tileSize * 3 / 5), Game1.viewport.Size).TileIndexProperties.ContainsKey("FrontAlways")) || (Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.GetBoundingBox().Right, (int)Game1.player.position.Y - Game1.tileSize * 3 / 5), Game1.viewport.Size) != null && !Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.GetBoundingBox().Right, (int)Game1.player.position.Y - Game1.tileSize * 3 / 5), Game1.viewport.Size).TileIndexProperties.ContainsKey("FrontAlways")))) + Game1.drawPlayerHeldObject(Game1.player); + if ((Game1.player.UsingTool || Game1.pickingTool) && Game1.player.CurrentTool != null && (!Game1.player.CurrentTool.Name.Equals("Seeds") || Game1.pickingTool) && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), (int)Game1.player.position.Y - Game1.tileSize * 3 / 5), Game1.viewport.Size) != null && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), Game1.player.getStandingY()), Game1.viewport.Size) == null) + Game1.drawTool(Game1.player); + if (Game1.currentLocation.Map.GetLayer("AlwaysFront") != null) + { + Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch); + Game1.currentLocation.Map.GetLayer("AlwaysFront").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, Game1.pixelZoom); + Game1.mapDisplayDevice.EndScene(); + } + if (Game1.toolHold > 400f && Game1.player.CurrentTool.UpgradeLevel >= 1 && Game1.player.canReleaseTool) + { + Color color = Color.White; + switch ((int)(Game1.toolHold / 600f) + 2) + { + case 1: + color = Tool.copperColor; + break; + case 2: + color = Tool.steelColor; + break; + case 3: + color = Tool.goldColor; + break; + case 4: + color = Tool.iridiumColor; + break; + } + Game1.spriteBatch.Draw(Game1.littleEffect, new Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X - 2, (int)Game1.player.getLocalPosition(Game1.viewport).Y - (Game1.player.CurrentTool.Name.Equals("Watering Can") ? 0 : Game1.tileSize) - 2, (int)(Game1.toolHold % 600f * 0.08f) + 4, Game1.tileSize / 8 + 4), Color.Black); + Game1.spriteBatch.Draw(Game1.littleEffect, new Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X, (int)Game1.player.getLocalPosition(Game1.viewport).Y - (Game1.player.CurrentTool.Name.Equals("Watering Can") ? 0 : Game1.tileSize), (int)(Game1.toolHold % 600f * 0.08f), Game1.tileSize / 8), color); + } + if (Game1.isDebrisWeather && Game1.currentLocation.IsOutdoors && !Game1.currentLocation.ignoreDebrisWeather && !Game1.currentLocation.Name.Equals("Desert") && Game1.viewport.X > -10) + { + foreach (WeatherDebris current6 in Game1.debrisWeather) + current6.draw(Game1.spriteBatch); + } + Game1.farmEvent?.draw(Game1.spriteBatch); + if (Game1.currentLocation.LightLevel > 0f && Game1.timeOfDay < 2000) + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * Game1.currentLocation.LightLevel); + if (Game1.screenGlow) + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Game1.screenGlowColor * Game1.screenGlowAlpha); + Game1.currentLocation.drawAboveAlwaysFrontLayer(Game1.spriteBatch); + if (Game1.player.CurrentTool is FishingRod && ((Game1.player.CurrentTool as FishingRod).isTimingCast || (Game1.player.CurrentTool as FishingRod).castingChosenCountdown > 0f || (Game1.player.CurrentTool as FishingRod).fishCaught || (Game1.player.CurrentTool as FishingRod).showingTreasure)) + Game1.player.CurrentTool.draw(Game1.spriteBatch); + if (Game1.isRaining && Game1.currentLocation.IsOutdoors && !Game1.currentLocation.Name.Equals("Desert") && !(Game1.currentLocation is Summit) && (!Game1.eventUp || Game1.currentLocation.isTileOnMap(new Vector2(Game1.viewport.X / Game1.tileSize, Game1.viewport.Y / Game1.tileSize)))) + { + for (int j = 0; j < Game1.rainDrops.Length; j++) + Game1.spriteBatch.Draw(Game1.rainTexture, Game1.rainDrops[j].position, Game1.getSourceRectForStandardTileSheet(Game1.rainTexture, Game1.rainDrops[j].frame), Color.White); + } + + Game1.spriteBatch.End(); + + //base.Draw(gameTime); + + Game1.spriteBatch.Begin(SpriteSortMode.FrontToBack, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + if (Game1.eventUp && Game1.currentLocation.currentEvent != null) + { + foreach (NPC current7 in Game1.currentLocation.currentEvent.actors) + { + if (current7.isEmoting) + { + Vector2 localPosition = current7.getLocalPosition(Game1.viewport); + localPosition.Y -= Game1.tileSize * 2 + Game1.pixelZoom * 3; + if (current7.age == 2) + localPosition.Y += Game1.tileSize / 2; + else if (current7.gender == 1) + localPosition.Y += Game1.tileSize / 6; + Game1.spriteBatch.Draw(Game1.emoteSpriteSheet, localPosition, new Rectangle(current7.CurrentEmoteIndex * (Game1.tileSize / 4) % Game1.emoteSpriteSheet.Width, current7.CurrentEmoteIndex * (Game1.tileSize / 4) / Game1.emoteSpriteSheet.Width * (Game1.tileSize / 4), Game1.tileSize / 4, Game1.tileSize / 4), Color.White, 0f, Vector2.Zero, 4f, SpriteEffects.None, current7.getStandingY() / 10000f); + } + } + } + Game1.spriteBatch.End(); + if (Game1.drawLighting) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, new BlendState + { + ColorBlendFunction = BlendFunction.ReverseSubtract, + ColorDestinationBlend = Blend.One, + ColorSourceBlend = Blend.SourceColor + }, SamplerState.LinearClamp, null, null); + Game1.spriteBatch.Draw(Game1.lightmap, Vector2.Zero, Game1.lightmap.Bounds, Color.White, 0f, Vector2.Zero, Game1.options.lightingQuality, SpriteEffects.None, 1f); + if (Game1.isRaining && Game1.currentLocation.isOutdoors && !(Game1.currentLocation is Desert)) + { + Game1.spriteBatch.Draw(Game1.staminaRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.OrangeRed * 0.45f); + } + Game1.spriteBatch.End(); + } + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + if (Game1.drawGrid) + { + int num2 = -Game1.viewport.X % Game1.tileSize; + float num3 = -(float)Game1.viewport.Y % Game1.tileSize; + for (int k = num2; k < Game1.graphics.GraphicsDevice.Viewport.Width; k += Game1.tileSize) + Game1.spriteBatch.Draw(Game1.staminaRect, new Rectangle(k, (int)num3, 1, Game1.graphics.GraphicsDevice.Viewport.Height), Color.Red * 0.5f); + for (float num4 = num3; num4 < (float)Game1.graphics.GraphicsDevice.Viewport.Height; num4 += (float)Game1.tileSize) + Game1.spriteBatch.Draw(Game1.staminaRect, new Rectangle(num2, (int)num4, Game1.graphics.GraphicsDevice.Viewport.Width, 1), Color.Red * 0.5f); + } + if (Game1.currentBillboard != 0) + this.drawBillboard(); + + if ((Game1.displayHUD || Game1.eventUp) && Game1.currentBillboard == 0 && Game1.gameMode == 3 && !Game1.freezeControls && !Game1.panMode) + { + GraphicsEvents.InvokeOnPreRenderHudEvent(this.Monitor); + this.drawHUD(); + GraphicsEvents.InvokeOnPostRenderHudEvent(this.Monitor); + } + else if (Game1.activeClickableMenu == null && Game1.farmEvent == null) + Game1.spriteBatch.Draw(Game1.mouseCursors, new Vector2(Game1.getOldMouseX(), Game1.getOldMouseY()), Game1.getSourceRectForStandardTileSheet(Game1.mouseCursors, 0, 16, 16), Color.White, 0f, Vector2.Zero, 4f + Game1.dialogueButtonScale / 150f, SpriteEffects.None, 1f); + + if (Game1.hudMessages.Any() && (!Game1.eventUp || Game1.isFestival())) + { + for (int l = Game1.hudMessages.Count - 1; l >= 0; l--) + Game1.hudMessages[l].draw(Game1.spriteBatch, l); + } + } + Game1.farmEvent?.draw(Game1.spriteBatch); + if (Game1.dialogueUp && !Game1.nameSelectUp && !Game1.messagePause && !(Game1.activeClickableMenu is DialogueBox)) + 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); + Game1.spriteBatch.Draw(Game1.staminaRect, new Rectangle((Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Width - Game1.dialogueWidth) / 2, Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Bottom - Game1.tileSize * 2, (int)(Game1.pauseAccumulator / Game1.pauseTime * Game1.dialogueWidth), Game1.tileSize / 2), Color.DimGray); + } + if (Game1.eventUp) + Game1.currentLocation.currentEvent?.drawAfterMap(Game1.spriteBatch); + if (Game1.isRaining && Game1.currentLocation.isOutdoors && !(Game1.currentLocation is Desert)) + Game1.spriteBatch.Draw(Game1.staminaRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Blue * 0.2f); + if ((Game1.fadeToBlack || Game1.globalFade) && !Game1.menuUp && (!Game1.nameSelectUp || Game1.messagePause)) + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * ((Game1.gameMode == 0) ? (1f - Game1.fadeToBlackAlpha) : Game1.fadeToBlackAlpha)); + else if (Game1.flashAlpha > 0f) + { + if (Game1.options.screenFlash) + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.White * Math.Min(1f, Game1.flashAlpha)); + Game1.flashAlpha -= 0.1f; + } + if ((Game1.messagePause || Game1.globalFade) && Game1.dialogueUp) + this.drawDialogueBox(); + foreach (TemporaryAnimatedSprite current8 in Game1.screenOverlayTempSprites) + current8.draw(Game1.spriteBatch, true); + if (Game1.debugMode) + { + Game1.spriteBatch.DrawString(Game1.smallFont, string.Concat(new object[] + { + Game1.panMode ? ((Game1.getOldMouseX() + Game1.viewport.X) / Game1.tileSize + "," + (Game1.getOldMouseY() + Game1.viewport.Y) / Game1.tileSize) : string.Concat("aplayer: ", Game1.player.getStandingX() / Game1.tileSize, ", ", Game1.player.getStandingY() / Game1.tileSize), + Environment.NewLine, + "debugOutput: ", + Game1.debugOutput + }), new Vector2(this.GraphicsDevice.Viewport.TitleSafeArea.X, this.GraphicsDevice.Viewport.TitleSafeArea.Y), Color.Red, 0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f); + } + /*if (inputMode) + { + spriteBatch.DrawString(smallFont, "Input: " + debugInput, new Vector2(tileSize, tileSize * 3), Color.Purple); + }*/ + 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); + + if (Game1.activeClickableMenu != null) + { + GraphicsEvents.InvokeOnPreRenderGuiEvent(this.Monitor); + try + { + Game1.activeClickableMenu.draw(Game1.spriteBatch); + } + catch (Exception ex) + { + this.Monitor.Log($"The {Game1.activeClickableMenu.GetType().FullName} menu crashed while drawing itself. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); + Game1.activeClickableMenu.exitThisMenu(); + } + GraphicsEvents.InvokeOnPostRenderGuiEvent(this.Monitor); + } + else + Game1.farmEvent?.drawAboveEverything(Game1.spriteBatch); + + GraphicsEvents.InvokeOnPostRenderEvent(this.Monitor); + Game1.spriteBatch.End(); + + if (!this.ZoomLevelIsOne) + { + this.GraphicsDevice.SetRenderTarget(null); + this.GraphicsDevice.Clear(this.bgColor); + Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.Opaque, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); + Game1.spriteBatch.Draw(this.screenWrapper, Vector2.Zero, this.screenWrapper.Bounds, Color.White, 0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.End(); + } + } + catch (Exception ex) + { + this.Monitor.Log($"An error occured in the overridden draw loop: {ex.GetLogSummary()}", LogLevel.Error); + } + } + + /**** + ** 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) + { + 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> + 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(); + } + + /// <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); + } + return buttons.ToArray(); + } + + /// <summary>Get whether a controller button was pressed since the last check.</summary> + /// <param name="button">The controller button to check.</param> + /// <param name="buttonState">The last known state.</param> + /// <param name="stateIndex">The player whose controller to check.</param> + private bool WasButtonJustPressed(Buttons button, ButtonState buttonState, PlayerIndex stateIndex) + { + return buttonState == ButtonState.Pressed && !this.PreviouslyPressedButtons[(int)stateIndex].Contains(button); + } + + /// <summary>Get whether a controller button was released since the last check.</summary> + /// <param name="button">The controller button to check.</param> + /// <param name="buttonState">The last known state.</param> + /// <param name="stateIndex">The player whose controller to check.</param> + private bool WasButtonJustReleased(Buttons button, ButtonState buttonState, PlayerIndex stateIndex) + { + return buttonState == ButtonState.Released && this.PreviouslyPressedButtons[(int)stateIndex].Contains(button); + } + + /// <summary>Get whether an analogue controller button was pressed since the last check.</summary> + /// <param name="button">The controller button to check.</param> + /// <param name="value">The last known value.</param> + /// <param name="stateIndex">The player whose controller to check.</param> + private bool WasButtonJustPressed(Buttons button, float value, PlayerIndex stateIndex) + { + return this.WasButtonJustPressed(button, value > 0.2f ? ButtonState.Pressed : ButtonState.Released, stateIndex); + } + + /// <summary>Get whether an analogue controller button was released since the last check.</summary> + /// <param name="button">The controller button to check.</param> + /// <param name="value">The last known value.</param> + /// <param name="stateIndex">The player whose controller to check.</param> + private bool WasButtonJustReleased(Buttons button, float value, PlayerIndex stateIndex) + { + return this.WasButtonJustReleased(button, value > 0.2f ? ButtonState.Pressed : ButtonState.Released, stateIndex); + } + + /// <summary>Detect changes since the last update ticket and trigger mod events.</summary> + private void UpdateEventCalls() + { + // save loaded event + 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 + this.KStateNow = Keyboard.GetState(); + this.MStateNow = Mouse.GetState(); + this.MPositionNow = new Point(Game1.getMouseX(), Game1.getMouseY()); + + // raise key pressed + foreach (var key in this.FramePressedKeys) + ControlEvents.InvokeKeyPressed(this.Monitor, key); + + // raise key released + foreach (var key in this.FrameReleasedKeys) + ControlEvents.InvokeKeyReleased(this.Monitor, key); + + // raise controller button pressed + for (var i = PlayerIndex.One; i <= PlayerIndex.Four; i++) + { + var buttons = this.GetFramePressedButtons(i); + foreach (var button in buttons) + { + if (button == Buttons.LeftTrigger || button == Buttons.RightTrigger) + ControlEvents.InvokeTriggerPressed(this.Monitor, i, button, button == Buttons.LeftTrigger ? GamePad.GetState(i).Triggers.Left : GamePad.GetState(i).Triggers.Right); + else + ControlEvents.InvokeButtonPressed(this.Monitor, i, button); + } + } + + // raise controller button released + for (var i = PlayerIndex.One; i <= PlayerIndex.Four; i++) + { + foreach (var button in this.GetFrameReleasedButtons(i)) + { + if (button == Buttons.LeftTrigger || button == Buttons.RightTrigger) + ControlEvents.InvokeTriggerReleased(this.Monitor, i, button, button == Buttons.LeftTrigger ? GamePad.GetState(i).Triggers.Left : GamePad.GetState(i).Triggers.Right); + else + ControlEvents.InvokeButtonReleased(this.Monitor, i, button); + } + } + + // raise keyboard state changed + if (this.KStateNow != this.KStatePrior) + ControlEvents.InvokeKeyboardChanged(this.Monitor, this.KStatePrior, this.KStateNow); + + // raise mouse state changed + if (this.MStateNow != this.MStatePrior) + { + ControlEvents.InvokeMouseChanged(this.Monitor, this.MStatePrior, this.MStateNow, this.MPositionPrior, this.MPositionNow); + this.MStatePrior = this.MStateNow; + this.MPositionPrior = this.MPositionPrior; + } + } + + // menu events + if (Game1.activeClickableMenu != this.PreviousActiveMenu) + { + IClickableMenu previousMenu = this.PreviousActiveMenu; + IClickableMenu newMenu = Game1.activeClickableMenu; + + // raise save events + // (saving is performed by SaveGameMenu; on days when the player shipping something, ShippingMenu wraps SaveGameMenu) + 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) + MenuEvents.InvokeMenuChanged(this.Monitor, previousMenu, newMenu); + else + MenuEvents.InvokeMenuClosed(this.Monitor, previousMenu); + + // update previous menu + // (if the menu was changed in one of the handlers, deliberately defer detection until the next update so mods can be notified of the new menu change) + this.PreviousActiveMenu = newMenu; + } + + // world & player events + if (this.IsWorldReady) + { + // raise location list changed + if (this.GetHash(Game1.locations) != this.PreviousGameLocations) + { + LocationEvents.InvokeLocationsChanged(this.Monitor, Game1.locations); + this.PreviousGameLocations = this.GetHash(Game1.locations); + } + + // raise current location changed + if (Game1.currentLocation != this.PreviousGameLocation) + { + LocationEvents.InvokeCurrentLocationChanged(this.Monitor, this.PreviousGameLocation, Game1.currentLocation); + this.PreviousGameLocation = Game1.currentLocation; + } + + // raise player changed + if (Game1.player != this.PreviousFarmer) + { + PlayerEvents.InvokeFarmerChanged(this.Monitor, this.PreviousFarmer, Game1.player); + this.PreviousFarmer = Game1.player; + } + + // raise player leveled up a skill + if (Game1.player.combatLevel != this.PreviousCombatLevel) + { + PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Combat, Game1.player.combatLevel); + this.PreviousCombatLevel = Game1.player.combatLevel; + } + if (Game1.player.farmingLevel != this.PreviousFarmingLevel) + { + PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Farming, Game1.player.farmingLevel); + this.PreviousFarmingLevel = Game1.player.farmingLevel; + } + if (Game1.player.fishingLevel != this.PreviousFishingLevel) + { + PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Fishing, Game1.player.fishingLevel); + this.PreviousFishingLevel = Game1.player.fishingLevel; + } + if (Game1.player.foragingLevel != this.PreviousForagingLevel) + { + PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Foraging, Game1.player.foragingLevel); + this.PreviousForagingLevel = Game1.player.foragingLevel; + } + if (Game1.player.miningLevel != this.PreviousMiningLevel) + { + PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Mining, Game1.player.miningLevel); + this.PreviousMiningLevel = Game1.player.miningLevel; + } + if (Game1.player.luckLevel != this.PreviousLuckLevel) + { + PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Luck, Game1.player.luckLevel); + this.PreviousLuckLevel = Game1.player.luckLevel; + } + + // raise player inventory changed + ItemStackChange[] changedItems = this.GetInventoryChanges(Game1.player.items, this.PreviousItems).ToArray(); + if (changedItems.Any()) + { + PlayerEvents.InvokeInventoryChanged(this.Monitor, Game1.player.items, changedItems); + this.PreviousItems = Game1.player.items.Where(n => n != null).ToDictionary(n => n, n => n.Stack); + } + + // raise current location's object list changed + { + int? objectHash = Game1.currentLocation?.objects != null ? this.GetHash(Game1.currentLocation.objects) : (int?)null; + if (objectHash != null && this.PreviousLocationObjects != objectHash) + { + LocationEvents.InvokeOnNewLocationObject(this.Monitor, Game1.currentLocation.objects); + this.PreviousLocationObjects = objectHash.Value; + } + } + + // raise time changed + if (Game1.timeOfDay != this.PreviousTime) + { + TimeEvents.InvokeTimeOfDayChanged(this.Monitor, this.PreviousTime, Game1.timeOfDay); + this.PreviousTime = Game1.timeOfDay; + } + if (Game1.dayOfMonth != this.PreviousDay) + { + TimeEvents.InvokeDayOfMonthChanged(this.Monitor, this.PreviousDay, Game1.dayOfMonth); + this.PreviousDay = Game1.dayOfMonth; + } + if (Game1.currentSeason != this.PreviousSeason) + { + TimeEvents.InvokeSeasonOfYearChanged(this.Monitor, this.PreviousSeason, Game1.currentSeason); + this.PreviousSeason = Game1.currentSeason; + } + if (Game1.year != this.PreviousYear) + { + TimeEvents.InvokeYearOfGameChanged(this.Monitor, this.PreviousYear, Game1.year); + this.PreviousYear = Game1.year; + } + + // raise mine level changed + if (Game1.mine != null && Game1.mine.mineLevel != this.PreviousMineLevel) + { + MineEvents.InvokeMineLevelChanged(this.Monitor, this.PreviousMineLevel, Game1.mine.mineLevel); + this.PreviousMineLevel = Game1.mine.mineLevel; + } + } + + // raise game day transition event (obsolete) + if (Game1.newDay != this.PreviousIsNewDay) + { + TimeEvents.InvokeOnNewDay(this.Monitor, this.PreviousDay, Game1.dayOfMonth, Game1.newDay); + this.PreviousIsNewDay = Game1.newDay; + } + } + + /// <summary>Get the player inventory changes between two states.</summary> + /// <param name="current">The player's current inventory.</param> + /// <param name="previous">The player's previous inventory.</param> + private IEnumerable<ItemStackChange> GetInventoryChanges(IEnumerable<Item> current, IDictionary<Item, int> previous) + { + current = current.Where(n => n != null).ToArray(); + foreach (Item item in current) + { + // stack size changed + if (previous != null && previous.ContainsKey(item)) + { + if (previous[item] != item.Stack) + yield return new ItemStackChange { Item = item, StackChange = item.Stack - previous[item], ChangeType = ChangeType.StackChange }; + } + + // new item + else + yield return new ItemStackChange { Item = item, StackChange = item.Stack, ChangeType = ChangeType.Added }; + } + + // removed items + if (previous != null) + { + foreach (var entry in previous) + { + if (current.Any(i => i == entry.Key)) + continue; + + yield return new ItemStackChange { Item = entry.Key, StackChange = -entry.Key.Stack, ChangeType = ChangeType.Removed }; + } + } + } + + /// <summary>Get a hash value for an enumeration.</summary> + /// <param name="enumerable">The enumeration of items to hash.</param> + private int GetHash(IEnumerable enumerable) + { + var hash = 0; + foreach (var v in enumerable) + hash ^= v.GetHashCode(); + return hash; + } + } +}
\ 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."); + } + } +} |