summaryrefslogtreecommitdiff
path: root/src/SMAPI/Framework
diff options
context:
space:
mode:
Diffstat (limited to 'src/SMAPI/Framework')
-rw-r--r--src/SMAPI/Framework/Content/ContentCache.cs150
-rw-r--r--src/SMAPI/Framework/GameVersion.cs3
-rw-r--r--src/SMAPI/Framework/ModHelpers/ContentHelper.cs236
-rw-r--r--src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs99
-rw-r--r--src/SMAPI/Framework/Reflection/PrivateProperty.cs30
-rw-r--r--src/SMAPI/Framework/Reflection/Reflector.cs16
-rw-r--r--src/SMAPI/Framework/SContentManager.cs618
-rw-r--r--src/SMAPI/Framework/SGame.cs20
-rw-r--r--src/SMAPI/Framework/Serialisation/JsonHelper.cs7
-rw-r--r--src/SMAPI/Framework/Serialisation/SelectiveStringEnumConverter.cs37
-rw-r--r--src/SMAPI/Framework/Serialisation/StringEnumConverter.cs22
11 files changed, 788 insertions, 450 deletions
diff --git a/src/SMAPI/Framework/Content/ContentCache.cs b/src/SMAPI/Framework/Content/ContentCache.cs
new file mode 100644
index 00000000..10c41d08
--- /dev/null
+++ b/src/SMAPI/Framework/Content/ContentCache.cs
@@ -0,0 +1,150 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.Contracts;
+using System.Linq;
+using Microsoft.Xna.Framework;
+using StardewModdingAPI.Framework.ModLoading;
+using StardewModdingAPI.Framework.Reflection;
+using StardewValley;
+
+namespace StardewModdingAPI.Framework.Content
+{
+ /// <summary>A low-level wrapper around the content cache which handles reading, writing, and invalidating entries in the cache. This doesn't handle any higher-level logic like localisation, loading content, etc. It assumes all keys passed in are already normalised.</summary>
+ internal class ContentCache
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The underlying asset cache.</summary>
+ private readonly IDictionary<string, object> Cache;
+
+ /// <summary>The possible directory separator characters in an asset key.</summary>
+ private readonly char[] PossiblePathSeparators;
+
+ /// <summary>The preferred directory separator chaeacter in an asset key.</summary>
+ private readonly string PreferredPathSeparator;
+
+ /// <summary>Applies platform-specific asset key normalisation so it's consistent with the underlying cache.</summary>
+ private readonly Func<string, string> NormaliseAssetNameForPlatform;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Get or set the value of a raw cache entry.</summary>
+ /// <param name="key">The cache key.</param>
+ public object this[string key]
+ {
+ get => this.Cache[key];
+ set => this.Cache[key] = value;
+ }
+
+ /// <summary>The current cache keys.</summary>
+ public IEnumerable<string> Keys => this.Cache.Keys;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /****
+ ** Constructor
+ ****/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="contentManager">The underlying content manager whose cache to manage.</param>
+ /// <param name="reflection">Simplifies access to private game code.</param>
+ /// <param name="possiblePathSeparators">The possible directory separator characters in an asset key.</param>
+ /// <param name="preferredPathSeparator">The preferred directory separator chaeacter in an asset key.</param>
+ public ContentCache(LocalizedContentManager contentManager, Reflector reflection, char[] possiblePathSeparators, string preferredPathSeparator)
+ {
+ // init
+ this.Cache = reflection.GetPrivateField<Dictionary<string, object>>(contentManager, "loadedAssets").GetValue();
+ this.PossiblePathSeparators = possiblePathSeparators;
+ this.PreferredPathSeparator = preferredPathSeparator;
+
+ // get 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
+ }
+
+ /****
+ ** Fetch
+ ****/
+ /// <summary>Get whether the cache contains a given key.</summary>
+ /// <param name="key">The cache key.</param>
+ public bool ContainsKey(string key)
+ {
+ return this.Cache.ContainsKey(key);
+ }
+
+
+ /****
+ ** Normalise
+ ****/
+ /// <summary>Normalise path separators in a file path. For asset keys, see <see cref="NormaliseKey"/> instead.</summary>
+ /// <param name="path">The file path to normalise.</param>
+ [Pure]
+ public string NormalisePathSeparators(string path)
+ {
+ string[] parts = path.Split(this.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries);
+ string normalised = string.Join(this.PreferredPathSeparator, parts);
+ if (path.StartsWith(this.PreferredPathSeparator))
+ normalised = this.PreferredPathSeparator + normalised; // keep root slash
+ return normalised;
+ }
+
+ /// <summary>Normalise a cache key so it's consistent with the underlying cache.</summary>
+ /// <param name="key">The asset key.</param>
+ [Pure]
+ public string NormaliseKey(string key)
+ {
+ key = this.NormalisePathSeparators(key);
+ return key.EndsWith(".xnb", StringComparison.InvariantCultureIgnoreCase)
+ ? key.Substring(0, key.Length - 4)
+ : this.NormaliseAssetNameForPlatform(key);
+ }
+
+ /****
+ ** Remove
+ ****/
+ /// <summary>Remove an asset with the given key.</summary>
+ /// <param name="key">The cache key.</param>
+ /// <param name="dispose">Whether to dispose the entry value, if applicable.</param>
+ /// <returns>Returns the removed key (if any).</returns>
+ public bool Remove(string key, bool dispose)
+ {
+ // get entry
+ if (!this.Cache.TryGetValue(key, out object value))
+ return false;
+
+ // dispose & remove entry
+ if (dispose && value is IDisposable disposable)
+ disposable.Dispose();
+
+ return this.Cache.Remove(key);
+ }
+
+ /// <summary>Purge matched assets from the cache.</summary>
+ /// <param name="predicate">Matches the asset keys to invalidate.</param>
+ /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
+ /// <returns>Returns the removed keys (if any).</returns>
+ public IEnumerable<string> Remove(Func<string, Type, bool> predicate, bool dispose = false)
+ {
+ List<string> removed = new List<string>();
+ foreach (string key in this.Cache.Keys.ToArray())
+ {
+ Type type = this.Cache[key].GetType();
+ if (predicate(key, type))
+ {
+ this.Remove(key, dispose);
+ removed.Add(key);
+ }
+ }
+ return removed;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/GameVersion.cs b/src/SMAPI/Framework/GameVersion.cs
index 48159f61..1884afe9 100644
--- a/src/SMAPI/Framework/GameVersion.cs
+++ b/src/SMAPI/Framework/GameVersion.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
namespace StardewModdingAPI.Framework
@@ -22,6 +22,7 @@ namespace StardewModdingAPI.Framework
["1.06"] = "1.0.6",
["1.07"] = "1.0.7",
["1.07a"] = "1.0.8-prerelease1",
+ ["1.08"] = "1.0.8",
["1.11"] = "1.1.1"
};
diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs
index 4f5bd2f0..be9594ee 100644
--- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs
@@ -4,7 +4,6 @@ using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
-using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;
using StardewModdingAPI.Framework.Exceptions;
@@ -74,12 +73,12 @@ namespace StardewModdingAPI.Framework.ModHelpers
this.ContentManager = contentManager;
this.ModFolderPath = modFolderPath;
this.ModName = modName;
- this.ModFolderPathFromContent = this.GetRelativePath(contentManager.FullRootDirectory, modFolderPath);
+ this.ModFolderPathFromContent = this.ContentManager.GetRelativePath(modFolderPath);
this.Monitor = monitor;
}
/// <summary>Load content from the game folder or mod folder (if not already cached), and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary>
- /// <typeparam name="T">The expected data type. The main supported types are <see cref="Texture2D"/> and dictionaries; other types may be supported by the game's content pipeline.</typeparam>
+ /// <typeparam name="T">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, and dictionaries; other types may be supported by the game's content pipeline.</typeparam>
/// <param name="key">The asset key to fetch (if the <paramref name="source"/> is <see cref="ContentSource.GameContent"/>), or the local path to a content file relative to the mod folder.</param>
/// <param name="source">Where to search for a matching content asset.</param>
/// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception>
@@ -88,9 +87,9 @@ namespace StardewModdingAPI.Framework.ModHelpers
{
SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}: {reasonPhrase}.");
- this.AssertValidAssetKeyFormat(key);
try
{
+ this.AssertValidAssetKeyFormat(key);
switch (source)
{
case ContentSource.GameContent:
@@ -103,60 +102,32 @@ namespace StardewModdingAPI.Framework.ModHelpers
throw GetContentError($"there's no matching file at path '{file.FullName}'.");
// get asset path
- string assetPath = this.GetModAssetPath(key, file.FullName);
+ string assetName = this.GetModAssetPath(key, file.FullName);
// try cache
- if (this.ContentManager.IsLoaded(assetPath))
- return this.ContentManager.Load<T>(assetPath);
+ if (this.ContentManager.IsLoaded(assetName))
+ return this.ContentManager.Load<T>(assetName);
- // load content
- switch (file.Extension.ToLower())
+ // fix map tilesheets
+ if (file.Extension.ToLower() == ".tbin")
{
- // XNB file
- case ".xnb":
- {
- T asset = this.ContentManager.Load<T>(assetPath);
- if (asset is Map)
- this.FixLocalMapTilesheets(asset as Map, key);
- return asset;
- }
-
- // unpacked map
- case ".tbin":
- {
- // validate
- if (typeof(T) != typeof(Map))
- throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'.");
-
- // fetch & cache
- FormatManager formatManager = FormatManager.Instance;
- Map map = formatManager.LoadMap(file.FullName);
- this.FixLocalMapTilesheets(map, key);
-
- // inject map
- this.ContentManager.Inject(assetPath, map);
- return (T)(object)map;
- }
-
- // unpacked image
- case ".png":
- // validate
- if (typeof(T) != typeof(Texture2D))
- throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'.");
-
- // fetch & cache
- using (FileStream stream = File.OpenRead(file.FullName))
- {
- Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream);
- texture = this.PremultiplyTransparency(texture);
- this.ContentManager.Inject(assetPath, texture);
- return (T)(object)texture;
- }
-
- default:
- throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.png', '.tbin', or '.xnb'.");
+ // validate
+ if (typeof(T) != typeof(Map))
+ throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'.");
+
+ // fetch & cache
+ FormatManager formatManager = FormatManager.Instance;
+ Map map = formatManager.LoadMap(file.FullName);
+ this.FixCustomTilesheetPaths(map, key);
+
+ // inject map
+ this.ContentManager.Inject(assetName, map, this.ContentManager);
+ return (T)(object)map;
}
+ // load through content manager
+ return this.ContentManager.Load<T>(assetName);
+
default:
throw GetContentError($"unknown content source '{source}'.");
}
@@ -193,9 +164,9 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <returns>Returns whether the given asset key was cached.</returns>
public bool InvalidateCache(string key)
{
- this.Monitor.Log($"Requested cache invalidation for '{key}'.", LogLevel.Trace);
string actualKey = this.GetActualAssetKey(key, ContentSource.GameContent);
- return this.ContentManager.InvalidateCache((otherKey, type) => otherKey.Equals(actualKey, StringComparison.InvariantCultureIgnoreCase));
+ this.Monitor.Log($"Requested cache invalidation for '{actualKey}'.", LogLevel.Trace);
+ return this.ContentManager.InvalidateCache(asset => asset.AssetNameEquals(actualKey));
}
/// <summary>Remove all assets of the given type from the cache so they're reloaded on the next request. <b>This can be a very expensive operation and should only be used in very specific cases.</b> This will reload core game assets if needed, but references to the former assets will still show the previous content.</summary>
@@ -207,28 +178,50 @@ namespace StardewModdingAPI.Framework.ModHelpers
return this.ContentManager.InvalidateCache((key, type) => typeof(T).IsAssignableFrom(type));
}
+ /// <summary>Remove matching assets from the content cache so they're reloaded on the next request. This will reload core game assets if needed, but references to the former asset will still show the previous content.</summary>
+ /// <param name="predicate">A predicate matching the assets to invalidate.</param>
+ /// <returns>Returns whether any cache entries were invalidated.</returns>
+ public bool InvalidateCache(Func<IAssetInfo, bool> predicate)
+ {
+ this.Monitor.Log("Requested cache invalidation for all assets matching a predicate.", LogLevel.Trace);
+ return this.ContentManager.InvalidateCache(predicate);
+ }
+
/*********
** Private methods
*********/
- /// <summary>Fix the tilesheets for a map loaded from the mod folder.</summary>
+ /// <summary>Assert that the given key has a valid format.</summary>
+ /// <param name="key">The asset key to check.</param>
+ /// <exception cref="ArgumentException">The asset key is empty or contains invalid characters.</exception>
+ [SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")]
+ private void AssertValidAssetKeyFormat(string key)
+ {
+ this.ContentManager.AssertValidAssetKeyFormat(key);
+ if (Path.IsPathRooted(key))
+ throw new ArgumentException("The asset key must not be an absolute path.");
+ }
+
+ /// <summary>Fix custom map tilesheet paths so they can be found by the content manager.</summary>
/// <param name="map">The map whose tilesheets to fix.</param>
/// <param name="mapKey">The map asset key within the mod folder.</param>
- /// <exception cref="ContentLoadException">The map tilesheets could not be loaded.</exception>
+ /// <exception cref="ContentLoadException">A map tilesheet couldn't be resolved.</exception>
/// <remarks>
- /// The game's logic for tilesheets in <see cref="Game1.setGraphicsForSeason"/> is a bit specialised. It boils down to this:
- /// * If the location is indoors or the desert, or the image source contains 'path' or 'object', it's loaded as-is relative to the <c>Content</c> folder.
+ /// The game's logic for tilesheets in <see cref="Game1.setGraphicsForSeason"/> is a bit specialised. It boils
+ /// down to this:
+ /// * If the location is indoors or the desert, or the image source contains 'path' or 'object', it's loaded
+ /// as-is relative to the <c>Content</c> folder.
/// * Else it's loaded from <c>Content\Maps</c> with a seasonal prefix.
///
/// That logic doesn't work well in our case, mainly because we have no location metadata at this point.
/// Instead we use a more heuristic approach: check relative to the map file first, then relative to
- /// <c>Content\Maps</c>, then <c>Content</c>. If the image source filename contains a seasonal prefix, we try
- /// for a seasonal variation and then an exact match.
+ /// <c>Content\Maps</c>, then <c>Content</c>. If the image source filename contains a seasonal prefix, try for a
+ /// seasonal variation and then an exact match.
///
/// While that doesn't exactly match the game logic, it's close enough that it's unlikely to make a difference.
/// </remarks>
- private void FixLocalMapTilesheets(Map map, string mapKey)
+ private void FixCustomTilesheetPaths(Map map, string mapKey)
{
- // check map info
+ // get map info
if (!map.TileSheets.Any())
return;
mapKey = this.ContentManager.NormaliseAssetName(mapKey); // Mono's Path.GetDirectoryName doesn't handle Windows dir separators
@@ -239,7 +232,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
{
string imageSource = tilesheet.ImageSource;
- // validate
+ // validate tilesheet path
if (Path.IsPathRooted(imageSource) || imageSource.Split(SContentManager.PossiblePathSeparators).Contains(".."))
throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded. Tilesheet paths must be a relative path without directory climbing (../).");
@@ -264,8 +257,8 @@ namespace StardewModdingAPI.Framework.ModHelpers
try
{
string key =
- this.TryLoadTilesheetImageSource(relativeMapFolder, seasonalImageSource)
- ?? this.TryLoadTilesheetImageSource(relativeMapFolder, imageSource);
+ this.GetTilesheetAssetName(relativeMapFolder, seasonalImageSource)
+ ?? this.GetTilesheetAssetName(relativeMapFolder, imageSource);
if (key != null)
{
tilesheet.ImageSource = key;
@@ -282,33 +275,22 @@ namespace StardewModdingAPI.Framework.ModHelpers
}
}
- /// <summary>Load a tilesheet image source if the file exists.</summary>
- /// <param name="relativeMapFolder">The folder path containing the map, relative to the mod folder.</param>
+ /// <summary>Get the actual asset name for a tilesheet.</summary>
+ /// <param name="modRelativeMapFolder">The folder path containing the map, relative to the mod folder.</param>
/// <param name="imageSource">The tilesheet image source to load.</param>
- /// <returns>Returns the loaded asset key (if it was loaded successfully).</returns>
- /// <remarks>See remarks on <see cref="FixLocalMapTilesheets"/>.</remarks>
- private string TryLoadTilesheetImageSource(string relativeMapFolder, string imageSource)
+ /// <returns>Returns the asset name.</returns>
+ /// <remarks>See remarks on <see cref="FixCustomTilesheetPaths"/>.</remarks>
+ private string GetTilesheetAssetName(string modRelativeMapFolder, string imageSource)
{
if (imageSource == null)
return null;
// check relative to map file
{
- string localKey = Path.Combine(relativeMapFolder, imageSource);
+ string localKey = Path.Combine(modRelativeMapFolder, imageSource);
FileInfo localFile = this.GetModFile(localKey);
if (localFile.Exists)
- {
- try
- {
- this.Load<Texture2D>(localKey);
- }
- catch (Exception ex)
- {
- throw new ContentLoadException($"The local '{imageSource}' tilesheet couldn't be loaded.", ex);
- }
-
return this.GetActualAssetKey(localKey);
- }
}
// check relative to content folder
@@ -327,7 +309,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
catch
{
// ignore file-not-found errors
- // TODO: while it's useful to suppress a asset-not-found error here to avoid
+ // TODO: while it's useful to suppress an asset-not-found error here to avoid
// confusion, this is a pretty naive approach. Even if the file doesn't exist,
// the file may have been loaded through an IAssetLoader which failed. So even
// if the content file doesn't exist, that doesn't mean the error here is a
@@ -343,18 +325,6 @@ namespace StardewModdingAPI.Framework.ModHelpers
return null;
}
- /// <summary>Assert that the given key has a valid format.</summary>
- /// <param name="key">The asset key to check.</param>
- /// <exception cref="ArgumentException">The asset key is empty or contains invalid characters.</exception>
- [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "Parameter is only used for assertion checks by design.")]
- private void AssertValidAssetKeyFormat(string key)
- {
- if (string.IsNullOrWhiteSpace(key))
- throw new ArgumentException("The asset key or local path is empty.");
- if (key.Intersect(Path.GetInvalidPathChars()).Any())
- throw new ArgumentException("The asset key or local path contains invalid characters.");
- }
-
/// <summary>Get a file from the mod folder.</summary>
/// <param name="path">The asset path relative to the mod folder.</param>
private FileInfo GetModFile(string path)
@@ -400,81 +370,5 @@ namespace StardewModdingAPI.Framework.ModHelpers
return absolutePath;
#endif
}
-
- /// <summary>Get a directory path relative to a given root.</summary>
- /// <param name="rootPath">The root path from which the path should be relative.</param>
- /// <param name="targetPath">The target file path.</param>
- private string GetRelativePath(string rootPath, string targetPath)
- {
- // convert to URIs
- Uri from = new Uri(rootPath + "/");
- Uri to = new Uri(targetPath + "/");
- if (from.Scheme != to.Scheme)
- throw new InvalidOperationException($"Can't get path for '{targetPath}' relative to '{rootPath}'.");
-
- // get relative path
- return Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString())
- .Replace(Path.DirectorySeparatorChar == '/' ? '\\' : '/', Path.DirectorySeparatorChar); // use correct separator for platform
- }
-
- /// <summary>Premultiply a texture's alpha values to avoid transparency issues in the game. This is only possible if the game isn't currently drawing.</summary>
- /// <param name="texture">The texture to premultiply.</param>
- /// <returns>Returns a premultiplied texture.</returns>
- /// <remarks>Based on <a href="https://gist.github.com/Layoric/6255384">code by Layoric</a>.</remarks>
- private Texture2D PremultiplyTransparency(Texture2D texture)
- {
- // validate
- if (Context.IsInDrawLoop)
- throw new NotSupportedException("Can't load a PNG file while the game is drawing to the screen. Make sure you load content outside the draw loop.");
-
- // process texture
- SpriteBatch spriteBatch = Game1.spriteBatch;
- GraphicsDevice gpu = Game1.graphics.GraphicsDevice;
- using (RenderTarget2D renderTarget = new RenderTarget2D(Game1.graphics.GraphicsDevice, texture.Width, texture.Height))
- {
- // create blank render target to premultiply
- gpu.SetRenderTarget(renderTarget);
- gpu.Clear(Color.Black);
-
- // multiply each color by the source alpha, and write just the color values into the final texture
- spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState
- {
- ColorDestinationBlend = Blend.Zero,
- ColorWriteChannels = ColorWriteChannels.Red | ColorWriteChannels.Green | ColorWriteChannels.Blue,
- AlphaDestinationBlend = Blend.Zero,
- AlphaSourceBlend = Blend.SourceAlpha,
- ColorSourceBlend = Blend.SourceAlpha
- });
- spriteBatch.Draw(texture, texture.Bounds, Color.White);
- spriteBatch.End();
-
- // copy the alpha values from the source texture into the final one without multiplying them
- spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState
- {
- ColorWriteChannels = ColorWriteChannels.Alpha,
- AlphaDestinationBlend = Blend.Zero,
- ColorDestinationBlend = Blend.Zero,
- AlphaSourceBlend = Blend.One,
- ColorSourceBlend = Blend.One
- });
- spriteBatch.Draw(texture, texture.Bounds, Color.White);
- spriteBatch.End();
-
- // release GPU
- gpu.SetRenderTarget(null);
-
- // extract premultiplied data
- Color[] data = new Color[texture.Width * texture.Height];
- renderTarget.GetData(data);
-
- // unset texture from GPU to regain control
- gpu.Textures[0] = null;
-
- // update texture with premultiplied data
- texture.SetData(data);
- }
-
- return texture;
- }
}
}
diff --git a/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs b/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs
index 8d435416..8788b142 100644
--- a/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs
@@ -1,4 +1,5 @@
using System;
+using System.Reflection;
using StardewModdingAPI.Framework.Reflection;
namespace StardewModdingAPI.Framework.ModHelpers
@@ -42,8 +43,9 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <returns>Returns the field wrapper, or <c>null</c> if the field doesn't exist and <paramref name="required"/> is <c>false</c>.</returns>
public IPrivateField<TValue> GetPrivateField<TValue>(object obj, string name, bool required = true)
{
- this.AssertAccessAllowed(obj);
- return this.Reflector.GetPrivateField<TValue>(obj, name, required);
+ return this.AssertAccessAllowed(
+ this.Reflector.GetPrivateField<TValue>(obj, name, required)
+ );
}
/// <summary>Get a private static field.</summary>
@@ -53,8 +55,9 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <param name="required">Whether to throw an exception if the private field is not found.</param>
public IPrivateField<TValue> GetPrivateField<TValue>(Type type, string name, bool required = true)
{
- this.AssertAccessAllowed(type);
- return this.Reflector.GetPrivateField<TValue>(type, name, required);
+ return this.AssertAccessAllowed(
+ this.Reflector.GetPrivateField<TValue>(type, name, required)
+ );
}
/****
@@ -67,8 +70,9 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <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)
{
- this.AssertAccessAllowed(obj);
- return this.Reflector.GetPrivateProperty<TValue>(obj, name, required);
+ return this.AssertAccessAllowed(
+ this.Reflector.GetPrivateProperty<TValue>(obj, name, required)
+ );
}
/// <summary>Get a private static property.</summary>
@@ -78,8 +82,9 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <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)
{
- this.AssertAccessAllowed(type);
- return this.Reflector.GetPrivateProperty<TValue>(type, name, required);
+ return this.AssertAccessAllowed(
+ this.Reflector.GetPrivateProperty<TValue>(type, name, required)
+ );
}
/****
@@ -98,7 +103,6 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// </remarks>
public TValue GetPrivateValue<TValue>(object obj, string name, bool required = true)
{
- this.AssertAccessAllowed(obj);
IPrivateField<TValue> field = this.GetPrivateField<TValue>(obj, name, required);
return field != null
? field.GetValue()
@@ -117,7 +121,6 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// </remarks>
public TValue GetPrivateValue<TValue>(Type type, string name, bool required = true)
{
- this.AssertAccessAllowed(type);
IPrivateField<TValue> field = this.GetPrivateField<TValue>(type, name, required);
return field != null
? field.GetValue()
@@ -133,8 +136,9 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <param name="required">Whether to throw an exception if the private field is not found.</param>
public IPrivateMethod GetPrivateMethod(object obj, string name, bool required = true)
{
- this.AssertAccessAllowed(obj);
- return this.Reflector.GetPrivateMethod(obj, name, required);
+ return this.AssertAccessAllowed(
+ this.Reflector.GetPrivateMethod(obj, name, required)
+ );
}
/// <summary>Get a private static method.</summary>
@@ -143,8 +147,9 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <param name="required">Whether to throw an exception if the private field is not found.</param>
public IPrivateMethod GetPrivateMethod(Type type, string name, bool required = true)
{
- this.AssertAccessAllowed(type);
- return this.Reflector.GetPrivateMethod(type, name, required);
+ return this.AssertAccessAllowed(
+ this.Reflector.GetPrivateMethod(type, name, required)
+ );
}
/****
@@ -157,8 +162,9 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <param name="required">Whether to throw an exception if the private field is not found.</param>
public IPrivateMethod GetPrivateMethod(object obj, string name, Type[] argumentTypes, bool required = true)
{
- this.AssertAccessAllowed(obj);
- return this.Reflector.GetPrivateMethod(obj, name, argumentTypes, required);
+ return this.AssertAccessAllowed(
+ this.Reflector.GetPrivateMethod(obj, name, argumentTypes, required)
+ );
}
/// <summary>Get a private static method.</summary>
@@ -168,33 +174,60 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <param name="required">Whether to throw an exception if the private field is not found.</param>
public IPrivateMethod GetPrivateMethod(Type type, string name, Type[] argumentTypes, bool required = true)
{
- this.AssertAccessAllowed(type);
- return this.Reflector.GetPrivateMethod(type, name, argumentTypes, required);
+ return this.AssertAccessAllowed(
+ this.Reflector.GetPrivateMethod(type, name, argumentTypes, required)
+ );
}
/*********
** Private methods
*********/
- /// <summary>Assert that mods can use the reflection helper to access the given type.</summary>
- /// <param name="type">The type being accessed.</param>
- private void AssertAccessAllowed(Type type)
+ /// <summary>Assert that mods can use the reflection helper to access the given member.</summary>
+ /// <typeparam name="T">The field value type.</typeparam>
+ /// <param name="field">The field being accessed.</param>
+ /// <returns>Returns the same field instance for convenience.</returns>
+ private IPrivateField<T> AssertAccessAllowed<T>(IPrivateField<T> field)
{
- // validate type namespace
- if (type.Namespace != null)
- {
- string rootSmapiNamespace = typeof(Program).Namespace;
- if (type.Namespace == rootSmapiNamespace || type.Namespace.StartsWith(rootSmapiNamespace + "."))
- throw new InvalidOperationException($"SMAPI blocked access by {this.ModName} to its internals through the reflection API. Accessing the SMAPI internals is strongly discouraged since they're subject to change, which means the mod can break without warning.");
- }
+ this.AssertAccessAllowed(field?.FieldInfo);
+ return field;
}
- /// <summary>Assert that mods can use the reflection helper to access the given type.</summary>
- /// <param name="obj">The object being accessed.</param>
- private void AssertAccessAllowed(object obj)
+ /// <summary>Assert that mods can use the reflection helper to access the given member.</summary>
+ /// <typeparam name="T">The property value type.</typeparam>
+ /// <param name="property">The property being accessed.</param>
+ /// <returns>Returns the same property instance for convenience.</returns>
+ private IPrivateProperty<T> AssertAccessAllowed<T>(IPrivateProperty<T> property)
{
- if (obj != null)
- this.AssertAccessAllowed(obj.GetType());
+ this.AssertAccessAllowed(property?.PropertyInfo);
+ return property;
+ }
+
+ /// <summary>Assert that mods can use the reflection helper to access the given member.</summary>
+ /// <param name="method">The method being accessed.</param>
+ /// <returns>Returns the same method instance for convenience.</returns>
+ private IPrivateMethod AssertAccessAllowed(IPrivateMethod method)
+ {
+ this.AssertAccessAllowed(method?.MethodInfo);
+ return method;
+ }
+
+ /// <summary>Assert that mods can use the reflection helper to access the given member.</summary>
+ /// <param name="member">The member being accessed.</param>
+ private void AssertAccessAllowed(MemberInfo member)
+ {
+ if (member == null)
+ return;
+
+ // get type which defines the member
+ Type declaringType = member.DeclaringType;
+ if (declaringType == null)
+ throw new InvalidOperationException($"Can't validate access to {member.MemberType} {member.Name} because it has no declaring type."); // should never happen
+
+ // validate access
+ string rootNamespace = typeof(Program).Namespace;
+ if (declaringType.Namespace == rootNamespace || declaringType.Namespace?.StartsWith(rootNamespace + ".") == true)
+ throw new InvalidOperationException($"SMAPI blocked access by {this.ModName} to its internals through the reflection API. Accessing the SMAPI internals is strongly discouraged since they're subject to change, which means the mod can break without warning. (Detected access to {declaringType.FullName}.{member.Name}.)");
}
}
}
diff --git a/src/SMAPI/Framework/Reflection/PrivateProperty.cs b/src/SMAPI/Framework/Reflection/PrivateProperty.cs
index 08204b7e..be346d71 100644
--- a/src/SMAPI/Framework/Reflection/PrivateProperty.cs
+++ b/src/SMAPI/Framework/Reflection/PrivateProperty.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Reflection;
namespace StardewModdingAPI.Framework.Reflection
@@ -10,14 +10,14 @@ namespace StardewModdingAPI.Framework.Reflection
/*********
** Properties
*********/
- /// <summary>The type that has the field.</summary>
- private readonly Type ParentType;
+ /// <summary>The display name shown in error messages.</summary>
+ private readonly string DisplayName;
- /// <summary>The object that has the instance field (if applicable).</summary>
- private readonly object Parent;
+ /// <summary>The underlying property getter.</summary>
+ private readonly Func<TValue> GetterDelegate;
- /// <summary>The display name shown in error messages.</summary>
- private string DisplayName => $"{this.ParentType.FullName}::{this.PropertyInfo.Name}";
+ /// <summary>The underlying property setter.</summary>
+ private readonly Action<TValue> SetterDelegate;
/*********
@@ -39,20 +39,24 @@ namespace StardewModdingAPI.Framework.Reflection
/// <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
+ // validate input
if (parentType == null)
throw new ArgumentNullException(nameof(parentType));
if (property == null)
throw new ArgumentNullException(nameof(property));
+
+ // validate static
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.DisplayName = $"{parentType.FullName}::{property.Name}";
this.PropertyInfo = property;
+
+ this.GetterDelegate = (Func<TValue>)Delegate.CreateDelegate(typeof(Func<TValue>), obj, this.PropertyInfo.GetMethod);
+ this.SetterDelegate = (Action<TValue>)Delegate.CreateDelegate(typeof(Action<TValue>), obj, this.PropertyInfo.SetMethod);
}
/// <summary>Get the property value.</summary>
@@ -60,7 +64,7 @@ namespace StardewModdingAPI.Framework.Reflection
{
try
{
- return (TValue)this.PropertyInfo.GetValue(this.Parent);
+ return this.GetterDelegate();
}
catch (InvalidCastException)
{
@@ -78,7 +82,7 @@ namespace StardewModdingAPI.Framework.Reflection
{
try
{
- this.PropertyInfo.SetValue(this.Parent, value);
+ this.SetterDelegate(value);
}
catch (InvalidCastException)
{
diff --git a/src/SMAPI/Framework/Reflection/Reflector.cs b/src/SMAPI/Framework/Reflection/Reflector.cs
index 5c2d90fa..23a48505 100644
--- a/src/SMAPI/Framework/Reflection/Reflector.cs
+++ b/src/SMAPI/Framework/Reflection/Reflector.cs
@@ -38,7 +38,7 @@ namespace StardewModdingAPI.Framework.Reflection
throw new ArgumentNullException(nameof(obj), "Can't get a private instance field from a null object.");
// get field from hierarchy
- IPrivateField<TValue> field = this.GetFieldFromHierarchy<TValue>(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic);
+ IPrivateField<TValue> field = this.GetFieldFromHierarchy<TValue>(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
if (required && field == null)
throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a private '{name}' instance field.");
return field;
@@ -52,7 +52,7 @@ namespace StardewModdingAPI.Framework.Reflection
public IPrivateField<TValue> GetPrivateField<TValue>(Type type, string name, bool required = true)
{
// get field from hierarchy
- IPrivateField<TValue> field = this.GetFieldFromHierarchy<TValue>(type, null, name, BindingFlags.NonPublic | BindingFlags.Static);
+ IPrivateField<TValue> field = this.GetFieldFromHierarchy<TValue>(type, null, name, BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public);
if (required && field == null)
throw new InvalidOperationException($"The {type.FullName} object doesn't have a private '{name}' static field.");
return field;
@@ -73,7 +73,7 @@ namespace StardewModdingAPI.Framework.Reflection
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);
+ IPrivateProperty<TValue> property = this.GetPropertyFromHierarchy<TValue>(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
if (required && property == null)
throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a private '{name}' instance property.");
return property;
@@ -87,7 +87,7 @@ namespace StardewModdingAPI.Framework.Reflection
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);
+ IPrivateProperty<TValue> property = this.GetPropertyFromHierarchy<TValue>(type, null, name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static);
if (required && property == null)
throw new InvalidOperationException($"The {type.FullName} object doesn't have a private '{name}' static property.");
return property;
@@ -107,7 +107,7 @@ namespace StardewModdingAPI.Framework.Reflection
throw new ArgumentNullException(nameof(obj), "Can't get a private instance method from a null object.");
// get method from hierarchy
- IPrivateMethod method = this.GetMethodFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic);
+ IPrivateMethod method = this.GetMethodFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
if (required && method == null)
throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a private '{name}' instance method.");
return method;
@@ -120,7 +120,7 @@ namespace StardewModdingAPI.Framework.Reflection
public IPrivateMethod GetPrivateMethod(Type type, string name, bool required = true)
{
// get method from hierarchy
- IPrivateMethod method = this.GetMethodFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Static);
+ IPrivateMethod method = this.GetMethodFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static);
if (required && method == null)
throw new InvalidOperationException($"The {type.FullName} object doesn't have a private '{name}' static method.");
return method;
@@ -141,7 +141,7 @@ namespace StardewModdingAPI.Framework.Reflection
throw new ArgumentNullException(nameof(obj), "Can't get a private instance method from a null object.");
// get method from hierarchy
- PrivateMethod method = this.GetMethodFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic, argumentTypes);
+ PrivateMethod method = this.GetMethodFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, argumentTypes);
if (required && method == null)
throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a private '{name}' instance method with that signature.");
return method;
@@ -155,7 +155,7 @@ namespace StardewModdingAPI.Framework.Reflection
public IPrivateMethod GetPrivateMethod(Type type, string name, Type[] argumentTypes, bool required = true)
{
// get field from hierarchy
- PrivateMethod method = this.GetMethodFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Static, argumentTypes);
+ PrivateMethod method = this.GetMethodFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, argumentTypes);
if (required && method == null)
throw new InvalidOperationException($"The {type.FullName} object doesn't have a private '{name}' static method with that signature.");
return method;
diff --git a/src/SMAPI/Framework/SContentManager.cs b/src/SMAPI/Framework/SContentManager.cs
index db202567..a755a6df 100644
--- a/src/SMAPI/Framework/SContentManager.cs
+++ b/src/SMAPI/Framework/SContentManager.cs
@@ -1,13 +1,17 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Diagnostics.Contracts;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
+using System.Threading;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Content;
+using Microsoft.Xna.Framework.Graphics;
using StardewModdingAPI.Framework.Content;
-using StardewModdingAPI.Framework.ModLoading;
+using StardewModdingAPI.Framework.Exceptions;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.Utilities;
using StardewModdingAPI.Metadata;
@@ -15,7 +19,17 @@ using StardewValley;
namespace StardewModdingAPI.Framework
{
- /// <summary>SMAPI's implementation of the game's content manager which lets it raise content events.</summary>
+ /// <summary>A thread-safe content manager which intercepts assets being loaded to let SMAPI mods inject or edit them.</summary>
+ /// <remarks>
+ /// This is the centralised content manager which manages all game assets. The game and mods don't use this class
+ /// directly; instead they use one of several <see cref="ContentManagerShim"/> instances, which proxy requests to
+ /// this class. That ensures that when the game disposes one content manager, the others can continue unaffected.
+ /// That notably requires this class to be thread-safe, since the content managers can be disposed asynchronously.
+ ///
+ /// Note that assets in the cache have two identifiers: the asset name (like "bundles") and key (like "bundles.pt-BR").
+ /// For English and non-translatable assets, these have the same value. The underlying cache only knows about asset
+ /// keys, and the game and mods only know about asset names. The content manager handles resolving them.
+ /// </remarks>
internal class SContentManager : LocalizedContentManager
{
/*********
@@ -27,11 +41,8 @@ namespace StardewModdingAPI.Framework
/// <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 underlying asset cache.</summary>
+ private readonly ContentCache Cache;
/// <summary>The private <see cref="LocalizedContentManager"/> method which generates the locale portion of an asset name.</summary>
private readonly IPrivateMethod GetKeyLocale;
@@ -46,10 +57,13 @@ namespace StardewModdingAPI.Framework
private readonly ContextHash<string> AssetsBeingLoaded = new ContextHash<string>();
/// <summary>A lookup of the content managers which loaded each asset.</summary>
- private readonly IDictionary<string, HashSet<ContentManager>> AssetLoaders = new Dictionary<string, HashSet<ContentManager>>();
+ private readonly IDictionary<string, HashSet<ContentManager>> ContentManagersByAssetKey = new Dictionary<string, HashSet<ContentManager>>();
+
+ /// <summary>The path prefix for assets in mod folders.</summary>
+ private readonly string ModContentPrefix;
- /// <summary>An object locked to prevent concurrent changes to the underlying assets.</summary>
- private readonly object Lock = new object();
+ /// <summary>A lock used to prevents concurrent changes to the cache while data is being read.</summary>
+ private readonly ReaderWriterLockSlim Lock = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion);
/*********
@@ -71,121 +85,176 @@ namespace StardewModdingAPI.Framework
/*********
** Public methods
*********/
+ /****
+ ** Constructor
+ ****/
/// <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)
+ /// <param name="reflection">Simplifies access to private code.</param>
+ public SContentManager(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, string languageCodeOverride, IMonitor monitor, Reflector reflection)
: base(serviceProvider, rootDirectory, currentCulture, languageCodeOverride)
{
- // validate
- if (monitor == null)
- throw new ArgumentNullException(nameof(monitor));
-
- // initialise
- var reflection = new Reflector();
- this.Monitor = monitor;
-
- // get underlying fields for interception
- this.Cache = reflection.GetPrivateField<Dictionary<string, object>>(this, "loadedAssets").GetValue();
+ // init
+ this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
+ this.Cache = new ContentCache(this, reflection, SContentManager.PossiblePathSeparators, SContentManager.PreferredPathSeparator);
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
+ this.ModContentPrefix = this.GetRelativePath(Constants.ModPath);
// get asset data
this.CoreAssets = new CoreAssets(this.NormaliseAssetName);
this.KeyLocales = this.GetKeyLocales(reflection);
}
+ /****
+ ** Asset key/name handling
+ ****/
/// <summary>Normalise path separators in a file path. For asset keys, see <see cref="NormaliseAssetName"/> instead.</summary>
/// <param name="path">The file path to normalise.</param>
+ [Pure]
public string NormalisePathSeparators(string path)
{
- string[] parts = path.Split(SContentManager.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries);
- string normalised = string.Join(SContentManager.PreferredPathSeparator, parts);
- if (path.StartsWith(SContentManager.PreferredPathSeparator))
- normalised = SContentManager.PreferredPathSeparator + normalised; // keep root slash
- return normalised;
+ return this.Cache.NormalisePathSeparators(path);
}
/// <summary>Normalise an asset name so it's consistent with the underlying cache.</summary>
/// <param name="assetName">The asset key.</param>
+ [Pure]
public string NormaliseAssetName(string assetName)
{
- assetName = this.NormalisePathSeparators(assetName);
- if (assetName.EndsWith(".xnb", StringComparison.InvariantCultureIgnoreCase))
- return assetName.Substring(0, assetName.Length - 4);
- return this.NormaliseAssetNameForPlatform(assetName);
+ return this.Cache.NormaliseKey(assetName);
+ }
+
+ /// <summary>Assert that the given key has a valid format.</summary>
+ /// <param name="key">The asset key to check.</param>
+ /// <exception cref="ArgumentException">The asset key is empty or contains invalid characters.</exception>
+ [SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")]
+ public void AssertValidAssetKeyFormat(string key)
+ {
+ if (string.IsNullOrWhiteSpace(key))
+ throw new ArgumentException("The asset key or local path is empty.");
+ if (key.Intersect(Path.GetInvalidPathChars()).Any())
+ throw new ArgumentException("The asset key or local path contains invalid characters.");
+ }
+
+ /// <summary>Get a directory path relative to the content root.</summary>
+ /// <param name="targetPath">The target file path.</param>
+ public string GetRelativePath(string targetPath)
+ {
+ // convert to URIs
+ Uri from = new Uri(this.FullRootDirectory + "/");
+ Uri to = new Uri(targetPath + "/");
+ if (from.Scheme != to.Scheme)
+ throw new InvalidOperationException($"Can't get path for '{targetPath}' relative to '{this.FullRootDirectory}'.");
+
+ // get relative path
+ return Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString())
+ .Replace(Path.DirectorySeparatorChar == '/' ? '\\' : '/', Path.DirectorySeparatorChar); // use correct separator for platform
+ }
+
+ /****
+ ** Content loading
+ ****/
+ /// <summary>Get the current content locale.</summary>
+ public string GetLocale()
+ {
+ return this.GetKeyLocale.Invoke<string>();
}
/// <summary>Get whether the content manager has already loaded and cached the given asset.</summary>
/// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
public bool IsLoaded(string assetName)
{
- lock (this.Lock)
- {
- assetName = this.NormaliseAssetName(assetName);
- return this.IsNormalisedKeyLoaded(assetName);
- }
+ assetName = this.Cache.NormaliseKey(assetName);
+ return this.WithReadLock(() => this.IsNormalisedKeyLoaded(assetName));
}
- /// <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>
+ /// <summary>Get the cached asset keys.</summary>
+ public IEnumerable<string> GetAssetKeys()
+ {
+ return this.WithReadLock(() =>
+ this.Cache.Keys
+ .Select(this.GetAssetName)
+ .Distinct()
+ );
+ }
+
+ /// <summary>Load an asset through the content pipeline. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary>
+ /// <typeparam name="T">The expected asset type.</typeparam>
+ /// <param name="assetName">The asset path relative to the content directory.</param>
public override T Load<T>(string assetName)
{
return this.LoadFor<T>(assetName, this);
}
- /// <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>
+ /// <summary>Load an asset through the content pipeline. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary>
+ /// <typeparam name="T">The expected asset type.</typeparam>
+ /// <param name="assetName">The asset path relative to the content directory.</param>
/// <param name="instance">The content manager instance for which to load the asset.</param>
+ /// <exception cref="ArgumentException">The <paramref name="assetName"/> is empty or contains invalid characters.</exception>
+ /// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception>
public T LoadFor<T>(string assetName, ContentManager instance)
{
- lock (this.Lock)
- {
- assetName = this.NormaliseAssetName(assetName);
+ // normalise asset key
+ this.AssertValidAssetKeyFormat(assetName);
+ assetName = this.NormaliseAssetName(assetName);
- // skip if already loaded
- if (this.IsNormalisedKeyLoaded(assetName))
- {
- this.TrackAssetLoader(assetName, instance);
- return base.Load<T>(assetName);
- }
+ // load game content
+ if (!assetName.StartsWith(this.ModContentPrefix))
+ return this.LoadImpl<T>(assetName, instance);
- // load asset
- T data;
- if (this.AssetsBeingLoaded.Contains(assetName))
- {
- this.Monitor.Log($"Broke loop while loading asset '{assetName}'.", LogLevel.Warn);
- this.Monitor.Log($"Bypassing mod loaders for this asset. Stack trace:\n{Environment.StackTrace}", LogLevel.Trace);
- data = base.Load<T>(assetName);
- }
- else
+ // load mod content
+ SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading content asset '{assetName}': {reasonPhrase}.");
+ try
+ {
+ return this.WithWriteLock(() =>
{
- data = this.AssetsBeingLoaded.Track(assetName, () =>
- {
- IAssetInfo info = new AssetInfo(this.GetLocale(), assetName, typeof(T), this.NormaliseAssetName);
- IAssetData asset = this.ApplyLoader<T>(info) ?? new AssetDataForObject(info, base.Load<T>(assetName), this.NormaliseAssetName);
- asset = this.ApplyEditors<T>(info, asset);
- return (T)asset.Data;
- });
- }
+ // try cache
+ if (this.IsLoaded(assetName))
+ return this.LoadImpl<T>(assetName, instance);
- // update cache & return data
- this.Cache[assetName] = data;
- this.TrackAssetLoader(assetName, instance);
- return data;
+ // get file
+ FileInfo file = this.GetModFile(assetName);
+ if (!file.Exists)
+ throw GetContentError("the specified path doesn't exist.");
+
+ // load content
+ switch (file.Extension.ToLower())
+ {
+ // XNB file
+ case ".xnb":
+ return this.LoadImpl<T>(assetName, instance);
+
+ // unpacked map
+ case ".tbin":
+ throw GetContentError($"can't read unpacked map file '{assetName}' directly from the underlying content manager. It must be loaded through the mod's {typeof(IModHelper)}.{nameof(IModHelper.Content)} helper.");
+
+ // unpacked image
+ case ".png":
+ // validate
+ if (typeof(T) != typeof(Texture2D))
+ throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'.");
+
+ // fetch & cache
+ using (FileStream stream = File.OpenRead(file.FullName))
+ {
+ Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream);
+ texture = this.PremultiplyTransparency(texture);
+ this.InjectWithoutLock(assetName, texture, instance);
+ return (T)(object)texture;
+ }
+
+ default:
+ throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.png', '.tbin', or '.xnb'.");
+ }
+ });
+ }
+ catch (Exception ex) when (!(ex is SContentLoadException))
+ {
+ throw new SContentLoadException($"The content manager failed loading content asset '{assetName}'.", ex);
}
}
@@ -193,40 +262,15 @@ namespace StardewModdingAPI.Framework
/// <typeparam name="T">The type of asset to inject.</typeparam>
/// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
/// <param name="value">The asset value.</param>
- public void Inject<T>(string assetName, T value)
- {
- lock (this.Lock)
- {
- assetName = this.NormaliseAssetName(assetName);
- this.Cache[assetName] = value;
- this.TrackAssetLoader(assetName, this);
- }
- }
-
- /// <summary>Get the current content locale.</summary>
- public string GetLocale()
- {
- return this.GetKeyLocale.Invoke<string>();
- }
-
- /// <summary>Get the cached asset keys.</summary>
- public IEnumerable<string> GetAssetKeys()
+ /// <param name="instance">The content manager instance for which to load the asset.</param>
+ public void Inject<T>(string assetName, T value, ContentManager instance)
{
- lock (this.Lock)
- {
- IEnumerable<string> GetAllAssetKeys()
- {
- foreach (string cacheKey in this.Cache.Keys)
- {
- this.ParseCacheKey(cacheKey, out string assetKey, out string _);
- yield return assetKey;
- }
- }
-
- return GetAllAssetKeys().Distinct();
- }
+ this.WithWriteLock(() => this.InjectWithoutLock(assetName, value, instance));
}
+ /****
+ ** Cache invalidation
+ ****/
/// <summary>Purge assets from the cache that match one of the interceptors.</summary>
/// <param name="editors">The asset editors for which to purge matching assets.</param>
/// <param name="loaders">The asset loaders for which to purge matching assets.</param>
@@ -239,21 +283,34 @@ namespace StardewModdingAPI.Framework
// get CanEdit/Load methods
MethodInfo canEdit = typeof(IAssetEditor).GetMethod(nameof(IAssetEditor.CanEdit));
MethodInfo canLoad = typeof(IAssetLoader).GetMethod(nameof(IAssetLoader.CanLoad));
+ if (canEdit == null || canLoad == null)
+ throw new InvalidOperationException("SMAPI could not access the interceptor methods."); // should never happen
// invalidate matching keys
- return this.InvalidateCache((assetName, assetType) =>
+ return this.InvalidateCache(asset =>
{
- // get asset metadata
- IAssetInfo info = new AssetInfo(this.GetLocale(), assetName, assetType, this.NormaliseAssetName);
-
// check loaders
- MethodInfo canLoadGeneric = canLoad.MakeGenericMethod(assetType);
- if (loaders.Any(loader => (bool)canLoadGeneric.Invoke(loader, new object[] { info })))
+ MethodInfo canLoadGeneric = canLoad.MakeGenericMethod(asset.DataType);
+ if (loaders.Any(loader => (bool)canLoadGeneric.Invoke(loader, new object[] { asset })))
return true;
// check editors
- MethodInfo canEditGeneric = canEdit.MakeGenericMethod(assetType);
- return editors.Any(editor => (bool)canEditGeneric.Invoke(editor, new object[] { info }));
+ MethodInfo canEditGeneric = canEdit.MakeGenericMethod(asset.DataType);
+ return editors.Any(editor => (bool)canEditGeneric.Invoke(editor, new object[] { asset }));
+ });
+ }
+
+ /// <summary>Purge matched assets from the cache.</summary>
+ /// <param name="predicate">Matches the asset keys to invalidate.</param>
+ /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
+ /// <returns>Returns whether any cache entries were invalidated.</returns>
+ public bool InvalidateCache(Func<IAssetInfo, bool> predicate, bool dispose = false)
+ {
+ string locale = this.GetLocale();
+ return this.InvalidateCache((assetName, type) =>
+ {
+ IAssetInfo info = new AssetInfo(locale, assetName, type, this.NormaliseAssetName);
+ return predicate(info);
});
}
@@ -263,83 +320,81 @@ namespace StardewModdingAPI.Framework
/// <returns>Returns whether any cache entries were invalidated.</returns>
public bool InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false)
{
- lock (this.Lock)
+ return this.WithWriteLock(() =>
{
- // find matching asset keys
- HashSet<string> purgeCacheKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
- HashSet<string> purgeAssetKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
- foreach (string cacheKey in this.Cache.Keys)
+ // invalidate matching keys
+ HashSet<string> removeKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
+ HashSet<string> removeAssetNames = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
+ this.Cache.Remove((key, type) =>
{
- this.ParseCacheKey(cacheKey, out string assetKey, out _);
- Type type = this.Cache[cacheKey].GetType();
- if (predicate(assetKey, type))
+ this.ParseCacheKey(key, out string assetName, out _);
+ if (removeAssetNames.Contains(assetName) || predicate(assetName, type))
{
- purgeAssetKeys.Add(assetKey);
- purgeCacheKeys.Add(cacheKey);
+ removeAssetNames.Add(assetName);
+ removeKeys.Add(key);
+ return true;
}
- }
+ return false;
+ });
- // purge assets
- foreach (string key in purgeCacheKeys)
- {
- if (dispose && this.Cache[key] is IDisposable disposable)
- disposable.Dispose();
- this.Cache.Remove(key);
- this.AssetLoaders.Remove(key);
- }
+ // update reference tracking
+ foreach (string key in removeKeys)
+ this.ContentManagersByAssetKey.Remove(key);
// reload core game assets
int reloaded = 0;
- foreach (string key in purgeAssetKeys)
+ foreach (string key in removeAssetNames)
{
if (this.CoreAssets.ReloadForKey(this, key))
reloaded++;
}
// report result
- if (purgeCacheKeys.Any())
+ if (removeKeys.Any())
{
- this.Monitor.Log($"Invalidated {purgeCacheKeys.Count} cache entries for {purgeAssetKeys.Count} asset keys: {string.Join(", ", purgeCacheKeys.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase))}. Reloaded {reloaded} core assets.", LogLevel.Trace);
+ this.Monitor.Log($"Invalidated {removeAssetNames.Count} asset names: {string.Join(", ", removeKeys.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase))}. Reloaded {reloaded} core assets.", LogLevel.Trace);
return true;
}
this.Monitor.Log("Invalidated 0 cache entries.", LogLevel.Trace);
return false;
- }
+ });
}
+ /****
+ ** Disposal
+ ****/
/// <summary>Dispose assets for the given content manager shim.</summary>
/// <param name="shim">The content manager whose assets to dispose.</param>
internal void DisposeFor(ContentManagerShim shim)
{
this.Monitor.Log($"Content manager '{shim.Name}' disposed, disposing assets that aren't needed by any other asset loader.", LogLevel.Trace);
- foreach (var entry in this.AssetLoaders)
- entry.Value.Remove(shim);
- this.InvalidateCache((key, type) => !this.AssetLoaders[key].Any(), dispose: true);
+ this.WithWriteLock(() =>
+ {
+ foreach (var entry in this.ContentManagersByAssetKey)
+ entry.Value.Remove(shim);
+ this.InvalidateCache((key, type) => !this.ContentManagersByAssetKey[key].Any(), dispose: true);
+ });
}
/*********
** Private methods
*********/
- /// <summary>Get whether an asset has already been loaded.</summary>
- /// <param name="normalisedAssetName">The normalised asset name.</param>
- private bool IsNormalisedKeyLoaded(string normalisedAssetName)
- {
- return this.Cache.ContainsKey(normalisedAssetName)
- || this.Cache.ContainsKey($"{normalisedAssetName}.{this.GetKeyLocale.Invoke<string>()}"); // translated asset
- }
-
- /// <summary>Track that a content manager loaded an asset.</summary>
- /// <param name="key">The asset key that was loaded.</param>
- /// <param name="manager">The content manager that loaded the asset.</param>
- private void TrackAssetLoader(string key, ContentManager manager)
+ /****
+ ** Disposal
+ ****/
+ /// <summary>Dispose held resources.</summary>
+ /// <param name="disposing">Whether the content manager is disposing (rather than finalising).</param>
+ protected override void Dispose(bool disposing)
{
- if (!this.AssetLoaders.TryGetValue(key, out HashSet<ContentManager> hash))
- hash = this.AssetLoaders[key] = new HashSet<ContentManager>();
- hash.Add(manager);
+ this.Monitor.Log("Disposing SMAPI's main content manager. It will no longer be usable after this point.", LogLevel.Trace);
+ base.Dispose(disposing);
}
+ /****
+ ** Asset name/key handling
+ ****/
/// <summary>Get the locale codes (like <c>ja-JP</c>) used in asset keys.</summary>
/// <param name="reflection">Simplifies access to private game code.</param>
private IDictionary<string, LanguageCode> GetKeyLocales(Reflector reflection)
@@ -367,11 +422,19 @@ namespace StardewModdingAPI.Framework
return map;
}
+ /// <summary>Get the asset name from a cache key.</summary>
+ /// <param name="cacheKey">The input cache key.</param>
+ private string GetAssetName(string cacheKey)
+ {
+ this.ParseCacheKey(cacheKey, out string assetName, out string _);
+ return assetName;
+ }
+
/// <summary>Parse a cache key into its component parts.</summary>
/// <param name="cacheKey">The input cache key.</param>
- /// <param name="assetKey">The original asset key.</param>
+ /// <param name="assetName">The original asset name.</param>
/// <param name="localeCode">The asset locale code (or <c>null</c> if not localised).</param>
- private void ParseCacheKey(string cacheKey, out string assetKey, out string localeCode)
+ private void ParseCacheKey(string cacheKey, out string assetName, out string localeCode)
{
// handle localised key
if (!string.IsNullOrWhiteSpace(cacheKey))
@@ -382,7 +445,7 @@ namespace StardewModdingAPI.Framework
string suffix = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1);
if (this.KeyLocales.ContainsKey(suffix))
{
- assetKey = cacheKey.Substring(0, lastSepIndex);
+ assetName = cacheKey.Substring(0, lastSepIndex);
localeCode = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1);
return;
}
@@ -390,10 +453,117 @@ namespace StardewModdingAPI.Framework
}
// handle simple key
- assetKey = cacheKey;
+ assetName = cacheKey;
localeCode = null;
}
+ /****
+ ** Cache handling
+ ****/
+ /// <summary>Get whether an asset has already been loaded.</summary>
+ /// <param name="normalisedAssetName">The normalised asset name.</param>
+ private bool IsNormalisedKeyLoaded(string normalisedAssetName)
+ {
+ return this.Cache.ContainsKey(normalisedAssetName)
+ || this.Cache.ContainsKey($"{normalisedAssetName}.{this.GetKeyLocale.Invoke<string>()}"); // translated asset
+ }
+
+ /// <summary>Track that a content manager loaded an asset.</summary>
+ /// <param name="key">The asset key that was loaded.</param>
+ /// <param name="manager">The content manager that loaded the asset.</param>
+ private void TrackAssetLoader(string key, ContentManager manager)
+ {
+ if (!this.ContentManagersByAssetKey.TryGetValue(key, out HashSet<ContentManager> hash))
+ hash = this.ContentManagersByAssetKey[key] = new HashSet<ContentManager>();
+ hash.Add(manager);
+ }
+
+ /****
+ ** Content loading
+ ****/
+ /// <summary>Load an asset name without heuristics to support mod content.</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>
+ /// <param name="instance">The content manager instance for which to load the asset.</param>
+ private T LoadImpl<T>(string assetName, ContentManager instance)
+ {
+ return this.WithWriteLock(() =>
+ {
+ // skip if already loaded
+ if (this.IsNormalisedKeyLoaded(assetName))
+ {
+ this.TrackAssetLoader(assetName, instance);
+ return base.Load<T>(assetName);
+ }
+
+ // load asset
+ T data;
+ if (this.AssetsBeingLoaded.Contains(assetName))
+ {
+ this.Monitor.Log($"Broke loop while loading asset '{assetName}'.", LogLevel.Warn);
+ this.Monitor.Log($"Bypassing mod loaders for this asset. Stack trace:\n{Environment.StackTrace}", LogLevel.Trace);
+ data = base.Load<T>(assetName);
+ }
+ else
+ {
+ data = this.AssetsBeingLoaded.Track(assetName, () =>
+ {
+ IAssetInfo info = new AssetInfo(this.GetLocale(), assetName, typeof(T), this.NormaliseAssetName);
+ IAssetData asset = this.ApplyLoader<T>(info) ?? new AssetDataForObject(info, base.Load<T>(assetName), this.NormaliseAssetName);
+ asset = this.ApplyEditors<T>(info, asset);
+ return (T)asset.Data;
+ });
+ }
+
+ // update cache & return data
+ this.InjectWithoutLock(assetName, data, instance);
+ return data;
+ });
+ }
+
+ /// <summary>Inject an asset into the cache without acquiring a write lock. This should only be called from within a write lock.</summary>
+ /// <typeparam name="T">The type of asset to inject.</typeparam>
+ /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
+ /// <param name="value">The asset value.</param>
+ /// <param name="instance">The content manager instance for which to load the asset.</param>
+ private void InjectWithoutLock<T>(string assetName, T value, ContentManager instance)
+ {
+ assetName = this.NormaliseAssetName(assetName);
+ this.Cache[assetName] = value;
+ this.TrackAssetLoader(assetName, instance);
+ }
+
+ /// <summary>Get a file from the mod folder.</summary>
+ /// <param name="path">The asset path relative to the content folder.</param>
+ private FileInfo GetModFile(string path)
+ {
+ // try exact match
+ FileInfo file = new FileInfo(Path.Combine(this.FullRootDirectory, path));
+
+ // try with default extension
+ if (!file.Exists && file.Extension.ToLower() != ".xnb")
+ {
+ FileInfo result = new FileInfo(file.FullName + ".xnb");
+ if (result.Exists)
+ file = result;
+ }
+
+ return file;
+ }
+
+ /// <summary>Get a file from the game's content folder.</summary>
+ /// <param name="key">The asset key.</param>
+ private FileInfo GetContentFolderFile(string key)
+ {
+ // get file path
+ string path = Path.Combine(this.FullRootDirectory, key);
+ if (!path.EndsWith(".xnb"))
+ path += ".xnb";
+
+ // get file
+ return new FileInfo(path);
+ }
+
/// <summary>Load the initial asset from the registered <see cref="Loaders"/>.</summary>
/// <param name="info">The basic asset metadata.</param>
/// <returns>Returns the loaded asset metadata, or <c>null</c> if no loader matched.</returns>
@@ -510,25 +680,123 @@ namespace StardewModdingAPI.Framework
{
foreach (var entry in entries)
{
- IModMetadata metadata = entry.Key;
+ IModMetadata mod = entry.Key;
IList<T> interceptors = entry.Value;
- // special case if mod is an interceptor
- if (metadata.Mod is T modAsInterceptor)
- yield return new KeyValuePair<IModMetadata, T>(metadata, modAsInterceptor);
-
// registered editors
foreach (T interceptor in interceptors)
- yield return new KeyValuePair<IModMetadata, T>(metadata, interceptor);
+ yield return new KeyValuePair<IModMetadata, T>(mod, interceptor);
}
}
- /// <summary>Dispose held resources.</summary>
- /// <param name="disposing">Whether the content manager is disposing (rather than finalising).</param>
- protected override void Dispose(bool disposing)
+ /// <summary>Premultiply a texture's alpha values to avoid transparency issues in the game. This is only possible if the game isn't currently drawing.</summary>
+ /// <param name="texture">The texture to premultiply.</param>
+ /// <returns>Returns a premultiplied texture.</returns>
+ /// <remarks>Based on <a href="https://gist.github.com/Layoric/6255384">code by Layoric</a>.</remarks>
+ private Texture2D PremultiplyTransparency(Texture2D texture)
{
- this.Monitor.Log("Disposing SMAPI's main content manager. It will no longer be usable after this point.", LogLevel.Trace);
- base.Dispose(disposing);
+ // validate
+ if (Context.IsInDrawLoop)
+ throw new NotSupportedException("Can't load a PNG file while the game is drawing to the screen. Make sure you load content outside the draw loop.");
+
+ // process texture
+ SpriteBatch spriteBatch = Game1.spriteBatch;
+ GraphicsDevice gpu = Game1.graphics.GraphicsDevice;
+ using (RenderTarget2D renderTarget = new RenderTarget2D(Game1.graphics.GraphicsDevice, texture.Width, texture.Height))
+ {
+ // create blank render target to premultiply
+ gpu.SetRenderTarget(renderTarget);
+ gpu.Clear(Color.Black);
+
+ // multiply each color by the source alpha, and write just the color values into the final texture
+ spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState
+ {
+ ColorDestinationBlend = Blend.Zero,
+ ColorWriteChannels = ColorWriteChannels.Red | ColorWriteChannels.Green | ColorWriteChannels.Blue,
+ AlphaDestinationBlend = Blend.Zero,
+ AlphaSourceBlend = Blend.SourceAlpha,
+ ColorSourceBlend = Blend.SourceAlpha
+ });
+ spriteBatch.Draw(texture, texture.Bounds, Color.White);
+ spriteBatch.End();
+
+ // copy the alpha values from the source texture into the final one without multiplying them
+ spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState
+ {
+ ColorWriteChannels = ColorWriteChannels.Alpha,
+ AlphaDestinationBlend = Blend.Zero,
+ ColorDestinationBlend = Blend.Zero,
+ AlphaSourceBlend = Blend.One,
+ ColorSourceBlend = Blend.One
+ });
+ spriteBatch.Draw(texture, texture.Bounds, Color.White);
+ spriteBatch.End();
+
+ // release GPU
+ gpu.SetRenderTarget(null);
+
+ // extract premultiplied data
+ Color[] data = new Color[texture.Width * texture.Height];
+ renderTarget.GetData(data);
+
+ // unset texture from GPU to regain control
+ gpu.Textures[0] = null;
+
+ // update texture with premultiplied data
+ texture.SetData(data);
+ }
+
+ return texture;
+ }
+
+ /****
+ ** Concurrency logic
+ ****/
+ /// <summary>Acquire a read lock which prevents concurrent writes to the cache while it's open.</summary>
+ /// <typeparam name="T">The action's return value.</typeparam>
+ /// <param name="action">The action to perform.</param>
+ private T WithReadLock<T>(Func<T> action)
+ {
+ try
+ {
+ this.Lock.EnterReadLock();
+ return action();
+ }
+ finally
+ {
+ this.Lock.ExitReadLock();
+ }
+ }
+
+ /// <summary>Acquire a write lock which prevents concurrent reads or writes to the cache while it's open.</summary>
+ /// <param name="action">The action to perform.</param>
+ private void WithWriteLock(Action action)
+ {
+ try
+ {
+ this.Lock.EnterWriteLock();
+ action();
+ }
+ finally
+ {
+ this.Lock.ExitWriteLock();
+ }
+ }
+
+ /// <summary>Acquire a write lock which prevents concurrent reads or writes to the cache while it's open.</summary>
+ /// <typeparam name="T">The action's return value.</typeparam>
+ /// <param name="action">The action to perform.</param>
+ private T WithWriteLock<T>(Func<T> action)
+ {
+ try
+ {
+ this.Lock.EnterReadLock();
+ return action();
+ }
+ finally
+ {
+ this.Lock.ExitReadLock();
+ }
}
}
}
diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs
index 6f8f7cef..c886a4b7 100644
--- a/src/SMAPI/Framework/SGame.cs
+++ b/src/SMAPI/Framework/SGame.cs
@@ -12,7 +12,6 @@ using Microsoft.Xna.Framework.Input;
using StardewModdingAPI.Events;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.Utilities;
-using StardewModdingAPI.Utilities;
using StardewValley;
using StardewValley.BellsAndWhistles;
using StardewValley.Locations;
@@ -180,7 +179,7 @@ namespace StardewModdingAPI.Framework
// override content manager
this.Monitor?.Log("Overriding content manager...", LogLevel.Trace);
- this.SContentManager = new SContentManager(this.Content.ServiceProvider, this.Content.RootDirectory, Thread.CurrentThread.CurrentUICulture, null, this.Monitor);
+ this.SContentManager = new SContentManager(this.Content.ServiceProvider, this.Content.RootDirectory, Thread.CurrentThread.CurrentUICulture, null, this.Monitor, reflection);
this.Content = new ContentManagerShim(this.SContentManager, "SGame.Content");
Game1.content = new ContentManagerShim(this.SContentManager, "Game1.content");
reflection.GetPrivateField<LocalizedContentManager>(typeof(Game1), "_temporaryContent").SetValue(new ContentManagerShim(this.SContentManager, "Game1._temporaryContent")); // regenerate value with new content manager
@@ -241,6 +240,9 @@ namespace StardewModdingAPI.Framework
return;
}
+ /*********
+ ** Save events + suppress events during save
+ *********/
// While the game is writing to the save file in the background, mods can unexpectedly
// fail since they don't have exclusive access to resources (e.g. collection changed
// during enumeration errors). To avoid problems, events are not invoked while a save
@@ -249,7 +251,7 @@ namespace StardewModdingAPI.Framework
if (Context.IsSaving)
{
// raise before-save
- if (!this.IsBetweenSaveEvents)
+ if (Context.IsWorldReady && !this.IsBetweenSaveEvents)
{
this.IsBetweenSaveEvents = true;
this.Monitor.Log("Context: before save.", LogLevel.Trace);
@@ -371,7 +373,8 @@ namespace StardewModdingAPI.Framework
SButton[] previousPressedKeys = this.PreviousPressedButtons;
SButton[] framePressedKeys = currentlyPressedKeys.Except(previousPressedKeys).ToArray();
SButton[] frameReleasedKeys = previousPressedKeys.Except(currentlyPressedKeys).ToArray();
- bool isClick = framePressedKeys.Contains(SButton.MouseLeft) || (framePressedKeys.Contains(SButton.ControllerA) && !currentlyPressedKeys.Contains(SButton.ControllerX));
+ bool isUseToolButton = Game1.options.useToolButton.Any(p => framePressedKeys.Contains(p.ToSButton()));
+ bool isActionButton = !isUseToolButton && Game1.options.actionButton.Any(p => framePressedKeys.Contains(p.ToSButton()));
// get cursor position
ICursorPosition cursor;
@@ -388,7 +391,7 @@ namespace StardewModdingAPI.Framework
// raise button pressed
foreach (SButton button in framePressedKeys)
{
- InputEvents.InvokeButtonPressed(this.Monitor, button, cursor, isClick);
+ InputEvents.InvokeButtonPressed(this.Monitor, button, cursor, isActionButton, isUseToolButton);
// legacy events
if (button.TryGetKeyboard(out Keys key))
@@ -408,10 +411,9 @@ namespace StardewModdingAPI.Framework
// raise button released
foreach (SButton button in frameReleasedKeys)
{
- bool wasClick =
- (button == SButton.MouseLeft && previousPressedKeys.Contains(SButton.MouseLeft)) // released left click
- || (button == SButton.ControllerA && previousPressedKeys.Contains(SButton.ControllerA) && !previousPressedKeys.Contains(SButton.ControllerX));
- InputEvents.InvokeButtonReleased(this.Monitor, button, cursor, wasClick);
+ bool wasUseToolButton = (from opt in Game1.options.useToolButton let optButton = opt.ToSButton() where optButton == button && framePressedKeys.Contains(optButton) select optButton).Any();
+ bool wasActionButton = !wasUseToolButton && (from opt in Game1.options.actionButton let optButton = opt.ToSButton() where optButton == button && framePressedKeys.Contains(optButton) select optButton).Any();
+ InputEvents.InvokeButtonReleased(this.Monitor, button, cursor, wasActionButton, wasUseToolButton);
// legacy events
if (button.TryGetKeyboard(out Keys key))
diff --git a/src/SMAPI/Framework/Serialisation/JsonHelper.cs b/src/SMAPI/Framework/Serialisation/JsonHelper.cs
index 3193aa3c..d923ec0c 100644
--- a/src/SMAPI/Framework/Serialisation/JsonHelper.cs
+++ b/src/SMAPI/Framework/Serialisation/JsonHelper.cs
@@ -1,9 +1,8 @@
-using System;
+using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.Xna.Framework.Input;
using Newtonsoft.Json;
-using StardewModdingAPI.Utilities;
namespace StardewModdingAPI.Framework.Serialisation
{
@@ -20,7 +19,9 @@ namespace StardewModdingAPI.Framework.Serialisation
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), typeof(SButton))
+ new StringEnumConverter<Buttons>(),
+ new StringEnumConverter<Keys>(),
+ new StringEnumConverter<SButton>()
}
};
diff --git a/src/SMAPI/Framework/Serialisation/SelectiveStringEnumConverter.cs b/src/SMAPI/Framework/Serialisation/SelectiveStringEnumConverter.cs
deleted file mode 100644
index 37108556..00000000
--- a/src/SMAPI/Framework/Serialisation/SelectiveStringEnumConverter.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-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/SMAPI/Framework/Serialisation/StringEnumConverter.cs b/src/SMAPI/Framework/Serialisation/StringEnumConverter.cs
new file mode 100644
index 00000000..7afe86cd
--- /dev/null
+++ b/src/SMAPI/Framework/Serialisation/StringEnumConverter.cs
@@ -0,0 +1,22 @@
+using System;
+using Newtonsoft.Json.Converters;
+
+namespace StardewModdingAPI.Framework.Serialisation
+{
+ /// <summary>A variant of <see cref="StringEnumConverter"/> which only converts a specified enum.</summary>
+ /// <typeparam name="T">The enum type.</typeparam>
+ internal class StringEnumConverter<T> : StringEnumConverter
+ {
+ /*********
+ ** Public methods
+ *********/
+ /// <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)
+ && (Nullable.GetUnderlyingType(type) ?? type) == typeof(T);
+ }
+ }
+}