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(