summaryrefslogtreecommitdiff
path: root/src/SMAPI/Framework/SContentManager.cs
diff options
context:
space:
mode:
Diffstat (limited to 'src/SMAPI/Framework/SContentManager.cs')
-rw-r--r--src/SMAPI/Framework/SContentManager.cs618
1 files changed, 443 insertions, 175 deletions
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();
+ }
}
}
}