From 929dccb75a1405737975d76648e015a3e7c00177 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 7 Oct 2017 23:07:10 -0400 Subject: reorganise repo structure --- src/SMAPI/Framework/Command.cs | 40 + src/SMAPI/Framework/CommandManager.cs | 116 ++ src/SMAPI/Framework/Content/AssetData.cs | 44 + .../Framework/Content/AssetDataForDictionary.cs | 45 + src/SMAPI/Framework/Content/AssetDataForImage.cs | 70 + src/SMAPI/Framework/Content/AssetDataForObject.cs | 54 + src/SMAPI/Framework/Content/AssetInfo.cs | 82 ++ src/SMAPI/Framework/ContentManagerShim.cs | 50 + src/SMAPI/Framework/CursorPosition.cs | 35 + src/SMAPI/Framework/DeprecationLevel.cs | 15 + src/SMAPI/Framework/DeprecationManager.cs | 105 ++ .../Exceptions/SAssemblyLoadFailedException.cs | 16 + .../Framework/Exceptions/SContentLoadException.cs | 18 + src/SMAPI/Framework/Exceptions/SParseException.cs | 17 + src/SMAPI/Framework/GameVersion.cs | 68 + src/SMAPI/Framework/IModMetadata.cs | 47 + src/SMAPI/Framework/InternalExtensions.cs | 131 ++ .../Logging/ConsoleInterceptionManager.cs | 86 ++ .../Framework/Logging/InterceptingTextWriter.cs | 63 + src/SMAPI/Framework/Logging/LogFileManager.cs | 57 + src/SMAPI/Framework/ModHelpers/BaseHelper.cs | 23 + src/SMAPI/Framework/ModHelpers/CommandHelper.cs | 54 + src/SMAPI/Framework/ModHelpers/ContentHelper.cs | 476 +++++++ src/SMAPI/Framework/ModHelpers/ModHelper.cs | 129 ++ .../Framework/ModHelpers/ModRegistryHelper.cs | 48 + src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs | 200 +++ .../Framework/ModHelpers/TranslationHelper.cs | 140 ++ .../ModLoading/AssemblyDefinitionResolver.cs | 61 + .../Framework/ModLoading/AssemblyLoadStatus.cs | 15 + src/SMAPI/Framework/ModLoading/AssemblyLoader.cs | 333 +++++ .../Framework/ModLoading/AssemblyParseResult.cs | 36 + .../Framework/ModLoading/Finders/EventFinder.cs | 82 ++ .../Framework/ModLoading/Finders/FieldFinder.cs | 82 ++ .../Framework/ModLoading/Finders/MethodFinder.cs | 82 ++ .../Framework/ModLoading/Finders/PropertyFinder.cs | 82 ++ .../Framework/ModLoading/Finders/TypeFinder.cs | 133 ++ .../Framework/ModLoading/IInstructionHandler.cs | 34 + .../ModLoading/IncompatibleInstructionException.cs | 35 + .../ModLoading/InstructionHandleResult.cs | 24 + .../ModLoading/InvalidModStateException.cs | 14 + .../Framework/ModLoading/ModDependencyStatus.cs | 18 + src/SMAPI/Framework/ModLoading/ModMetadata.cs | 68 + .../Framework/ModLoading/ModMetadataStatus.cs | 12 + src/SMAPI/Framework/ModLoading/ModResolver.cs | 366 +++++ src/SMAPI/Framework/ModLoading/Platform.cs | 12 + .../Framework/ModLoading/PlatformAssemblyMap.cs | 55 + src/SMAPI/Framework/ModLoading/RewriteHelper.cs | 94 ++ .../ModLoading/Rewriters/FieldReplaceRewriter.cs | 50 + .../Rewriters/FieldToPropertyRewriter.cs | 51 + .../ModLoading/Rewriters/MethodParentRewriter.cs | 88 ++ .../ModLoading/Rewriters/TypeReferenceRewriter.cs | 154 +++ .../Rewriters/VirtualEntryCallRemover.cs | 90 ++ src/SMAPI/Framework/ModRegistry.cs | 113 ++ src/SMAPI/Framework/Models/Manifest.cs | 47 + src/SMAPI/Framework/Models/ManifestDependency.cs | 34 + src/SMAPI/Framework/Models/ModCompatibility.cs | 55 + src/SMAPI/Framework/Models/ModDataID.cs | 85 ++ src/SMAPI/Framework/Models/ModDataRecord.cs | 63 + src/SMAPI/Framework/Models/ModStatus.cs | 18 + src/SMAPI/Framework/Models/SConfig.cs | 27 + src/SMAPI/Framework/Monitor.cs | 194 +++ src/SMAPI/Framework/Reflection/CacheEntry.cs | 30 + src/SMAPI/Framework/Reflection/PrivateField.cs | 93 ++ src/SMAPI/Framework/Reflection/PrivateMethod.cs | 99 ++ src/SMAPI/Framework/Reflection/PrivateProperty.cs | 93 ++ src/SMAPI/Framework/Reflection/Reflector.cs | 276 ++++ src/SMAPI/Framework/RequestExitDelegate.cs | 7 + src/SMAPI/Framework/SContentManager.cs | 531 ++++++++ src/SMAPI/Framework/SGame.cs | 1403 ++++++++++++++++++++ src/SMAPI/Framework/Serialisation/JsonHelper.cs | 96 ++ .../Framework/Serialisation/SFieldConverter.cs | 121 ++ .../Serialisation/SelectiveStringEnumConverter.cs | 37 + src/SMAPI/Framework/Utilities/ContextHash.cs | 61 + src/SMAPI/Framework/Utilities/Countdown.cs | 44 + src/SMAPI/Framework/WebApiClient.cs | 73 + 75 files changed, 7900 insertions(+) create mode 100644 src/SMAPI/Framework/Command.cs create mode 100644 src/SMAPI/Framework/CommandManager.cs create mode 100644 src/SMAPI/Framework/Content/AssetData.cs create mode 100644 src/SMAPI/Framework/Content/AssetDataForDictionary.cs create mode 100644 src/SMAPI/Framework/Content/AssetDataForImage.cs create mode 100644 src/SMAPI/Framework/Content/AssetDataForObject.cs create mode 100644 src/SMAPI/Framework/Content/AssetInfo.cs create mode 100644 src/SMAPI/Framework/ContentManagerShim.cs create mode 100644 src/SMAPI/Framework/CursorPosition.cs create mode 100644 src/SMAPI/Framework/DeprecationLevel.cs create mode 100644 src/SMAPI/Framework/DeprecationManager.cs create mode 100644 src/SMAPI/Framework/Exceptions/SAssemblyLoadFailedException.cs create mode 100644 src/SMAPI/Framework/Exceptions/SContentLoadException.cs create mode 100644 src/SMAPI/Framework/Exceptions/SParseException.cs create mode 100644 src/SMAPI/Framework/GameVersion.cs create mode 100644 src/SMAPI/Framework/IModMetadata.cs create mode 100644 src/SMAPI/Framework/InternalExtensions.cs create mode 100644 src/SMAPI/Framework/Logging/ConsoleInterceptionManager.cs create mode 100644 src/SMAPI/Framework/Logging/InterceptingTextWriter.cs create mode 100644 src/SMAPI/Framework/Logging/LogFileManager.cs create mode 100644 src/SMAPI/Framework/ModHelpers/BaseHelper.cs create mode 100644 src/SMAPI/Framework/ModHelpers/CommandHelper.cs create mode 100644 src/SMAPI/Framework/ModHelpers/ContentHelper.cs create mode 100644 src/SMAPI/Framework/ModHelpers/ModHelper.cs create mode 100644 src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs create mode 100644 src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs create mode 100644 src/SMAPI/Framework/ModHelpers/TranslationHelper.cs create mode 100644 src/SMAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs create mode 100644 src/SMAPI/Framework/ModLoading/AssemblyLoadStatus.cs create mode 100644 src/SMAPI/Framework/ModLoading/AssemblyLoader.cs create mode 100644 src/SMAPI/Framework/ModLoading/AssemblyParseResult.cs create mode 100644 src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs create mode 100644 src/SMAPI/Framework/ModLoading/Finders/FieldFinder.cs create mode 100644 src/SMAPI/Framework/ModLoading/Finders/MethodFinder.cs create mode 100644 src/SMAPI/Framework/ModLoading/Finders/PropertyFinder.cs create mode 100644 src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs create mode 100644 src/SMAPI/Framework/ModLoading/IInstructionHandler.cs create mode 100644 src/SMAPI/Framework/ModLoading/IncompatibleInstructionException.cs create mode 100644 src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs create mode 100644 src/SMAPI/Framework/ModLoading/InvalidModStateException.cs create mode 100644 src/SMAPI/Framework/ModLoading/ModDependencyStatus.cs create mode 100644 src/SMAPI/Framework/ModLoading/ModMetadata.cs create mode 100644 src/SMAPI/Framework/ModLoading/ModMetadataStatus.cs create mode 100644 src/SMAPI/Framework/ModLoading/ModResolver.cs create mode 100644 src/SMAPI/Framework/ModLoading/Platform.cs create mode 100644 src/SMAPI/Framework/ModLoading/PlatformAssemblyMap.cs create mode 100644 src/SMAPI/Framework/ModLoading/RewriteHelper.cs create mode 100644 src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs create mode 100644 src/SMAPI/Framework/ModLoading/Rewriters/FieldToPropertyRewriter.cs create mode 100644 src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs create mode 100644 src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs create mode 100644 src/SMAPI/Framework/ModLoading/Rewriters/VirtualEntryCallRemover.cs create mode 100644 src/SMAPI/Framework/ModRegistry.cs create mode 100644 src/SMAPI/Framework/Models/Manifest.cs create mode 100644 src/SMAPI/Framework/Models/ManifestDependency.cs create mode 100644 src/SMAPI/Framework/Models/ModCompatibility.cs create mode 100644 src/SMAPI/Framework/Models/ModDataID.cs create mode 100644 src/SMAPI/Framework/Models/ModDataRecord.cs create mode 100644 src/SMAPI/Framework/Models/ModStatus.cs create mode 100644 src/SMAPI/Framework/Models/SConfig.cs create mode 100644 src/SMAPI/Framework/Monitor.cs create mode 100644 src/SMAPI/Framework/Reflection/CacheEntry.cs create mode 100644 src/SMAPI/Framework/Reflection/PrivateField.cs create mode 100644 src/SMAPI/Framework/Reflection/PrivateMethod.cs create mode 100644 src/SMAPI/Framework/Reflection/PrivateProperty.cs create mode 100644 src/SMAPI/Framework/Reflection/Reflector.cs create mode 100644 src/SMAPI/Framework/RequestExitDelegate.cs create mode 100644 src/SMAPI/Framework/SContentManager.cs create mode 100644 src/SMAPI/Framework/SGame.cs create mode 100644 src/SMAPI/Framework/Serialisation/JsonHelper.cs create mode 100644 src/SMAPI/Framework/Serialisation/SFieldConverter.cs create mode 100644 src/SMAPI/Framework/Serialisation/SelectiveStringEnumConverter.cs create mode 100644 src/SMAPI/Framework/Utilities/ContextHash.cs create mode 100644 src/SMAPI/Framework/Utilities/Countdown.cs create mode 100644 src/SMAPI/Framework/WebApiClient.cs (limited to 'src/SMAPI/Framework') diff --git a/src/SMAPI/Framework/Command.cs b/src/SMAPI/Framework/Command.cs new file mode 100644 index 00000000..943e018d --- /dev/null +++ b/src/SMAPI/Framework/Command.cs @@ -0,0 +1,40 @@ +using System; + +namespace StardewModdingAPI.Framework +{ + /// A command that can be submitted through the SMAPI console to interact with SMAPI. + internal class Command + { + /********* + ** Accessor + *********/ + /// The friendly name for the mod that registered the command. + public string ModName { get; } + + /// The command name, which the user must type to trigger it. + public string Name { get; } + + /// The human-readable documentation shown when the player runs the built-in 'help' command. + public string Documentation { get; } + + /// The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user. + public Action Callback { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The friendly name for the mod that registered the command. + /// The command name, which the user must type to trigger it. + /// The human-readable documentation shown when the player runs the built-in 'help' command. + /// The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user. + public Command(string modName, string name, string documentation, Action callback) + { + this.ModName = modName; + this.Name = name; + this.Documentation = documentation; + this.Callback = callback; + } + } +} diff --git a/src/SMAPI/Framework/CommandManager.cs b/src/SMAPI/Framework/CommandManager.cs new file mode 100644 index 00000000..79a23d03 --- /dev/null +++ b/src/SMAPI/Framework/CommandManager.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace StardewModdingAPI.Framework +{ + /// Manages console commands. + internal class CommandManager + { + /********* + ** Properties + *********/ + /// The commands registered with SMAPI. + private readonly IDictionary Commands = new Dictionary(StringComparer.InvariantCultureIgnoreCase); + + + /********* + ** Public methods + *********/ + /// Add a console command. + /// The friendly mod name for this instance. + /// The command name, which the user must type to trigger it. + /// The human-readable documentation shown when the player runs the built-in 'help' command. + /// The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user. + /// Whether to allow a null argument; this should only used for backwards compatibility. + /// The or is null or empty. + /// The is not a valid format. + /// There's already a command with that name. + public void Add(string modName, string name, string documentation, Action 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)); + } + + /// Get a command by its unique name. + /// The command name. + /// Returns the matching command, or null if not found. + public Command Get(string name) + { + name = this.GetNormalisedName(name); + this.Commands.TryGetValue(name, out Command command); + return command; + } + + /// Get all registered commands. + public IEnumerable GetAll() + { + return this.Commands + .Values + .OrderBy(p => p.Name); + } + + /// Trigger a command. + /// The raw command input. + /// Returns whether a matching command was triggered. + 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); + } + + /// Trigger a command. + /// The command name. + /// The command arguments. + /// Returns whether a matching command was triggered. + public bool Trigger(string name, string[] arguments) + { + // get normalised name + name = this.GetNormalisedName(name); + if (name == null) + return false; + + // get command + if (this.Commands.TryGetValue(name, out Command command)) + { + command.Callback.Invoke(name, arguments); + return true; + } + return false; + } + + + /********* + ** Private methods + *********/ + /// Get a normalised command name. + /// The command name. + private string GetNormalisedName(string name) + { + name = name?.Trim().ToLower(); + return !string.IsNullOrWhiteSpace(name) + ? name + : null; + } + } +} diff --git a/src/SMAPI/Framework/Content/AssetData.cs b/src/SMAPI/Framework/Content/AssetData.cs new file mode 100644 index 00000000..1ab9eebd --- /dev/null +++ b/src/SMAPI/Framework/Content/AssetData.cs @@ -0,0 +1,44 @@ +using System; + +namespace StardewModdingAPI.Framework.Content +{ + /// Base implementation for a content helper which encapsulates access and changes to content being read from a data file. + /// The interface value type. + internal class AssetData : AssetInfo, IAssetData + { + /********* + ** Accessors + *********/ + /// The content data being read. + public TValue Data { get; protected set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The content's locale code, if the content is localised. + /// The normalised asset name being read. + /// The content data being read. + /// Normalises an asset key to match the cache key. + public AssetData(string locale, string assetName, TValue data, Func getNormalisedPath) + : base(locale, assetName, data.GetType(), getNormalisedPath) + { + this.Data = data; + } + + /// 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. + /// The new content value. + /// The is null. + /// The 's type is not compatible with the loaded asset's type. + public void ReplaceWith(TValue value) + { + if (value == null) + throw new ArgumentNullException(nameof(value), "Can't set a loaded asset to a null value."); + if (!this.DataType.IsInstanceOfType(value)) + throw new InvalidCastException($"Can't replace loaded asset of type {this.GetFriendlyTypeName(this.DataType)} with value of type {this.GetFriendlyTypeName(value.GetType())}. The new type must be compatible to prevent game errors."); + + this.Data = value; + } + } +} diff --git a/src/SMAPI/Framework/Content/AssetDataForDictionary.cs b/src/SMAPI/Framework/Content/AssetDataForDictionary.cs new file mode 100644 index 00000000..e9b29b12 --- /dev/null +++ b/src/SMAPI/Framework/Content/AssetDataForDictionary.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace StardewModdingAPI.Framework.Content +{ + /// Encapsulates access and changes to dictionary content being read from a data file. + internal class AssetDataForDictionary : AssetData>, IAssetDataForDictionary + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The content's locale code, if the content is localised. + /// The normalised asset name being read. + /// The content data being read. + /// Normalises an asset key to match the cache key. + public AssetDataForDictionary(string locale, string assetName, IDictionary data, Func getNormalisedPath) + : base(locale, assetName, data, getNormalisedPath) { } + + /// Add or replace an entry in the dictionary. + /// The entry key. + /// The entry value. + public void Set(TKey key, TValue value) + { + this.Data[key] = value; + } + + /// Add or replace an entry in the dictionary. + /// The entry key. + /// A callback which accepts the current value and returns the new value. + public void Set(TKey key, Func value) + { + this.Data[key] = value(this.Data[key]); + } + + /// Dynamically replace values in the dictionary. + /// A lambda which takes the current key and value for an entry, and returns the new value. + public void Set(Func replacer) + { + foreach (var pair in this.Data.ToArray()) + this.Data[pair.Key] = replacer(pair.Key, pair.Value); + } + } +} diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs new file mode 100644 index 00000000..45c5588b --- /dev/null +++ b/src/SMAPI/Framework/Content/AssetDataForImage.cs @@ -0,0 +1,70 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace StardewModdingAPI.Framework.Content +{ + /// Encapsulates access and changes to dictionary content being read from a data file. + internal class AssetDataForImage : AssetData, IAssetDataForImage + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The content's locale code, if the content is localised. + /// The normalised asset name being read. + /// The content data being read. + /// Normalises an asset key to match the cache key. + public AssetDataForImage(string locale, string assetName, Texture2D data, Func getNormalisedPath) + : base(locale, assetName, data, getNormalisedPath) { } + + /// Overwrite part of the image. + /// The image to patch into the content. + /// The part of the to copy (or null to take the whole texture). This must be within the bounds of the texture. + /// The part of the content to patch (or null to patch the whole texture). The original content within this area will be erased. This must be within the bounds of the existing spritesheet. + /// Indicates how an image should be patched. + /// One of the arguments is null. + /// The is outside the bounds of the spritesheet. + 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/SMAPI/Framework/Content/AssetDataForObject.cs b/src/SMAPI/Framework/Content/AssetDataForObject.cs new file mode 100644 index 00000000..f30003e4 --- /dev/null +++ b/src/SMAPI/Framework/Content/AssetDataForObject.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework.Graphics; + +namespace StardewModdingAPI.Framework.Content +{ + /// Encapsulates access and changes to content being read from a data file. + internal class AssetDataForObject : AssetData, IAssetData + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The content's locale code, if the content is localised. + /// The normalised asset name being read. + /// The content data being read. + /// Normalises an asset key to match the cache key. + public AssetDataForObject(string locale, string assetName, object data, Func getNormalisedPath) + : base(locale, assetName, data, getNormalisedPath) { } + + /// Construct an instance. + /// The asset metadata. + /// The content data being read. + /// Normalises an asset key to match the cache key. + public AssetDataForObject(IAssetInfo info, object data, Func getNormalisedPath) + : this(info.Locale, info.AssetName, data, getNormalisedPath) { } + + /// Get a helper to manipulate the data as a dictionary. + /// The expected dictionary key. + /// The expected dictionary balue. + /// The content being read isn't a dictionary. + public IAssetDataForDictionary AsDictionary() + { + return new AssetDataForDictionary(this.Locale, this.AssetName, this.GetData>(), this.GetNormalisedPath); + } + + /// Get a helper to manipulate the data as an image. + /// The content being read isn't an image. + public IAssetDataForImage AsImage() + { + return new AssetDataForImage(this.Locale, this.AssetName, this.GetData(), this.GetNormalisedPath); + } + + /// Get the data as a given type. + /// The expected data type. + /// The data can't be converted to . + public TData GetData() + { + 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/SMAPI/Framework/Content/AssetInfo.cs b/src/SMAPI/Framework/Content/AssetInfo.cs new file mode 100644 index 00000000..d580dc06 --- /dev/null +++ b/src/SMAPI/Framework/Content/AssetInfo.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework.Graphics; + +namespace StardewModdingAPI.Framework.Content +{ + internal class AssetInfo : IAssetInfo + { + /********* + ** Properties + *********/ + /// Normalises an asset key to match the cache key. + protected readonly Func GetNormalisedPath; + + + /********* + ** Accessors + *********/ + /// The content's locale code, if the content is localised. + public string Locale { get; } + + /// The normalised asset name being read. The format may change between platforms; see to compare with a known path. + public string AssetName { get; } + + /// The content data type. + public Type DataType { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The content's locale code, if the content is localised. + /// The normalised asset name being read. + /// The content type being read. + /// Normalises an asset key to match the cache key. + public AssetInfo(string locale, string assetName, Type type, Func getNormalisedPath) + { + this.Locale = locale; + this.AssetName = assetName; + this.DataType = type; + this.GetNormalisedPath = getNormalisedPath; + } + + /// Get whether the asset name being loaded matches a given name after normalisation. + /// The expected asset path, relative to the game's content folder and without the .xnb extension or locale suffix (like 'Data\ObjectInformation'). + public bool AssetNameEquals(string path) + { + path = this.GetNormalisedPath(path); + return this.AssetName.Equals(path, StringComparison.InvariantCultureIgnoreCase); + } + + + /********* + ** Protected methods + *********/ + /// Get a human-readable type name. + /// The type to name. + 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/SMAPI/Framework/ContentManagerShim.cs b/src/SMAPI/Framework/ContentManagerShim.cs new file mode 100644 index 00000000..d46f23a3 --- /dev/null +++ b/src/SMAPI/Framework/ContentManagerShim.cs @@ -0,0 +1,50 @@ +using StardewValley; + +namespace StardewModdingAPI.Framework +{ + /// A minimal content manager which defers to SMAPI's main content manager. + internal class ContentManagerShim : LocalizedContentManager + { + /********* + ** Properties + *********/ + /// SMAPI's underlying content manager. + private readonly SContentManager ContentManager; + + + /********* + ** Accessors + *********/ + /// The content manager's name for logs (if any). + public string Name { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// SMAPI's underlying content manager. + /// The content manager's name for logs (if any). + public ContentManagerShim(SContentManager contentManager, string name) + : base(contentManager.ServiceProvider, contentManager.RootDirectory, contentManager.CurrentCulture, contentManager.LanguageCodeOverride) + { + this.ContentManager = contentManager; + this.Name = name; + } + + /// Load an asset that has been processed by the content pipeline. + /// The type of asset to load. + /// The asset path relative to the loader root directory, not including the .xnb extension. + public override T Load(string assetName) + { + return this.ContentManager.LoadFor(assetName, this); + } + + /// Dispose held resources. + /// Whether the content manager is disposing (rather than finalising). + protected override void Dispose(bool disposing) + { + this.ContentManager.DisposeFor(this); + } + } +} diff --git a/src/SMAPI/Framework/CursorPosition.cs b/src/SMAPI/Framework/CursorPosition.cs new file mode 100644 index 00000000..db02b3d1 --- /dev/null +++ b/src/SMAPI/Framework/CursorPosition.cs @@ -0,0 +1,35 @@ +using Microsoft.Xna.Framework; + +namespace StardewModdingAPI.Framework +{ + /// Defines a position on a given map at different reference points. + internal class CursorPosition : ICursorPosition + { + /********* + ** Accessors + *********/ + /// The pixel position relative to the top-left corner of the visible screen. + public Vector2 ScreenPixels { get; } + + /// The tile position under the cursor relative to the top-left corner of the map. + public Vector2 Tile { get; } + + /// The tile position that the game considers under the cursor for purposes of clicking actions. This may be different than if that's too far from the player. + public Vector2 GrabTile { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The pixel position relative to the top-left corner of the visible screen. + /// The tile position relative to the top-left corner of the map. + /// The tile position that the game considers under the cursor for purposes of clicking actions. + public CursorPosition(Vector2 screenPixels, Vector2 tile, Vector2 grabTile) + { + this.ScreenPixels = screenPixels; + this.Tile = tile; + this.GrabTile = grabTile; + } + } +} diff --git a/src/SMAPI/Framework/DeprecationLevel.cs b/src/SMAPI/Framework/DeprecationLevel.cs new file mode 100644 index 00000000..c0044053 --- /dev/null +++ b/src/SMAPI/Framework/DeprecationLevel.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Framework +{ + /// Indicates how deprecated something is. + internal enum DeprecationLevel + { + /// It's deprecated but won't be removed soon. Mod authors have some time to update their mods. Deprecation warnings should be logged, but not written to the console. + Notice, + + /// Mods should no longer be using it. Deprecation messages should be debug entries in the console. + Info, + + /// The code will be removed soon. Deprecation messages should be warnings in the console. + PendingRemoval + } +} \ No newline at end of file diff --git a/src/SMAPI/Framework/DeprecationManager.cs b/src/SMAPI/Framework/DeprecationManager.cs new file mode 100644 index 00000000..b07c6c7d --- /dev/null +++ b/src/SMAPI/Framework/DeprecationManager.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; + +namespace StardewModdingAPI.Framework +{ + /// Manages deprecation warnings. + internal class DeprecationManager + { + /********* + ** Properties + *********/ + /// The deprecations which have already been logged (as 'mod name::noun phrase::version'). + private readonly HashSet LoggedDeprecations = new HashSet(StringComparer.InvariantCultureIgnoreCase); + + /// Encapsulates monitoring and logging for a given module. + private readonly IMonitor Monitor; + + /// Tracks the installed mods. + private readonly ModRegistry ModRegistry; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Encapsulates monitoring and logging for a given module. + /// Tracks the installed mods. + public DeprecationManager(IMonitor monitor, ModRegistry modRegistry) + { + this.Monitor = monitor; + this.ModRegistry = modRegistry; + } + + /// Log a deprecation warning. + /// A noun phrase describing what is deprecated. + /// The SMAPI version which deprecated it. + /// How deprecated the code is. + public void Warn(string nounPhrase, string version, DeprecationLevel severity) + { + this.Warn(this.ModRegistry.GetModFromStack(), nounPhrase, version, severity); + } + + /// Log a deprecation warning. + /// The friendly mod name which used the deprecated code. + /// A noun phrase describing what is deprecated. + /// The SMAPI version which deprecated it. + /// How deprecated the code is. + public void Warn(string source, string nounPhrase, string version, DeprecationLevel severity) + { + // ignore if already warned + if (!this.MarkWarned(source ?? "", nounPhrase, version)) + return; + + // build message + string message = $"{source ?? "An unknown mod"} uses deprecated code ({nounPhrase})."; + if (source == null) + message += $"{Environment.NewLine}{Environment.StackTrace}"; + + // log message + switch (severity) + { + case DeprecationLevel.Notice: + this.Monitor.Log(message, LogLevel.Trace); + break; + + case DeprecationLevel.Info: + this.Monitor.Log(message, LogLevel.Debug); + break; + + case DeprecationLevel.PendingRemoval: + this.Monitor.Log(message, LogLevel.Warn); + break; + + default: + throw new NotSupportedException($"Unknown deprecation level '{severity}'"); + } + } + + /// Mark a deprecation warning as already logged. + /// A noun phrase describing what is deprecated (e.g. "the Extensions.AsInt32 method"). + /// The SMAPI version which deprecated it. + /// Returns whether the deprecation was successfully marked as warned. Returns false if it was already marked. + public bool MarkWarned(string nounPhrase, string version) + { + return this.MarkWarned(this.ModRegistry.GetModFromStack(), nounPhrase, version); + } + + /// Mark a deprecation warning as already logged. + /// The friendly name of the assembly which used the deprecated code. + /// A noun phrase describing what is deprecated (e.g. "the Extensions.AsInt32 method"). + /// The SMAPI version which deprecated it. + /// Returns whether the deprecation was successfully marked as warned. Returns false if it was already marked. + public bool MarkWarned(string source, string nounPhrase, string version) + { + if (string.IsNullOrWhiteSpace(source)) + throw new InvalidOperationException("The deprecation source cannot be empty."); + + string key = $"{source}::{nounPhrase}::{version}"; + if (this.LoggedDeprecations.Contains(key)) + return false; + this.LoggedDeprecations.Add(key); + return true; + } + } +} diff --git a/src/SMAPI/Framework/Exceptions/SAssemblyLoadFailedException.cs b/src/SMAPI/Framework/Exceptions/SAssemblyLoadFailedException.cs new file mode 100644 index 00000000..ec9279f1 --- /dev/null +++ b/src/SMAPI/Framework/Exceptions/SAssemblyLoadFailedException.cs @@ -0,0 +1,16 @@ +using System; + +namespace StardewModdingAPI.Framework.Exceptions +{ + /// An exception thrown when an assembly can't be loaded by SMAPI, with all the relevant details in the message. + internal class SAssemblyLoadFailedException : Exception + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The error message. + public SAssemblyLoadFailedException(string message) + : base(message) { } + } +} diff --git a/src/SMAPI/Framework/Exceptions/SContentLoadException.cs b/src/SMAPI/Framework/Exceptions/SContentLoadException.cs new file mode 100644 index 00000000..85d85e3d --- /dev/null +++ b/src/SMAPI/Framework/Exceptions/SContentLoadException.cs @@ -0,0 +1,18 @@ +using System; +using Microsoft.Xna.Framework.Content; + +namespace StardewModdingAPI.Framework.Exceptions +{ + /// An implementation of used by SMAPI to detect whether it was thrown by SMAPI or the underlying framework. + internal class SContentLoadException : ContentLoadException + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The error message. + /// The underlying exception, if any. + public SContentLoadException(string message, Exception ex = null) + : base(message, ex) { } + } +} diff --git a/src/SMAPI/Framework/Exceptions/SParseException.cs b/src/SMAPI/Framework/Exceptions/SParseException.cs new file mode 100644 index 00000000..f7133ee7 --- /dev/null +++ b/src/SMAPI/Framework/Exceptions/SParseException.cs @@ -0,0 +1,17 @@ +using System; + +namespace StardewModdingAPI.Framework.Exceptions +{ + /// A format exception which provides a user-facing error message. + internal class SParseException : FormatException + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The error message. + /// The underlying exception, if any. + public SParseException(string message, Exception ex = null) + : base(message, ex) { } + } +} diff --git a/src/SMAPI/Framework/GameVersion.cs b/src/SMAPI/Framework/GameVersion.cs new file mode 100644 index 00000000..48159f61 --- /dev/null +++ b/src/SMAPI/Framework/GameVersion.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; + +namespace StardewModdingAPI.Framework +{ + /// An implementation of that correctly handles the non-semantic versions used by older Stardew Valley releases. + internal class GameVersion : SemanticVersion + { + /********* + ** Private methods + *********/ + /// A mapping of game to semantic versions. + private static readonly IDictionary VersionMap = new Dictionary(StringComparer.InvariantCultureIgnoreCase) + { + ["1.01"] = "1.0.1", + ["1.02"] = "1.0.2", + ["1.03"] = "1.0.3", + ["1.04"] = "1.0.4", + ["1.05"] = "1.0.5", + ["1.051"] = "1.0.6-prerelease1", // not a very good mapping, but good enough for SMAPI's purposes. + ["1.051b"] = "1.0.6-prelease2", + ["1.06"] = "1.0.6", + ["1.07"] = "1.0.7", + ["1.07a"] = "1.0.8-prerelease1", + ["1.11"] = "1.1.1" + }; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The game version string. + public GameVersion(string version) + : base(GameVersion.GetSemanticVersionString(version)) { } + + /// Get a string representation of the version. + public override string ToString() + { + return GameVersion.GetGameVersionString(base.ToString()); + } + + + /********* + ** Private methods + *********/ + /// Convert a game version string to a semantic version string. + /// The game version string. + private static string GetSemanticVersionString(string gameVersion) + { + return GameVersion.VersionMap.TryGetValue(gameVersion, out string semanticVersion) + ? semanticVersion + : gameVersion; + } + + /// Convert a game version string to a semantic version string. + /// The game version string. + private static string GetGameVersionString(string gameVersion) + { + foreach (var mapping in GameVersion.VersionMap) + { + if (mapping.Value.Equals(gameVersion, StringComparison.InvariantCultureIgnoreCase)) + return mapping.Key; + } + return gameVersion; + } + } +} diff --git a/src/SMAPI/Framework/IModMetadata.cs b/src/SMAPI/Framework/IModMetadata.cs new file mode 100644 index 00000000..c21734a7 --- /dev/null +++ b/src/SMAPI/Framework/IModMetadata.cs @@ -0,0 +1,47 @@ +using StardewModdingAPI.Framework.Models; +using StardewModdingAPI.Framework.ModLoading; + +namespace StardewModdingAPI.Framework +{ + /// Metadata for a mod. + internal interface IModMetadata + { + /********* + ** Accessors + *********/ + /// The mod's display name. + string DisplayName { get; } + + /// The mod's full directory path. + string DirectoryPath { get; } + + /// The mod manifest. + IManifest Manifest { get; } + + /// >Metadata about the mod from SMAPI's internal data (if any). + ModDataRecord DataRecord { get; } + + /// The metadata resolution status. + ModMetadataStatus Status { get; } + + /// The reason the metadata is invalid, if any. + string Error { get; } + + /// The mod instance (if it was loaded). + IMod Mod { get; } + + + /********* + ** Public methods + *********/ + /// Set the mod status. + /// The metadata resolution status. + /// The reason the metadata is invalid, if any. + /// Return the instance for chaining. + IModMetadata SetStatus(ModMetadataStatus status, string error = null); + + /// Set the mod instance. + /// The mod instance to set. + IModMetadata SetMod(IMod mod); + } +} diff --git a/src/SMAPI/Framework/InternalExtensions.cs b/src/SMAPI/Framework/InternalExtensions.cs new file mode 100644 index 00000000..3709e05d --- /dev/null +++ b/src/SMAPI/Framework/InternalExtensions.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework.Reflection; +using StardewValley; + +namespace StardewModdingAPI.Framework +{ + /// Provides extension methods for SMAPI's internal use. + internal static class InternalExtensions + { + /**** + ** IMonitor + ****/ + /// Safely raise an event, and intercept any exceptions thrown by its handlers. + /// Encapsulates monitoring and logging. + /// The event name for error messages. + /// The event handlers. + /// The event sender. + /// The event arguments (or null to pass ). + public static void SafelyRaisePlainEvent(this IMonitor monitor, string name, IEnumerable handlers, object sender = null, EventArgs args = null) + { + if (handlers == null) + return; + + foreach (EventHandler handler in handlers.Cast()) + { + // handle SMAPI exiting + if (monitor.IsExiting) + { + monitor.Log($"SMAPI shutting down: aborting {name} event.", LogLevel.Warn); + return; + } + + // raise event + try + { + handler.Invoke(sender, args ?? EventArgs.Empty); + } + catch (Exception ex) + { + monitor.Log($"A mod failed handling the {name} event:\n{ex.GetLogSummary()}", LogLevel.Error); + } + } + } + + /// Safely raise an event, and intercept any exceptions thrown by its handlers. + /// The event argument object type. + /// Encapsulates monitoring and logging. + /// The event name for error messages. + /// The event handlers. + /// The event sender. + /// The event arguments. + public static void SafelyRaiseGenericEvent(this IMonitor monitor, string name, IEnumerable handlers, object sender, TEventArgs args) + { + if (handlers == null) + return; + + foreach (EventHandler handler in handlers.Cast>()) + { + try + { + handler.Invoke(sender, args); + } + catch (Exception ex) + { + monitor.Log($"A mod failed handling the {name} event:\n{ex.GetLogSummary()}", LogLevel.Error); + } + } + } + + /// Log a message for the player or developer the first time it occurs. + /// The monitor through which to log the message. + /// The hash of logged messages. + /// The message to log. + /// The log severity level. + public static void LogOnce(this IMonitor monitor, HashSet hash, string message, LogLevel level = LogLevel.Trace) + { + if (!hash.Contains(message)) + { + monitor.Log(message, level); + hash.Add(message); + } + } + + /**** + ** Exceptions + ****/ + /// Get a string representation of an exception suitable for writing to the error log. + /// The error to summarise. + public static string GetLogSummary(this Exception exception) + { + switch (exception) + { + case TypeLoadException ex: + return $"Failed loading type '{ex.TypeName}': {exception}"; + + case ReflectionTypeLoadException ex: + string summary = exception.ToString(); + foreach (Exception childEx in ex.LoaderExceptions) + summary += $"\n\n{childEx.GetLogSummary()}"; + return summary; + + default: + return exception.ToString(); + } + } + + /**** + ** Sprite batch + ****/ + /// Get whether the sprite batch is between a begin and end pair. + /// The sprite batch to check. + /// The reflection helper with which to access private fields. + public static bool IsOpen(this SpriteBatch spriteBatch, Reflector reflection) + { + // get field name + const string fieldName = +#if SMAPI_FOR_WINDOWS + "inBeginEndPair"; +#else + "_beginCalled"; +#endif + + // get result + return reflection.GetPrivateField(Game1.spriteBatch, fieldName).GetValue(); + } + } +} diff --git a/src/SMAPI/Framework/Logging/ConsoleInterceptionManager.cs b/src/SMAPI/Framework/Logging/ConsoleInterceptionManager.cs new file mode 100644 index 00000000..b8f2c34e --- /dev/null +++ b/src/SMAPI/Framework/Logging/ConsoleInterceptionManager.cs @@ -0,0 +1,86 @@ +using System; + +namespace StardewModdingAPI.Framework.Logging +{ + /// Manages console output interception. + internal class ConsoleInterceptionManager : IDisposable + { + /********* + ** Properties + *********/ + /// The intercepting console writer. + private readonly InterceptingTextWriter Output; + + + /********* + ** Accessors + *********/ + /// Whether the current console supports color formatting. + public bool SupportsColor { get; } + + /// The event raised when a message is written to the console directly. + public event Action OnMessageIntercepted; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public ConsoleInterceptionManager() + { + // redirect output through interceptor + this.Output = new InterceptingTextWriter(Console.Out); + this.Output.OnMessageIntercepted += line => this.OnMessageIntercepted?.Invoke(line); + Console.SetOut(this.Output); + + // test color support + this.SupportsColor = this.TestColorSupport(); + } + + /// Get an exclusive lock and write to the console output without interception. + /// The action to perform within the exclusive write block. + public void ExclusiveWriteWithoutInterception(Action action) + { + lock (Console.Out) + { + try + { + this.Output.ShouldIntercept = false; + action(); + } + finally + { + this.Output.ShouldIntercept = true; + } + } + } + + /// Release all resources. + public void Dispose() + { + Console.SetOut(this.Output.Out); + this.Output.Dispose(); + } + + + /********* + ** private methods + *********/ + /// Test whether the current console supports color formatting. + private bool TestColorSupport() + { + try + { + this.ExclusiveWriteWithoutInterception(() => + { + Console.ForegroundColor = Console.ForegroundColor; + }); + return true; + } + catch (Exception) + { + return false; // Mono bug + } + } + } +} diff --git a/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs b/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs new file mode 100644 index 00000000..9ca61b59 --- /dev/null +++ b/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs @@ -0,0 +1,63 @@ +using System; +using System.IO; +using System.Text; + +namespace StardewModdingAPI.Framework.Logging +{ + /// A text writer which allows intercepting output. + internal class InterceptingTextWriter : TextWriter + { + /********* + ** Accessors + *********/ + /// The underlying console output. + public TextWriter Out { get; } + + /// The character encoding in which the output is written. + public override Encoding Encoding => this.Out.Encoding; + + /// Whether to intercept console output. + public bool ShouldIntercept { get; set; } + + /// The event raised when a message is written to the console directly. + public event Action OnMessageIntercepted; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying output writer. + public InterceptingTextWriter(TextWriter output) + { + this.Out = output; + } + + /// Writes a subarray of characters to the text string or stream. + /// The character array to write data from. + /// The character position in the buffer at which to start retrieving data. + /// The number of characters to write. + public override void Write(char[] buffer, int index, int count) + { + if (this.ShouldIntercept) + this.OnMessageIntercepted?.Invoke(new string(buffer, index, count).TrimEnd('\r', '\n')); + else + this.Out.Write(buffer, index, count); + } + + /// Writes a character to the text string or stream. + /// The character to write to the text stream. + /// Console log messages from the game should be caught by . This method passes through anything that bypasses that method for some reason, since it's better to show it to users than hide it from everyone. + public override void Write(char ch) + { + this.Out.Write(ch); + } + + /// Releases the unmanaged resources used by the and optionally releases the managed resources. + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + protected override void Dispose(bool disposing) + { + this.OnMessageIntercepted = null; + } + } +} diff --git a/src/SMAPI/Framework/Logging/LogFileManager.cs b/src/SMAPI/Framework/Logging/LogFileManager.cs new file mode 100644 index 00000000..8cfe0527 --- /dev/null +++ b/src/SMAPI/Framework/Logging/LogFileManager.cs @@ -0,0 +1,57 @@ +using System; +using System.IO; + +namespace StardewModdingAPI.Framework.Logging +{ + /// Manages reading and writing to log file. + internal class LogFileManager : IDisposable + { + /********* + ** Properties + *********/ + /// The underlying stream writer. + private readonly StreamWriter Stream; + + + /********* + ** Accessors + *********/ + /// The full path to the log file being written. + public string Path { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The log file to write. + public LogFileManager(string path) + { + this.Path = path; + + // create log directory if needed + string logDir = System.IO.Path.GetDirectoryName(path); + if (logDir == null) + throw new ArgumentException($"The log path '{path}' is not valid."); + Directory.CreateDirectory(logDir); + + // open log file stream + this.Stream = new StreamWriter(path, append: false) { AutoFlush = true }; + } + + /// Write a message to the log. + /// The message to log. + public void WriteLine(string message) + { + // always use Windows-style line endings for convenience + // (Linux/Mac editors are fine with them, Windows editors often require them) + this.Stream.Write(message + "\r\n"); + } + + /// Release all resources. + public void Dispose() + { + this.Stream.Dispose(); + } + } +} diff --git a/src/SMAPI/Framework/ModHelpers/BaseHelper.cs b/src/SMAPI/Framework/ModHelpers/BaseHelper.cs new file mod