summaryrefslogtreecommitdiff
path: root/src/SMAPI
diff options
context:
space:
mode:
authorJesse Plamondon-Willard <github@jplamondonw.com>2018-05-22 22:53:44 -0400
committerJesse Plamondon-Willard <github@jplamondonw.com>2018-05-22 22:53:44 -0400
commitbd04d46dd1d66b30d4f21575bbbd2e541eabcef3 (patch)
treed40fac21157dc7893e3763b34505079171163e5d /src/SMAPI
parent3e36af75d59dbd539cfb2c304281f64f1753fc9f (diff)
downloadSMAPI-bd04d46dd1d66b30d4f21575bbbd2e541eabcef3.tar.gz
SMAPI-bd04d46dd1d66b30d4f21575bbbd2e541eabcef3.tar.bz2
SMAPI-bd04d46dd1d66b30d4f21575bbbd2e541eabcef3.zip
refactor content API to fix load errors with decentralised cache (#524)
Diffstat (limited to 'src/SMAPI')
-rw-r--r--src/SMAPI/Framework/ContentCoordinator.cs123
-rw-r--r--src/SMAPI/Framework/ContentManagers/BaseContentManager.cs268
-rw-r--r--src/SMAPI/Framework/ContentManagers/GameContentManager.cs252
-rw-r--r--src/SMAPI/Framework/ContentManagers/IContentManager.cs81
-rw-r--r--src/SMAPI/Framework/ContentManagers/ModContentManager.cs207
-rw-r--r--src/SMAPI/Framework/ModHelpers/ContentHelper.cs66
-rw-r--r--src/SMAPI/Framework/SContentManager.cs653
-rw-r--r--src/SMAPI/Framework/SGame.cs4
-rw-r--r--src/SMAPI/Framework/Utilities/PathUtilities.cs7
-rw-r--r--src/SMAPI/Program.cs9
-rw-r--r--src/SMAPI/StardewModdingAPI.csproj5
11 files changed, 966 insertions, 709 deletions
diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs
index 397a9d90..c2614001 100644
--- a/src/SMAPI/Framework/ContentCoordinator.cs
+++ b/src/SMAPI/Framework/ContentCoordinator.cs
@@ -5,10 +5,14 @@ using System.IO;
using System.Linq;
using System.Reflection;
using Microsoft.Xna.Framework.Content;
+using Microsoft.Xna.Framework.Graphics;
using StardewModdingAPI.Framework.Content;
+using StardewModdingAPI.Framework.ContentManagers;
using StardewModdingAPI.Framework.Reflection;
+using StardewModdingAPI.Framework.Utilities;
using StardewModdingAPI.Metadata;
using StardewValley;
+using xTile;
namespace StardewModdingAPI.Framework
{
@@ -18,6 +22,9 @@ namespace StardewModdingAPI.Framework
/*********
** Properties
*********/
+ /// <summary>An asset key prefix for assets from SMAPI mod folders.</summary>
+ private readonly string ManagedPrefix = "SMAPI";
+
/// <summary>Encapsulates monitoring and logging.</summary>
private readonly IMonitor Monitor;
@@ -28,7 +35,7 @@ namespace StardewModdingAPI.Framework
private readonly Reflector Reflection;
/// <summary>The loaded content managers (including the <see cref="MainContentManager"/>).</summary>
- private readonly IList<SContentManager> ContentManagers = new List<SContentManager>();
+ private readonly IList<IContentManager> ContentManagers = new List<IContentManager>();
/// <summary>Whether the content coordinator has been disposed.</summary>
private bool IsDisposed;
@@ -38,7 +45,7 @@ namespace StardewModdingAPI.Framework
** Accessors
*********/
/// <summary>The primary content manager used for most assets.</summary>
- public SContentManager MainContentManager { get; private set; }
+ public GameContentManager MainContentManager { get; private set; }
/// <summary>The current language as a constant.</summary>
public LocalizedContentManager.LanguageCode Language => this.MainContentManager.Language;
@@ -68,28 +75,110 @@ namespace StardewModdingAPI.Framework
this.Reflection = reflection;
this.FullRootDirectory = Path.Combine(Constants.ExecutionPath, rootDirectory);
this.ContentManagers.Add(
- this.MainContentManager = new SContentManager("Game1.content", serviceProvider, rootDirectory, currentCulture, this, monitor, reflection, this.OnDisposing, isModFolder: false)
+ this.MainContentManager = new GameContentManager("Game1.content", serviceProvider, rootDirectory, currentCulture, this, monitor, reflection, this.OnDisposing)
);
- this.CoreAssets = new CoreAssetPropagator(this.MainContentManager.NormaliseAssetName, reflection);
+ this.CoreAssets = new CoreAssetPropagator(this.MainContentManager.AssertAndNormaliseAssetName, reflection);
+ }
+
+ /// <summary>Get a new content manager which handles reading files from the game content folder with support for interception.</summary>
+ /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param>
+ public GameContentManager CreateGameContentManager(string name)
+ {
+ GameContentManager manager = new GameContentManager(name, this.MainContentManager.ServiceProvider, this.MainContentManager.RootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.OnDisposing);
+ this.ContentManagers.Add(manager);
+ return manager;
}
- /// <summary>Get a new content manager which defers loading to the content core.</summary>
+ /// <summary>Get a new content manager which handles reading files from a SMAPI mod folder with support for unpacked files.</summary>
/// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param>
- /// <param name="isModFolder">Whether this content manager is wrapped around a mod folder.</param>
- /// <param name="rootDirectory">The root directory to search for content (or <c>null</c>. for the default)</param>
- public SContentManager CreateContentManager(string name, bool isModFolder, string rootDirectory = null)
+ /// <param name="rootDirectory">The root directory to search for content (or <c>null</c> for the default).</param>
+ public ModContentManager CreateModContentManager(string name, string rootDirectory)
{
- SContentManager manager = new SContentManager(name, this.MainContentManager.ServiceProvider, rootDirectory ?? this.MainContentManager.RootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.OnDisposing, isModFolder);
+ ModContentManager manager = new ModContentManager(name, this.MainContentManager.ServiceProvider, rootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.OnDisposing);
this.ContentManagers.Add(manager);
return manager;
}
/// <summary>Get the current content locale.</summary>
- public string GetLocale() => this.MainContentManager.GetLocale(LocalizedContentManager.CurrentLanguageCode);
+ public string GetLocale()
+ {
+ return this.MainContentManager.GetLocale(LocalizedContentManager.CurrentLanguageCode);
+ }
+
+ /// <summary>Get whether this asset is mapped to a mod folder.</summary>
+ /// <param name="key">The asset key.</param>
+ public bool IsManagedAssetKey(string key)
+ {
+ return key.StartsWith(this.ManagedPrefix);
+ }
+
+ /// <summary>Parse a managed SMAPI asset key which maps to a mod folder.</summary>
+ /// <param name="key">The asset key.</param>
+ /// <param name="contentManagerID">The unique name for the content manager which should load this asset.</param>
+ /// <param name="relativePath">The relative path within the mod folder.</param>
+ /// <returns>Returns whether the asset was parsed successfully.</returns>
+ public bool TryParseManagedAssetKey(string key, out string contentManagerID, out string relativePath)
+ {
+ contentManagerID = null;
+ relativePath = null;
+
+ // not a managed asset
+ if (!key.StartsWith(this.ManagedPrefix))
+ return false;
- /// <summary>Convert an absolute file path into a appropriate asset name.</summary>
- /// <param name="absolutePath">The absolute path to the file.</param>
- public string GetAssetNameFromFilePath(string absolutePath) => this.MainContentManager.GetAssetNameFromFilePath(absolutePath, ContentSource.GameContent);
+ // parse
+ string[] parts = PathUtilities.GetSegments(key, 3);
+ if (parts.Length != 3) // managed key prefix, mod id, relative path
+ return false;
+ contentManagerID = Path.Combine(parts[0], parts[1]);
+ relativePath = parts[2];
+ return true;
+ }
+
+ /// <summary>Get the managed asset key prefix for a mod.</summary>
+ /// <param name="modID">The mod's unique ID.</param>
+ public string GetManagedAssetPrefix(string modID)
+ {
+ return Path.Combine(this.ManagedPrefix, modID.ToLower());
+ }
+
+ /// <summary>Get a copy of an asset from a mod folder.</summary>
+ /// <typeparam name="T">The asset type.</typeparam>
+ /// <param name="internalKey">The internal asset key.</param>
+ /// <param name="contentManagerID">The unique name for the content manager which should load this asset.</param>
+ /// <param name="relativePath">The internal SMAPI asset key.</param>
+ /// <param name="language">The language code for which to load content.</param>
+ public T LoadAndCloneManagedAsset<T>(string internalKey, string contentManagerID, string relativePath, LocalizedContentManager.LanguageCode language)
+ {
+ // get content manager
+ IContentManager contentManager = this.ContentManagers.FirstOrDefault(p => p.Name == contentManagerID);
+ if (contentManager == null)
+ throw new InvalidOperationException($"The '{contentManagerID}' prefix isn't handled by any mod.");
+
+ // get cloned asset
+ T data = contentManager.Load<T>(internalKey, language);
+ switch (data as object)
+ {
+ case Texture2D source:
+ {
+ int[] pixels = new int[source.Width * source.Height];
+ source.GetData(pixels);
+
+ Texture2D clone = new Texture2D(source.GraphicsDevice, source.Width, source.Height);
+ clone.SetData(pixels);
+ return (T)(object)clone;
+ }
+
+ case Dictionary<string, string> source:
+ return (T)(object)new Dictionary<string, string>(source);
+
+ case Dictionary<int, string> source:
+ return (T)(object)new Dictionary<int, string>(source);
+
+ default:
+ return data;
+ }
+ }
/// <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>
@@ -129,7 +218,7 @@ namespace StardewModdingAPI.Framework
string locale = this.GetLocale();
return this.InvalidateCache((assetName, type) =>
{
- IAssetInfo info = new AssetInfo(locale, assetName, type, this.MainContentManager.NormaliseAssetName);
+ IAssetInfo info = new AssetInfo(locale, assetName, type, this.MainContentManager.AssertAndNormaliseAssetName);
return predicate(info);
});
}
@@ -142,7 +231,7 @@ namespace StardewModdingAPI.Framework
{
// invalidate cache
HashSet<string> removedAssetNames = new HashSet<string>();
- foreach (SContentManager contentManager in this.ContentManagers)
+ foreach (IContentManager contentManager in this.ContentManagers)
{
foreach (string name in contentManager.InvalidateCache(predicate, dispose))
removedAssetNames.Add(name);
@@ -172,7 +261,7 @@ namespace StardewModdingAPI.Framework
this.IsDisposed = true;
this.Monitor.Log("Disposing the content coordinator. Content managers will no longer be usable after this point.", LogLevel.Trace);
- foreach (SContentManager contentManager in this.ContentManagers)
+ foreach (IContentManager contentManager in this.ContentManagers)
contentManager.Dispose();
this.ContentManagers.Clear();
this.MainContentManager = null;
@@ -184,7 +273,7 @@ namespace StardewModdingAPI.Framework
*********/
/// <summary>A callback invoked when a content manager is disposed.</summary>
/// <param name="contentManager">The content manager being disposed.</param>
- private void OnDisposing(SContentManager contentManager)
+ private void OnDisposing(IContentManager contentManager)
{
if (this.IsDisposed)
return;
diff --git a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs
new file mode 100644
index 00000000..ff0e2de4
--- /dev/null
+++ b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs
@@ -0,0 +1,268 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Diagnostics.Contracts;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using Microsoft.Xna.Framework.Content;
+using StardewModdingAPI.Framework.Content;
+using StardewModdingAPI.Framework.Exceptions;
+using StardewModdingAPI.Framework.Reflection;
+using StardewValley;
+
+namespace StardewModdingAPI.Framework.ContentManagers
+{
+ /// <summary>A content manager which handles reading files from a SMAPI mod folder with support for unpacked files.</summary>
+ internal abstract class BaseContentManager : LocalizedContentManager, IContentManager
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The central coordinator which manages content managers.</summary>
+ protected readonly ContentCoordinator Coordinator;
+
+ /// <summary>The underlying asset cache.</summary>
+ protected readonly ContentCache Cache;
+
+ /// <summary>Encapsulates monitoring and logging.</summary>
+ protected readonly IMonitor Monitor;
+
+ /// <summary>Whether the content coordinator has been disposed.</summary>
+ private bool IsDisposed;
+
+ /// <summary>The language enum values indexed by locale code.</summary>
+ private readonly IDictionary<string, LanguageCode> LanguageCodes;
+
+ /// <summary>A callback to invoke when the content manager is being disposed.</summary>
+ private readonly Action<BaseContentManager> OnDisposing;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>A name for the mod manager. Not guaranteed to be unique.</summary>
+ public string Name { get; }
+
+ /// <summary>The current language as a constant.</summary>
+ public LanguageCode Language => this.GetCurrentLanguage();
+
+ /// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary>
+ public string FullRootDirectory => Path.Combine(Constants.ExecutionPath, this.RootDirectory);
+
+ /// <summary>Whether this content manager is for a mod folder.</summary>
+ public bool IsModContentManager { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param>
+ /// <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="coordinator">The central coordinator which manages content managers.</param>
+ /// <param name="monitor">Encapsulates monitoring and logging.</param>
+ /// <param name="reflection">Simplifies access to private code.</param>
+ /// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param>
+ /// <param name="isModFolder">Whether this content manager is for a mod folder.</param>
+ protected BaseContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing, bool isModFolder)
+ : base(serviceProvider, rootDirectory, currentCulture)
+ {
+ // init
+ this.Name = name;
+ this.Coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator));
+ this.Cache = new ContentCache(this, reflection);
+ this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
+ this.OnDisposing = onDisposing;
+ this.IsModContentManager = isModFolder;
+
+ // get asset data
+ this.LanguageCodes = this.GetKeyLocales().ToDictionary(p => p.Value, p => p.Key, StringComparer.InvariantCultureIgnoreCase);
+ }
+
+ /// <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>
+ public override T Load<T>(string assetName)
+ {
+ return this.Load<T>(assetName, LocalizedContentManager.CurrentLanguageCode);
+ }
+
+ /// <summary>Load the base asset without localisation.</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>
+ public override T LoadBase<T>(string assetName)
+ {
+ return this.Load<T>(assetName, LanguageCode.en);
+ }
+
+ /// <summary>Inject an asset into the cache.</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>
+ public void Inject<T>(string assetName, T value)
+ {
+ assetName = this.AssertAndNormaliseAssetName(assetName);
+ this.Cache[assetName] = value;
+
+ }
+
+ /// <summary>Normalise path separators in a file path. For asset keys, see <see cref="AssertAndNormaliseAssetName"/> instead.</summary>
+ /// <param name="path">The file path to normalise.</param>
+ [Pure]
+ public string NormalisePathSeparators(string path)
+ {
+ return this.Cache.NormalisePathSeparators(path);
+ }
+
+ /// <summary>Assert that the given key has a valid format and return a normalised form consistent with the underlying cache.</summary>
+ /// <param name="assetName">The asset key to check.</param>
+ /// <exception cref="SContentLoadException">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 string AssertAndNormaliseAssetName(string assetName)
+ {
+ // NOTE: the game checks for ContentLoadException to handle invalid keys, so avoid
+ // throwing other types like ArgumentException here.
+ if (string.IsNullOrWhiteSpace(assetName))
+ throw new SContentLoadException("The asset key or local path is empty.");
+ if (assetName.Intersect(Path.GetInvalidPathChars()).Any())
+ throw new SContentLoadException("The asset key or local path contains invalid characters.");
+
+ return this.Cache.NormaliseKey(assetName);
+ }
+
+ /****
+ ** Content loading
+ ****/
+ /// <summary>Get the current content locale.</summary>
+ public string GetLocale()
+ {
+ return this.GetLocale(this.GetCurrentLanguage());
+ }
+
+ /// <summary>The locale for a language.</summary>
+ /// <param name="language">The language.</param>
+ public string GetLocale(LanguageCode language)
+ {
+ return this.LanguageCodeString(language);
+ }
+
+ /// <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)
+ {
+ assetName = this.Cache.NormaliseKey(assetName);
+ return this.IsNormalisedKeyLoaded(assetName);
+ }
+
+ /// <summary>Get the cached asset keys.</summary>
+ public IEnumerable<string> GetAssetKeys()
+ {
+ return this.Cache.Keys
+ .Select(this.GetAssetName)
+ .Distinct();
+ }
+
+ /****
+ ** Cache invalidation
+ ****/
+ /// <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 number of invalidated assets.</returns>
+ public IEnumerable<string> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false)
+ {
+ HashSet<string> removeAssetNames = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
+ this.Cache.Remove((key, type) =>
+ {
+ this.ParseCacheKey(key, out string assetName, out _);
+
+ if (removeAssetNames.Contains(assetName) || predicate(assetName, type))
+ {
+ removeAssetNames.Add(assetName);
+ return true;
+ }
+ return false;
+ });
+
+ return removeAssetNames;
+ }
+
+ /// <summary>Dispose held resources.</summary>
+ /// <param name="isDisposing">Whether the content manager is being disposed (rather than finalized).</param>
+ protected override void Dispose(bool isDisposing)
+ {
+ if (this.IsDisposed)
+ return;
+ this.IsDisposed = true;
+
+ this.OnDisposing(this);
+ base.Dispose(isDisposing);
+ }
+
+ /// <inheritdoc />
+ public override void Unload()
+ {
+ if (this.IsDisposed)
+ return; // base logic doesn't allow unloading twice, which happens due to SMAPI and the game both unloading
+
+ base.Unload();
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get the locale codes (like <c>ja-JP</c>) used in asset keys.</summary>
+ private IDictionary<LanguageCode, string> GetKeyLocales()
+ {
+ // create locale => code map
+ IDictionary<LanguageCode, string> map = new Dictionary<LanguageCode, string>();
+ foreach (LanguageCode code in Enum.GetValues(typeof(LanguageCode)))
+ map[code] = this.GetLocale(code);
+
+ 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="assetName">The original asset name.</param>
+ /// <param name="localeCode">The asset locale code (or <c>null</c> if not localised).</param>
+ protected void ParseCacheKey(string cacheKey, out string assetName, out string localeCode)
+ {
+ // handle localised key
+ if (!string.IsNullOrWhiteSpace(cacheKey))
+ {
+ int lastSepIndex = cacheKey.LastIndexOf(".", StringComparison.InvariantCulture);
+ if (lastSepIndex >= 0)
+ {
+ string suffix = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1);
+ if (this.LanguageCodes.ContainsKey(suffix))
+ {
+ assetName = cacheKey.Substring(0, lastSepIndex);
+ localeCode = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1);
+ return;
+ }
+ }
+ }
+
+ // handle simple key
+ assetName = cacheKey;
+ localeCode = null;
+ }
+
+ /// <summary>Get whether an asset has already been loaded.</summary>
+ /// <param name="normalisedAssetName">The normalised asset name.</param>
+ protected abstract bool IsNormalisedKeyLoaded(string normalisedAssetName);
+ }
+}
diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
new file mode 100644
index 00000000..cfedb5af
--- /dev/null
+++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
@@ -0,0 +1,252 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using StardewModdingAPI.Framework.Content;
+using StardewModdingAPI.Framework.Reflection;
+using StardewModdingAPI.Framework.Utilities;
+using StardewValley;
+
+namespace StardewModdingAPI.Framework.ContentManagers
+{
+ /// <summary>A content manager which handles reading files from the game content folder with support for interception.</summary>
+ internal class GameContentManager : BaseContentManager
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The assets currently being intercepted by <see cref="IAssetLoader"/> instances. This is used to prevent infinite loops when a loader loads a new asset.</summary>
+ private readonly ContextHash<string> AssetsBeingLoaded = new ContextHash<string>();
+
+ /// <summary>Interceptors which provide the initial versions of matching assets.</summary>
+ private IDictionary<IModMetadata, IList<IAssetLoader>> Loaders => this.Coordinator.Loaders;
+
+ /// <summary>Interceptors which edit matching assets after they're loaded.</summary>
+ private IDictionary<IModMetadata, IList<IAssetEditor>> Editors => this.Coordinator.Editors;
+
+ /// <summary>A lookup which indicates whether the asset is localisable (i.e. the filename contains the locale), if previously loaded.</summary>
+ private readonly IDictionary<string, bool> IsLocalisableLookup;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param>
+ /// <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="coordinator">The central coordinator which manages content managers.</param>
+ /// <param name="monitor">Encapsulates monitoring and logging.</param>
+ /// <param name="reflection">Simplifies access to private code.</param>
+ /// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param>
+ public GameContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing)
+ : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isModFolder: false)
+ {
+ this.IsLocalisableLookup = reflection.GetField<IDictionary<string, bool>>(this, "_localizedAsset").GetValue();
+ }
+
+ /// <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>
+ /// <param name="language">The language code for which to load content.</param>
+ public override T Load<T>(string assetName, LanguageCode language)
+ {
+ assetName = this.AssertAndNormaliseAssetName(assetName);
+
+ // get from cache
+ if (this.IsLoaded(assetName))
+ return base.Load<T>(assetName, language);
+
+ // get managed asset
+ if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath))
+ {
+ T managedAsset = this.Coordinator.LoadAndCloneManagedAsset<T>(assetName, contentManagerID, relativePath, language);
+ this.Inject(assetName, managedAsset);
+ return managedAsset;
+ }
+
+ // 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, language);
+ }
+ else
+ {
+ data = this.AssetsBeingLoaded.Track(assetName, () =>
+ {
+ string locale = this.GetLocale(language);
+ IAssetInfo info = new AssetInfo(locale, assetName, typeof(T), this.AssertAndNormaliseAssetName);
+ IAssetData asset =
+ this.ApplyLoader<T>(info)
+ ?? new AssetDataForObject(info, base.Load<T>(assetName, language), this.AssertAndNormaliseAssetName);
+ asset = this.ApplyEditors<T>(info, asset);
+ return (T)asset.Data;
+ });
+ }
+
+ // update cache & return data
+ this.Inject(assetName, data);
+ return data;
+ }
+
+ /// <summary>Create a new content manager for temporary use.</summary>
+ public override LocalizedContentManager CreateTemporary()
+ {
+ return this.Coordinator.CreateGameContentManager("(temporary)");
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get whether an asset has already been loaded.</summary>
+ /// <param name="normalisedAssetName">The normalised asset name.</param>
+ protected override bool IsNormalisedKeyLoaded(string normalisedAssetName)
+ {
+ // default English
+ if (this.Language == LocalizedContentManager.LanguageCode.en || this.Coordinator.IsManagedAssetKey(normalisedAssetName))
+ return this.Cache.ContainsKey(normalisedAssetName);
+
+ // translated
+ string localeKey = $"{normalisedAssetName}.{this.GetLocale(this.GetCurrentLanguage())}";
+ if (this.IsLocalisableLookup.TryGetValue(localeKey, out bool localisable))
+ {
+ return localisable
+ ? this.Cache.ContainsKey(localeKey)
+ : this.Cache.ContainsKey(normalisedAssetName);
+ }
+
+ // not loaded yet
+ return false;
+ }
+
+ /// <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>
+ private IAssetData ApplyLoader<T>(IAssetInfo info)
+ {
+ // find matching loaders
+ var loaders = this.GetInterceptors(this.Loaders)
+ .Where(entry =>
+ {
+ try
+ {
+ return entry.Value.CanLoad<T>(info);
+ }
+ catch (Exception ex)
+ {
+ entry.Key.LogAsMod($"Mod failed when checking whether it could load asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
+ return false;
+ }
+ })
+ .ToArray();
+
+ // validate loaders
+ if (!loaders.Any())
+ return null;
+ if (loaders.Length > 1)
+ {
+ string[] loaderNames = loaders.Select(p => p.Key.DisplayName).ToArray();
+ this.Monitor.Log($"Multiple mods want to provide the '{info.AssetName}' asset ({string.Join(", ", loaderNames)}), but an asset can't be loaded multiple times. SMAPI will use the default asset instead; uninstall one of the mods to fix this. (Message for modders: you should usually use {typeof(IAssetEditor)} instead to avoid conflicts.)", LogLevel.Warn);
+ return null;
+ }
+
+ // fetch asset from loader
+ IModMetadata mod = loaders[0].Key;
+ IAssetLoader loader = loaders[0].Value;
+ T data;
+ try
+ {
+ data = loader.Load<T>(info);
+ this.Monitor.Log($"{mod.DisplayName} loaded asset '{info.AssetName}'.", LogLevel.Trace);
+ }
+ catch (Exception ex)
+ {
+ mod.LogAsMod($"Mod crashed when loading asset '{info.AssetName}'. SMAPI will use the default asset instead. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
+ return null;
+ }
+
+ // validate asset
+ if (data == null)
+ {
+ mod.LogAsMod($"Mod incorrectly set asset '{info.AssetName}' to a null value; ignoring override.", LogLevel.Error);
+ return null;
+ }
+
+ // return matched asset
+ return new AssetDataForObject(info, data, this.AssertAndNormaliseAssetName);
+ }
+
+ /// <summary>Apply any <see cref="Editors"/> to a loaded asset.</summary>
+ /// <typeparam name="T">The asset type.</typeparam>
+ /// <param name="info">The basic asset metadata.</param>
+ /// <param name="asset">The loaded asset.</param>
+ private IAssetData ApplyEditors<T>(IAssetInfo info, IAssetData asset)
+ {
+ IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.AssertAndNormaliseAssetName);
+
+ // edit asset
+ foreach (var entry in this.GetInterceptors(this.Editors))
+ {
+ // check for match
+ IModMetadata mod = entry.Key;
+ IAssetEditor editor = entry.Value;
+ try
+ {
+ if (!editor.CanEdit<T>(info))
+ continue;
+ }
+ catch (Exception ex)
+ {
+ mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
+ continue;
+ }
+
+ // try edit
+ object prevAsset = asset.Data;
+ try
+ {
+ editor.Edit<T>(asset);
+ this.Monitor.Log($"{mod.DisplayName} intercepted {info.AssetName}.", LogLevel.Trace);
+ }
+ catch (Exception ex)
+ {
+ mod.LogAsMod($"Mod crashed when editing asset '{info.AssetName}', which may cause errors in-game. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
+ }
+
+ // validate edit
+ if (asset.Data == null)
+ {
+ mod.LogAsMod($"Mod incorrectly set asset '{info.AssetName}' to a null value; ignoring override.", LogLevel.Warn);
+ asset = GetNewData(prevAsset);
+ }
+ else if (!(asset.Data is T))
+ {
+ mod.LogAsMod($"Mod incorrectly set asset '{asset.AssetName}' to incompatible type '{asset.Data.GetType()}', expected '{typeof(T)}'; ignoring override.", LogLevel.Warn);
+