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
+{
+ /// 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.
+ internal class ContentCache
+ {
+ /*********
+ ** Properties
+ *********/
+ /// The underlying asset cache.
+ private readonly IDictionary Cache;
+
+ /// The possible directory separator characters in an asset key.
+ private readonly char[] PossiblePathSeparators;
+
+ /// The preferred directory separator chaeacter in an asset key.
+ private readonly string PreferredPathSeparator;
+
+ /// Applies platform-specific asset key normalisation so it's consistent with the underlying cache.
+ private readonly Func NormaliseAssetNameForPlatform;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// Get or set the value of a raw cache entry.
+ /// The cache key.
+ public object this[string key]
+ {
+ get => this.Cache[key];
+ set => this.Cache[key] = value;
+ }
+
+ /// The current cache keys.
+ public IEnumerable Keys => this.Cache.Keys;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /****
+ ** Constructor
+ ****/
+ /// Construct an instance.
+ /// The underlying content manager whose cache to manage.
+ /// Simplifies access to private game code.
+ /// The possible directory separator characters in an asset key.
+ /// The preferred directory separator chaeacter in an asset key.
+ public ContentCache(LocalizedContentManager contentManager, Reflector reflection, char[] possiblePathSeparators, string preferredPathSeparator)
+ {
+ // init
+ this.Cache = reflection.GetPrivateField>(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(path);
+ }
+ else
+ this.NormaliseAssetNameForPlatform = key => key.Replace('\\', '/'); // based on MonoGame's ContentManager.Load logic
+ }
+
+ /****
+ ** Fetch
+ ****/
+ /// Get whether the cache contains a given key.
+ /// The cache key.
+ public bool ContainsKey(string key)
+ {
+ return this.Cache.ContainsKey(key);
+ }
+
+
+ /****
+ ** Normalise
+ ****/
+ /// Normalise path separators in a file path. For asset keys, see instead.
+ /// The file path to normalise.
+ [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;
+ }
+
+ /// Normalise a cache key so it's consistent with the underlying cache.
+ /// The asset key.
+ [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
+ ****/
+ /// Remove an asset with the given key.
+ /// The cache key.
+ /// Whether to dispose the entry value, if applicable.
+ /// Returns the removed key (if any).
+ 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);
+ }
+
+ /// Purge matched assets from the cache.
+ /// Matches the asset keys to invalidate.
+ /// Whether to dispose invalidated assets. This should only be true when they're being invalidated as part of a dispose, to avoid crashing the game.
+ /// Returns the removed keys (if any).
+ public IEnumerable Remove(Func predicate, bool dispose = false)
+ {
+ List removed = new List();
+ 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/SContentManager.cs b/src/SMAPI/Framework/SContentManager.cs
index 2f5d104f..0b6daaa6 100644
--- a/src/SMAPI/Framework/SContentManager.cs
+++ b/src/SMAPI/Framework/SContentManager.cs
@@ -1,13 +1,13 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.Contracts;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
-using Microsoft.Xna.Framework;
+using System.Threading;
using Microsoft.Xna.Framework.Content;
using StardewModdingAPI.Framework.Content;
-using StardewModdingAPI.Framework.ModLoading;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.Utilities;
using StardewModdingAPI.Metadata;
@@ -15,7 +15,17 @@ using StardewValley;
namespace StardewModdingAPI.Framework
{
- /// SMAPI's implementation of the game's content manager which lets it raise content events.
+ /// A thread-safe content manager which intercepts assets being loaded to let SMAPI mods inject or edit them.
+ ///
+ /// 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 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.
+ ///
internal class SContentManager : LocalizedContentManager
{
/*********
@@ -27,11 +37,8 @@ namespace StardewModdingAPI.Framework
/// Encapsulates monitoring and logging.
private readonly IMonitor Monitor;
- /// The underlying content manager's asset cache.
- private readonly IDictionary Cache;
-
- /// Applies platform-specific asset key normalisation so it's consistent with the underlying cache.
- private readonly Func NormaliseAssetNameForPlatform;
+ /// The underlying asset cache.
+ private readonly ContentCache Cache;
/// The private method which generates the locale portion of an asset name.
private readonly IPrivateMethod GetKeyLocale;
@@ -46,10 +53,10 @@ namespace StardewModdingAPI.Framework
private readonly ContextHash AssetsBeingLoaded = new ContextHash();
/// A lookup of the content managers which loaded each asset.
- private readonly IDictionary> AssetLoaders = new Dictionary>();
+ private readonly IDictionary> ContentManagersByAssetKey = new Dictionary>();
- /// An object locked to prevent concurrent changes to the underlying assets.
- private readonly object Lock = new object();
+ /// A lock used to prevents concurrent changes to the cache while data is being read.
+ private readonly ReaderWriterLockSlim Lock = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion);
/*********
@@ -77,30 +84,15 @@ namespace StardewModdingAPI.Framework
/// The current culture for which to localise content.
/// The current language code for which to localise content.
/// Encapsulates monitoring and logging.
- public SContentManager(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, string languageCodeOverride, IMonitor monitor)
+ /// Simplifies access to private code.
+ 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>(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(path);
- }
- else
- this.NormaliseAssetNameForPlatform = key => key.Replace('\\', '/'); // based on MonoGame's ContentManager.Load logic
-
// get asset data
this.CoreAssets = new CoreAssets(this.NormaliseAssetName);
this.KeyLocales = this.GetKeyLocales(reflection);
@@ -108,34 +100,26 @@ namespace StardewModdingAPI.Framework
/// Normalise path separators in a file path. For asset keys, see instead.
/// The file path to normalise.
+ [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);
}
/// Normalise an asset name so it's consistent with the underlying cache.
/// The asset key.
+ [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);
}
/// Get whether the content manager has already loaded and cached the given asset.
/// The asset path relative to the loader root directory, not including the .xnb extension.
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));
}
/// Load an asset that has been processed by the content pipeline.
@@ -152,10 +136,9 @@ namespace StardewModdingAPI.Framework
/// The content manager instance for which to load the asset.
public T LoadFor(string assetName, ContentManager instance)
{
- lock (this.Lock)
+ assetName = this.NormaliseAssetName(assetName);
+ return this.WithWriteLock(() =>
{
- assetName = this.NormaliseAssetName(assetName);
-
// skip if already loaded
if (this.IsNormalisedKeyLoaded(assetName))
{
@@ -186,7 +169,7 @@ namespace StardewModdingAPI.Framework
this.Cache[assetName] = data;
this.TrackAssetLoader(assetName, instance);
return data;
- }
+ });
}
/// Inject an asset into the cache.
@@ -195,12 +178,12 @@ namespace StardewModdingAPI.Framework
/// The asset value.
public void Inject(string assetName, T value)
{
- lock (this.Lock)
+ this.WithWriteLock(() =>
{
assetName = this.NormaliseAssetName(assetName);
this.Cache[assetName] = value;
this.TrackAssetLoader(assetName, this);
- }
+ });
}
/// Get the current content locale.
@@ -212,19 +195,11 @@ namespace StardewModdingAPI.Framework
/// Get the cached asset keys.
public IEnumerable GetAssetKeys()
{
- lock (this.Lock)
- {
- IEnumerable GetAllAssetKeys()
- {
- foreach (string cacheKey in this.Cache.Keys)
- {
- this.ParseCacheKey(cacheKey, out string assetKey, out string _);
- yield return assetKey;
- }
- }
-
- return GetAllAssetKeys().Distinct();
- }
+ return this.WithReadLock(() =>
+ this.Cache.Keys
+ .Select(this.GetAssetName)
+ .Distinct()
+ );
}
/// Purge assets from the cache that match one of the interceptors.
@@ -239,11 +214,12 @@ 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) =>
{
- // get asset metadata
IAssetInfo info = new AssetInfo(this.GetLocale(), assetName, assetType, this.NormaliseAssetName);
// check loaders
@@ -263,48 +239,44 @@ namespace StardewModdingAPI.Framework
/// Returns whether any cache entries were invalidated.
public bool InvalidateCache(Func predicate, bool dispose = false)
{
- lock (this.Lock)
+ return this.WithWriteLock(() =>
{
- // find matching asset keys
- HashSet purgeCacheKeys = new HashSet(StringComparer.InvariantCultureIgnoreCase);
- HashSet purgeAssetKeys = new HashSet(StringComparer.InvariantCultureIgnoreCase);
- foreach (string cacheKey in this.Cache.Keys)
+ // invalidate matching keys
+ HashSet removeKeys = new HashSet(StringComparer.InvariantCultureIgnoreCase);
+ HashSet removeAssetNames = new HashSet(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;
- }
+ });
}
/// Dispose assets for the given content manager shim.
@@ -313,15 +285,26 @@ namespace StardewModdingAPI.Framework
{
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
*********/
+ /// Dispose held resources.
+ /// Whether the content manager is disposing (rather than finalising).
+ protected override void Dispose(bool disposing)
+ {
+ this.Monitor.Log("Disposing SMAPI's main content manager. It will no longer be usable after this point.", LogLevel.Trace);
+ base.Dispose(disposing);
+ }
+
/// Get whether an asset has already been loaded.
/// The normalised asset name.
private bool IsNormalisedKeyLoaded(string normalisedAssetName)
@@ -335,8 +318,8 @@ namespace StardewModdingAPI.Framework
/// The content manager that loaded the asset.
private void TrackAssetLoader(string key, ContentManager manager)
{
- if (!this.AssetLoaders.TryGetValue(key, out HashSet hash))
- hash = this.AssetLoaders[key] = new HashSet();
+ if (!this.ContentManagersByAssetKey.TryGetValue(key, out HashSet hash))
+ hash = this.ContentManagersByAssetKey[key] = new HashSet();
hash.Add(manager);
}
@@ -367,11 +350,19 @@ namespace StardewModdingAPI.Framework
return map;
}
+ /// Get the asset name from a cache key.
+ /// The input cache key.
+ private string GetAssetName(string cacheKey)
+ {
+ this.ParseCacheKey(cacheKey, out string assetName, out string _);
+ return assetName;
+ }
+
/// Parse a cache key into its component parts.
/// The input cache key.
- /// The original asset key.
+ /// The original asset name.
/// The asset locale code (or null if not localised).
- 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 +373,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,7 +381,7 @@ namespace StardewModdingAPI.Framework
}
// handle simple key
- assetKey = cacheKey;
+ assetName = cacheKey;
localeCode = null;
}
@@ -519,12 +510,51 @@ namespace StardewModdingAPI.Framework
}
}
- /// Dispose held resources.
- /// Whether the content manager is disposing (rather than finalising).
- protected override void Dispose(bool disposing)
+ /// Acquire a read lock which prevents concurrent writes to the cache while it's open.
+ /// The action's return value.
+ /// The action to perform.
+ private T WithReadLock(Func action)
{
- this.Monitor.Log("Disposing SMAPI's main content manager. It will no longer be usable after this point.", LogLevel.Trace);
- base.Dispose(disposing);
+ try
+ {
+ this.Lock.EnterReadLock();
+ return action();
+ }
+ finally
+ {
+ this.Lock.ExitReadLock();
+ }
+ }
+
+ /// Acquire a write lock which prevents concurrent reads or writes to the cache while it's open.
+ /// The action to perform.
+ private void WithWriteLock(Action action)
+ {
+ try
+ {
+ this.Lock.EnterWriteLock();
+ action();
+ }
+ finally
+ {
+ this.Lock.ExitWriteLock();
+ }
+ }
+
+ /// Acquire a write lock which prevents concurrent reads or writes to the cache while it's open.
+ /// The action's return value.
+ /// The action to perform.
+ private T WithWriteLock(Func 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 ca19d726..c62c1393 100644
--- a/src/SMAPI/Framework/SGame.cs
+++ b/src/SMAPI/Framework/SGame.cs
@@ -179,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(typeof(Game1), "_temporaryContent").SetValue(new ContentManagerShim(this.SContentManager, "Game1._temporaryContent")); // regenerate value with new content manager
diff --git a/src/SMAPI/StardewModdingAPI.csproj b/src/SMAPI/StardewModdingAPI.csproj
index 6f7c2b3f..605292b2 100644
--- a/src/SMAPI/StardewModdingAPI.csproj
+++ b/src/SMAPI/StardewModdingAPI.csproj
@@ -89,6 +89,7 @@
Properties\GlobalAssemblyInfo.cs
+