summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/release-notes.md2
-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
12 files changed, 968 insertions, 709 deletions
diff --git a/docs/release-notes.md b/docs/release-notes.md
index 118cc441..b053789d 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -32,6 +32,8 @@
* Fixed input suppression not working consistently for clicks.
* Fixed console command input not saved to the log.
* Fixed `helper.ModRegistry.GetApi` interface validation errors not mentioning which interface caused the issue.
+ * Fixed mods able to intercept other mods' assets via the internal asset keys.
+ * Fixed mods able to indirectly change other mods' data through shared content caches.
* **Breaking changes** (see [migration guide](https://stardewvalleywiki.com/Modding:Migrate_to_Stardew_Valley_1.3)):
* Dropped some deprecated APIs.
* `LocationEvents` have been rewritten (see above).
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);
+ asset = GetNewData(prevAsset);
+ }
+ }
+
+ // return result
+ return asset;
+ }
+
+ /// <summary>Get all registered interceptors from a list.</summary>
+ private IEnumerable<KeyValuePair<IModMetadata, T>> GetInterceptors<T>(IDictionary<IModMetadata, IList<T>> entries)
+ {
+ foreach (var entry in entries)
+ {
+ IModMetadata mod = entry.Key;
+ IList<T> interceptors = entry.Value;
+
+ // registered editors
+ foreach (T interceptor in interceptors)
+ yield return new KeyValuePair<IModMetadata, T>(mod, interceptor);
+ }
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/ContentManagers/IContentManager.cs b/src/SMAPI/Framework/ContentManagers/IContentManager.cs
new file mode 100644
index 00000000..aa5be9b6
--- /dev/null
+++ b/src/SMAPI/Framework/ContentManagers/IContentManager.cs
@@ -0,0 +1,81 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Diagnostics.Contracts;
+using Microsoft.Xna.Framework.Content;
+using StardewModdingAPI.Framework.Exceptions;
+using StardewValley;
+
+namespace StardewModdingAPI.Framework.ContentManagers
+{
+ /// <summary>A content manager which handles reading files.</summary>
+ internal interface IContentManager : IDisposable
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>A name for the mod manager. Not guaranteed to be unique.</summary>
+ string Name { get; }
+
+ /// <summary>The current language as a constant.</summary>
+ LocalizedContentManager.LanguageCode Language { get; }
+
+ /// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary>
+ string FullRootDirectory { get; }
+
+ /// <summary>Whether this content manager is for a mod folder.</summary>
+ bool IsModContentManager { get; }
+
+
+ /*********
+ ** Methods
+ *********/
+ /// <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>
+ T Load<T>(string 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>
+ /// <param name="language">The language code for which to load content.</param>
+ T Load<T>(string assetName, LocalizedContentManager.LanguageCode language);
+
+ /// <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>
+ void Inject<T>(string assetName, T 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]
+ string NormalisePathSeparators(string 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.")]
+ string AssertAndNormaliseAssetName(string assetName);
+
+ /// <summary>Get the current content locale.</summary>
+ string GetLocale();
+
+ /// <summary>The locale for a language.</summary>
+ /// <param name="language">The language.</param>
+ string GetLocale(LocalizedContentManager.LanguageCode 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>
+ bool IsLoaded(string assetName);
+
+ /// <summary>Get the cached asset keys.</summary>
+ IEnumerable<string> GetAssetKeys();
+
+ /// <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>
+ IEnumerable<string> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false);
+ }
+}
diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
new file mode 100644
index 00000000..80bf37e9
--- /dev/null
+++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
@@ -0,0 +1,207 @@
+using System;
+using System.Globalization;
+using System.IO;
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Graphics;
+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 class ModContentManager : BaseContentManager
+ {
+ /*********
+ ** 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 ModContentManager(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: true) { }
+
+ /// <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))
+ {
+ if (contentManagerID != this.Name)
+ {
+ T data = this.Coordinator.LoadAndCloneManagedAsset<T>(assetName, contentManagerID, relativePath, language);
+ this.Inject(assetName, data);
+ return data;
+ }
+
+ return this.LoadManagedAsset<T>(assetName, contentManagerID, relativePath, language);
+ }
+
+ throw new NotSupportedException("Can't load content folder asset from a mod content manager.");
+ }
+
+ /// <summary>Create a new content manager for temporary use.</summary>
+ public override LocalizedContentManager CreateTemporary()
+ {
+ throw new NotSupportedException("Can't create a temporary mod content manager.");
+ }
+
+
+ /*********
+ ** 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)
+ {
+ return this.Cache.ContainsKey(normalisedAssetName);
+ }
+
+ /// <summary>Load a managed mod asset.</summary>
+ /// <typeparam name="T">The type of asset to load.</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 relative path within the mod folder.</param>
+ /// <param name="language">The language code for which to load content.</param>
+ private T LoadManagedAsset<T>(string internalKey, string contentManagerID, string relativePath, LanguageCode language)
+ {
+ SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading asset '{relativePath}' from {contentManagerID}: {reasonPhrase}");
+ try
+ {
+ // get file
+ FileInfo file = this.GetModFile(relativePath);
+ if (!file.Exists)
+ throw GetContentError("the specified path doesn't exist.");
+
+ // load content
+ switch (file.Extension.ToLower())
+ {
+ // XNB file
+ case ".xnb":
+ return base.Load<T>(relativePath, language);
+
+ // unpacked map
+ case ".tbin":
+ throw GetContentError($"can't read unpacked map file 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.Inject(internalKey, texture);
+ 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))
+ {
+ if (ex.GetInnermostException() is DllNotFoundException dllEx && dllEx.Message == "libgdiplus.dylib")
+ throw GetContentError("couldn't find libgdiplus, which is needed to load mod images. Make sure Mono is installed and you're running the game through the normal launcher.");
+ throw new SContentLoadException($"The content manager failed loading content asset '{relativePath}' from {contentManagerID}.", ex);
+ }
+ }
+
+ /// <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>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/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs
index 4a71f7e7..ce26c980 100644
--- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs
@@ -7,6 +7,7 @@ using System.IO;
using System.Linq;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;
+using StardewModdingAPI.Framework.ContentManagers;
using StardewModdingAPI.Framework.Exceptions;
using StardewModdingAPI.Framework.Utilities;
using StardewValley;
@@ -25,8 +26,11 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <summary>SMAPI's core content logic.</summary>
private readonly ContentCoordinator ContentCore;
- /// <summary>The content manager for this mod.</summary>
- private readonly SContentManager ContentManager;
+ /// <summary>A content manager for this mod which manages files from the game's Content folder.</summary>
+ private readonly IContentManager GameContentManager;
+
+ /// <summary>A content manager for this mod which manages files from the mod's folder.</summary>
+ private readonly IContentManager ModContentManager;
/// <summary>The absolute path to the mod folder.</summary>
private readonly string ModFolderPath;
@@ -42,10 +46,10 @@ namespace StardewModdingAPI.Framework.ModHelpers
** Accessors
*********/
/// <summary>The game's current locale code (like <c>pt-BR</c>).</summary>
- public string CurrentLocale => this.ContentManager.GetLocale();
+ public string CurrentLocale => this.GameContentManager.GetLocale();
/// <summary>The game's current locale as an enum value.</summary>
- public LocalizedContentManager.LanguageCode CurrentLocaleConstant => this.ContentManager.Language;
+ public LocalizedContentManager.LanguageCode CurrentLocaleConstant => this.GameContentManager.Language;
/// <summary>The observable implementation of <see cref="AssetEditors"/>.</summary>
internal ObservableCollection<IAssetEditor> ObservableAssetEditors { get; } = new ObservableCollection<IAssetEditor>();
@@ -65,16 +69,16 @@ namespace StardewModdingAPI.Framework.ModHelpers
*********/
/// <summary>Construct an instance.</summary>
/// <param name="contentCore">SMAPI's core content logic.</param>
- /// <param name="contentManager">The content manager for this mod.</param>
/// <param name="modFolderPath">The absolute path to the mod folder.</param>
/// <param name="modID">The unique ID of the relevant mod.</param>
/// <param name="modName">The friendly mod name for use in errors.</param>
/// <param name="monitor">Encapsulates monitoring and logging.</param>
- public ContentHelper(ContentCoordinator contentCore, SContentManager contentManager, string modFolderPath, string modID, string modName, IMonitor monitor)
+ public ContentHelper(ContentCoordinator contentCore, string modFolderPath, string modID, string modName, IMonitor monitor)
: base(modID)
{
this.ContentCore = contentCore;
- this.ContentManager = contentManager;
+ this.GameContentManager = contentCore.CreateGameContentManager(this.ContentCore.GetManagedAssetPrefix(modID) + ".content");
+ this.ModContentManager = contentCore.CreateModContentManager(this.ContentCore.GetManagedAssetPrefix(modID), rootDirectory: modFolderPath);
this.ModFolderPath = modFolderPath;
this.ModName = modName;
this.Monitor = monitor;
@@ -92,24 +96,22 @@ namespace StardewModdingAPI.Framework.ModHelpers
try
{
- this.AssertValidAssetKeyFormat(key);
+ this.AssertAndNormaliseAssetName(key);
switch (source)
{
case ContentSource.GameContent:
- return this.ContentCore.MainContentManager.Load<T>(key);
+ return this.GameContentManager.Load<T>(key);
case ContentSource.ModFolder:
// get file
FileInfo file = this.GetModFile(key);
if (!file.Exists)
throw GetContentError($"there's no matching file at path '{file.FullName}'.");
-
- // get asset path
- string assetName = this.ContentManager.GetAssetNameFromFilePath(file.FullName, ContentSource.ModFolder);
+ string internalKey = this.GetInternalModAssetKey(file);
// try cache
- if (this.ContentManager.IsLoaded(assetName))
- return this.ContentManager.Load<T>(assetName);
+ if (this.ModContentManager.IsLoaded(internalKey))
+ return this.ModContentManager.Load<T>(internalKey);
// fix map tilesheets
if (file.Extension.ToLower() == ".tbin")
@@ -121,15 +123,15 @@ namespace StardewModdingAPI.Framework.ModHelpers
// fetch & cache
FormatManager formatManager = FormatManager.Instance;
Map map = formatManager.LoadMap(file.FullName);
- this.FixCustomTilesheetPaths(map, key);
+ this.FixCustomTilesheetPaths(map, relativeMapPath: key);
// inject map
- this.ContentManager.Inject(assetName, map);
+ this.ModContentManager.Inject(internalKey, map);
return (T)(object)map;
}
// load through content manager
- return this.ContentManager.Load<T>(assetName);
+ return this.ModContentManager.Load<T>(internalKey);
default:
throw GetContentError($"unknown content source '{source}'.");
@@ -146,7 +148,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
[Pure]
public string NormaliseAssetName(string assetName)
{
- return this.ContentManager.NormaliseAssetName(assetName);
+ return this.ModContentManager.AssertAndNormaliseAssetName(assetName);
}
/// <summary>Get the underlying key in the game's content cache for an asset. This can be used to load custom map tilesheets, but should be avoided when you can use the content API instead. This does not validate whether the asset exists.</summary>
@@ -158,11 +160,11 @@ namespace StardewModdingAPI.Framework.ModHelpers
switch (source)
{
case ContentSource.GameContent:
- return this.ContentManager.NormaliseAssetName(key);
+ return this.GameContentManager.AssertAndNormaliseAssetName(key);
case ContentSource.ModFolder:
FileInfo file = this.GetModFile(key);
- return this.ContentManager.NormaliseAssetName(this.ContentManager.GetAssetNameFromFilePath(file.FullName, ContentSource.GameContent));
+ return this.GetInternalModAssetKey(file);
default:
throw new NotSupportedException($"Unknown content source '{source}'.");
@@ -205,16 +207,24 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <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)
+ private void AssertAndNormaliseAssetName(string key)
{
- this.ContentManager.AssertValidAssetKeyFormat(key);
+ this.ModContentManager.AssertAndNormaliseAssetName(key);
if (Path.IsPathRooted(key))
throw new ArgumentException("The asset key must not be an absolute path.");
}
+ /// <summary>Get the internal key in the content cache for a mod asset.</summary>
+ /// <param name="modFile">The asset file.</param>
+ private string GetInternalModAssetKey(FileInfo modFile)
+ {
+ string relativePath = PathUtilities.GetRelativePath(this.ModFolderPath, modFile.FullName);
+ return Path.Combine(this.ModContentManager.Name, relativePath);
+ }
+
/// <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>
+ /// <param name="relativeMapPath">The relative map path within the mod folder.</param>
/// <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
@@ -230,13 +240,13 @@ namespace StardewModdingAPI.Framework.ModHelpers
///
/// While that doesn't exactly match the game logic, it's close enough that it's unlikely to make a difference.
/// </remarks>
- private void FixCustomTilesheetPaths(Map map, string mapKey)
+ private void FixCustomTilesheetPaths(Map map, string relativeMapPath)
{
// get map info
if (!map.TileSheets.Any())
return;
- mapKey = this.ContentManager.NormaliseAssetName(mapKey); // Mono's Path.GetDirectoryName doesn't handle Windows dir separators
- string relativeMapFolder = Path.GetDirectoryName(mapKey) ?? ""; // folder path containing the map, relative to the mod folder
+ relativeMapPath = this.ModContentManager.AssertAndNormaliseAssetName(relativeMapPath); // Mono's Path.GetDirectoryName doesn't handle Windows dir separators
+ string relativeMapFolder = Path.GetDirectoryName(relativeMapPath) ?? ""; // folder path containing the map, relative to the mod folder
// fix tilesheets
foreach (TileSheet tilesheet in map.TileSheets)
@@ -341,7 +351,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
private FileInfo GetModFile(string path)
{
// try exact match
- path = Path.Combine(this.ModFolderPath, this.ContentManager.NormalisePathSeparators(path));
+ path = Path.Combine(this.ModFolderPath, this.ModContentManager.NormalisePathSeparators(path));
FileInfo file = new FileInfo(path);
// try with default extension
@@ -360,7 +370,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
private FileInfo GetContentFolderFile(string key)
{
// get file path
- string path = Path.Combine(this.ContentManager.FullRootDirectory, key);
+ string path = Path.Combine(this.GameContentManager.FullRootDirectory, key);
if (!path.EndsWith(".xnb"))
path += ".xnb";
diff --git a/src/SMAPI/Framework/SContentManager.cs b/src/SMAPI/Framework/SContentManager.cs
deleted file mode 100644
index e97e655d..00000000
--- a/src/SMAPI/Framework/SContentManager.cs
+++ /dev/null
@@ -1,653 +0,0 @@
-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;
-using Microsoft.Xna.Framework.Content;
-using Microsoft.Xna.Framework.Graphics;
-using StardewModdingAPI.Framework.Content;
-using StardewModdingAPI.Framework.Exceptions;
-using StardewModdingAPI.Framework.Reflection;
-using StardewModdingAPI.Framework.Utilities;
-using StardewValley;
-
-namespace StardewModdingAPI.Framework
-{
- /// <summary>A minimal content manager which defers to SMAPI's core content logic.</summary>
- internal class SContentManager : LocalizedContentManager
- {
- /*********
- ** Properties
- *********/
- /// <summary>The central coordinator which manages content managers.</summary>
- private readonly ContentCoordinator Coordinator;
-
- /// <summary>The underlying asset cache.</summary>
- private readonly ContentCache Cache;
-
- /// <summary>Encapsulates monitoring and logging.</summary>
- private readonly IMonitor Monitor;
-
- /// <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;
-
- /// <summary>The language enum values indexed by locale code.</summary>
- private readonly IDictionary<string, LocalizedContentManager.LanguageCode> LanguageCodes;
-
- /// <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>The path prefix for assets in mod folders.</summary>
- private readonly string ModContentPrefix;
-
- /// <summary>A callback to invoke when the content manager is being disposed.</summary>
- private readonly Action<SContentManager> OnDisposing;
-
- /// <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>Whether the content coordinator has been disposed.</summary>
- private bool IsDisposed;
-
-
- /*********
- ** Accessors
- *********/
- /// <summary>A name for the mod manager. Not guaranteed to be unique.</summary>
- public string Name { get; }
-
- /// <summary>Whether this content manager is wrapped around a mod folder.</summary>
- public bool IsModFolder { get; }
-
- /// <summary>The current language as a constant.</summary>
- public LocalizedContentManager.LanguageCode Language => this.GetCurrentLanguage();
-
- /// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary>
- public string FullRootDirectory => Path.Combine(Constants.ExecutionPath, this.RootDirectory);
-
-
- /*********
- ** 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="isModFolder">Whether this content manager is wrapped around a mod folder.</param>
- /// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param>
- public SContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<SContentManager> onDisposing, bool isModFolder)
- : base(serviceProvider, rootDirectory, currentCulture)
- {
- // init
- this.Name = name;
- this.IsModFolder = isModFolder;
- this.Coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator));
- this.Cache = new ContentCache(this, reflection);
- this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
- this.ModContentPrefix = this.GetAssetNameFromFilePath(Constants.ModPath, ContentSource.GameContent);
- this.OnDisposing = onDisposing;
-
- // get asset data
- this.LanguageCodes = this.GetKeyLocales().ToDictionary(p => p.Value, p => p.Key, StringComparer.InvariantCultureIgnoreCase);
- 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>
- public override T Load<T>(string assetName)
- {
- return this.Load<T>(assetName, LocalizedContentManager.CurrentLanguageCode);
- }
-
- /// <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)
- {
- // normalise asset key
- this.AssertValidAssetKeyFormat(assetName);
- assetName = this.NormaliseAssetName(assetName);
-
- // load game content
- if (!this.IsModFolder && !assetName.StartsWith(this.ModContentPrefix))
- return this.LoadImpl<T>(assetName, language);
-
- // load mod content
- SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading content asset '{assetName}': {reasonPhrase}");
- try
- {
- // try cache
- if (this.IsLoaded(assetName))
- return this.LoadImpl<T>(assetName, language);
-
- // 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, language);
-
- // 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.Inject(assetName, texture);
- 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))
- {
- if (ex.GetInnermostException() is DllNotFoundException dllEx && dllEx.Message == "libgdiplus.dylib")
- throw GetContentError("couldn't find libgdiplus, which is needed to load mod images. Make sure Mono is installed and you're running the game through the normal launcher.");
- throw new SContentLoadException($"The content manager failed loading content asset '{assetName}'.", ex);
- }
- }
-
- /// <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.NormaliseAssetName(assetName);
- this.Cache[assetName] = value;
- }
-
- /// <summary>Create a new content manager for temporary use.</summary>
- public override LocalizedContentManager CreateTemporary()
- {
- return this.Coordinator.CreateContentManager("(temporary)", isModFolder: false);
- }
-
- /// <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)
- {
- 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)
- {
- 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="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 void AssertValidAssetKeyFormat(string key)
- {
- // NOTE: the game checks for ContentLoadException to handle invalid keys, so avoid
- // throwing other types like ArgumentException here.
- if (string.IsNullOrWhiteSpace(key))
- throw new SContentLoadException("The asset key or local path is empty.");
- if (key.Intersect(Path.GetInvalidPathChars()).Any())
- throw new SContentLoadException("The asset key or local path contains invalid characters.");
- }
-
- /// <summary>Convert an absolute file path into an appropriate asset name.</summary>
- /// <param name="absolutePath">The absolute path to the file.</param>
- /// <param name="relativeTo">The folder to which to get a relative path.</param>
- public string GetAssetNameFromFilePath(string absolutePath, ContentSource relativeTo)
- {
-#if SMAPI_FOR_WINDOWS
- // XNA doesn't allow absolute asset paths, so get a path relative to the source folder
- string sourcePath = relativeTo == ContentSource.GameContent ? this.Coordinator.FullRootDirectory : this.FullRootDirectory;
- return this.GetRelativePath(sourcePath, absolutePath);
-#else
- // MonoGame is weird about relative paths on Mac, but allows absolute paths
- return absolutePath;
-#endif
- }
-
- /****
- ** 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(LocalizedContentManager.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
- *********/
- /****
- ** Asset name/key handling
- ****/
- /// <summary>Get a directory or file path relative to the content root.</summary>
- /// <param name="sourcePath">The source file path.</param>
- /// <param name="targetPath">The target file path.</param>
- private string GetRelativePath(string sourcePath, string targetPath)
- {
- return PathUtilities.GetRelativePath(sourcePath, targetPath);
- }
-
- /// <summary>Get the locale codes (like <c>ja-JP</c>) used in asset keys.</summary>
- private IDictionary<LocalizedContentManager.LanguageCode, string> GetKeyLocales()
- {
- // create locale => code map
- IDictionary<LocalizedContentManager.LanguageCode, string> map = new Dictionary<LocalizedContentManager.LanguageCode, string>();
- foreach (LocalizedContentManager.LanguageCode code in Enum.GetValues(typeof(LocalizedContentManager.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>
- private 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;
- }
-
- /****
- ** 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)
- {
- // default English
- if (this.Language == LocalizedContentManager.LanguageCode.en)
- 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;
- }
-
- /****
- ** 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="language">The language code for which to load content.</param>
- private T LoadImpl<T>(string assetName, LocalizedContentManager.LanguageCode language)
- {
- // skip if already loaded
- if (this.IsNormalisedKeyLoaded(assetName))
- return base.Load<T>(assetName, language);
-
- // 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.NormaliseAssetName);
- IAssetData asset =
- this.ApplyLoader<T>(info)
- ?? new AssetDataForObject(info, base.Load<T>(assetName, language), this.NormaliseAssetName);
- asset = this.ApplyEditors<T>(info, asset);
- return (T)asset.Data;
- });
- }
-
- // update cache & return data
- this.Inject(assetName, data);
- return data;
- }
-
- /// <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>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.NormaliseAssetName);
- }
-
- /// <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.NormaliseAssetName);
-
- // 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);
- asset = GetNewData(prevAsset);
- }
- }
-
- // return result
- return asset;
- }
-
- /// <summary>Get all registered interceptors from a list.</summary>
- private IEnumerable<KeyValuePair<IModMetadata, T>> GetInterceptors<T>(IDictionary<IModMetadata, IList<T>> entries)
- {
- foreach (var entry in entries)
- {
- IModMetadata mod = entry.Key;
- IList<T> interceptors = entry.Value;
-
- // registered editors
- foreach (T interceptor in interceptors)
- yield return new KeyValuePair<IModMetadata, T>(mod, interceptor);
- }
- }
-
- /// <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/SGame.cs b/src/SMAPI/Framework/SGame.cs
index 8a4f987b..91612fb0 100644
--- a/src/SMAPI/Framework/SGame.cs
+++ b/src/SMAPI/Framework/SGame.cs
@@ -202,7 +202,7 @@ namespace StardewModdingAPI.Framework
this.ContentCore = new ContentCoordinator(serviceProvider, rootDirectory, Thread.CurrentThread.CurrentUICulture, SGame.MonitorDuringInitialisation, SGame.ReflectorDuringInitialisation);
SGame.MonitorDuringInitialisation = null;
this.NextContentManagerIsMain = true;
- return this.ContentCore.CreateContentManager("Game1._temporaryContent", isModFolder: false);
+ return this.ContentCore.CreateGameContentManager("Game1._temporaryContent");
}
// Game1.content initialising from LoadContent
@@ -213,7 +213,7 @@ namespace StardewModdingAPI.Framework
}
// any other content manager
- return this.ContentCore.CreateContentManager("(generated)", isModFolder: false, rootDirectory: rootDirectory);
+ return this.ContentCore.CreateGameContentManager("(generated)");
}
/// <summary>The method called when the game is updating its state. This happens roughly 60 times per second.</summary>
diff --git a/src/SMAPI/Framework/Utilities/PathUtilities.cs b/src/SMAPI/Framework/Utilities/PathUtilities.cs
index 0233d796..51d45ebd 100644
--- a/src/SMAPI/Framework/Utilities/PathUtilities.cs
+++ b/src/SMAPI/Framework/Utilities/PathUtilities.cs
@@ -23,9 +23,12 @@ namespace StardewModdingAPI.Framework.Utilities
*********/
/// <summary>Get the segments from a path (e.g. <c>/usr/bin/boop</c> => <c>usr</c>, <c>bin</c>, and <c>boop</c>).</summary>
/// <param name="path">The path to split.</param>
- public static string[] GetSegments(string path)
+ /// <param name="limit">The number of segments to match. Any additional segments will be merged into the last returned part.</param>
+ public static string[] GetSegments(string path, int? limit = null)
{
- return path.Split(PathUtilities.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries);
+ return limit.HasValue
+ ? path.Split(PathUtilities.PossiblePathSeparators, limit.Value, StringSplitOptions.RemoveEmptyEntries)
+ : path.Split(PathUtilities.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries);
}
/// <summary>Normalise path separators in a file path.</summary>
diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs
index 6aff6dc6..340d2ddb 100644
--- a/src/SMAPI/Program.cs
+++ b/src/SMAPI/Program.cs
@@ -755,8 +755,7 @@ namespace StardewModdingAPI
// load mod as content pack
IManifest manifest = metadata.Manifest;
IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName);
- SContentManager contentManager = this.ContentCore.CreateContentManager($"Mods.{metadata.Manifest.UniqueID}", isModFolder: true, rootDirectory: metadata.DirectoryPath);
- IContentHelper contentHelper = new ContentHelper(this.ContentCore, contentManager, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor);
+ IContentHelper contentHelper = new ContentHelper(this.ContentCore, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor);
IContentPack contentPack = new ContentPack(metadata.DirectoryPath, manifest, contentHelper, jsonHelper);
metadata.SetMod(contentPack, monitor);
this.ModRegistry.Add(metadata);
@@ -844,8 +843,7 @@ namespace StardewModdingAPI
IModHelper modHelper;
{
ICommandHelper commandHelper = new CommandHelper(manifest.UniqueID, metadata.DisplayName, this.CommandManager);
- SContentManager contentManager = this.ContentCore.CreateContentManager($"Mods.{metadata.Manifest.UniqueID}", isModFolder: true, rootDirectory: metadata.DirectoryPath);
- IContentHelper contentHelper = new ContentHelper(contentCore, contentManager, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor);
+ IContentHelper contentHelper = new ContentHelper(contentCore, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor);
IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, metadata.DisplayName, this.Reflection, this.DeprecationManager);
IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry, proxyFactory, monitor);
IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(manifest.UniqueID, this.GameInstance.Multiplayer);
@@ -854,8 +852,7 @@ namespace StardewModdingAPI
IContentPack CreateTransitionalContentPack(string packDirPath, IManifest packManifest)
{
IMonitor packMonitor = this.GetSecondaryMonitor(packManifest.Name);
- SContentManager packContentManager = this.ContentCore.CreateContentManager($"Mods.{packManifest.UniqueID}", isModFolder: true, rootDirectory: packDirPath);
- IContentHelper packContentHelper = new ContentHelper(contentCore, packContentManager, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor);
+ IContentHelper packContentHelper = new ContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor);
return new ContentPack(packDirPath, packManifest, packContentHelper, this.JsonHelper);
}
diff --git a/src/SMAPI/StardewModdingAPI.csproj b/src/SMAPI/StardewModdingAPI.csproj
index 19c1e6fe..e9e0ea54 100644
--- a/src/SMAPI/StardewModdingAPI.csproj
+++ b/src/SMAPI/StardewModdingAPI.csproj
@@ -87,6 +87,10 @@
</Compile>
<Compile Include="Events\EventArgsLocationBuildingsChanged.cs" />
<Compile Include="Events\MultiplayerEvents.cs" />
+ <Compile Include="Framework\ContentManagers\BaseContentManager.cs" />
+ <Compile Include="Framework\ContentManagers\GameContentManager.cs" />
+ <Compile Include="Framework\ContentManagers\IContentManager.cs" />
+ <Compile Include="Framework\ContentManagers\ModContentManager.cs" />
<Compile Include="Framework\Events\EventManager.cs" />
<Compile Include="Framework\Events\ManagedEvent.cs" />
<Compile Include="Events\SpecialisedEvents.cs" />
@@ -123,7 +127,6 @@
<Compile Include="Framework\ModLoading\Rewriters\VirtualEntryCallRemover.cs" />
<Compile Include="Framework\ModLoading\Rewriters\MethodParentRewriter.cs" />
<Compile Include="Framework\ModLoading\Rewriters\TypeReferenceRewriter.cs" />
- <Compile Include="Framework\SContentManager.cs" />
<Compile Include="Framework\Exceptions\SAssemblyLoadFailedException.cs" />
<Compile Include="Framework\ModLoading\AssemblyLoadStatus.cs" />
<Compile Include="Framework\Reflection\InterfaceProxyBuilder.cs" />