summaryrefslogtreecommitdiff
path: root/src/StardewModdingAPI/Framework
diff options
context:
space:
mode:
Diffstat (limited to 'src/StardewModdingAPI/Framework')
-rw-r--r--src/StardewModdingAPI/Framework/AssemblyLoader.cs134
-rw-r--r--src/StardewModdingAPI/Framework/Command.cs40
-rw-r--r--src/StardewModdingAPI/Framework/CommandHelper.cs53
-rw-r--r--src/StardewModdingAPI/Framework/CommandManager.cs117
-rw-r--r--src/StardewModdingAPI/Framework/Content/ContentEventData.cs111
-rw-r--r--src/StardewModdingAPI/Framework/Content/ContentEventHelper.cs47
-rw-r--r--src/StardewModdingAPI/Framework/Content/ContentEventHelperForDictionary.cs45
-rw-r--r--src/StardewModdingAPI/Framework/Content/ContentEventHelperForImage.cs70
-rw-r--r--src/StardewModdingAPI/Framework/DeprecationManager.cs2
-rw-r--r--src/StardewModdingAPI/Framework/InternalExtensions.cs16
-rw-r--r--src/StardewModdingAPI/Framework/Logging/ConsoleInterceptionManager.cs86
-rw-r--r--src/StardewModdingAPI/Framework/Logging/InterceptingTextWriter.cs79
-rw-r--r--src/StardewModdingAPI/Framework/Logging/LogFileManager.cs (renamed from src/StardewModdingAPI/Framework/LogFileManager.cs)8
-rw-r--r--src/StardewModdingAPI/Framework/Manifest.cs39
-rw-r--r--src/StardewModdingAPI/Framework/ModHelper.cs111
-rw-r--r--src/StardewModdingAPI/Framework/ModRegistry.cs30
-rw-r--r--src/StardewModdingAPI/Framework/Models/IncompatibleMod.cs57
-rw-r--r--src/StardewModdingAPI/Framework/Models/ModCompatibility.cs65
-rw-r--r--src/StardewModdingAPI/Framework/Models/ModCompatibilityType.cs12
-rw-r--r--src/StardewModdingAPI/Framework/Models/SConfig.cs (renamed from src/StardewModdingAPI/Framework/Models/UserSettings.cs)11
-rw-r--r--src/StardewModdingAPI/Framework/Monitor.cs71
-rw-r--r--src/StardewModdingAPI/Framework/Reflection/PrivateProperty.cs93
-rw-r--r--src/StardewModdingAPI/Framework/Reflection/ReflectionHelper.cs57
-rw-r--r--src/StardewModdingAPI/Framework/RequestExitDelegate.cs7
-rw-r--r--src/StardewModdingAPI/Framework/SContentManager.cs135
-rw-r--r--src/StardewModdingAPI/Framework/SGame.cs1063
-rw-r--r--src/StardewModdingAPI/Framework/Serialisation/JsonHelper.cs69
-rw-r--r--src/StardewModdingAPI/Framework/Serialisation/SelectiveStringEnumConverter.cs37
-rw-r--r--src/StardewModdingAPI/Framework/Serialisation/SemanticVersionConverter.cs51
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.");
+ }
+ }
+}