refactor content API to fix load errors with decentralised cache (#524)

This commit is contained in:
Jesse Plamondon-Willard 2018-05-22 22:53:44 -04:00
parent 3e36af75d5
commit bd04d46dd1
12 changed files with 968 additions and 709 deletions

View File

@ -32,6 +32,8 @@
* Fixed input suppression not working consistently for clicks. * Fixed input suppression not working consistently for clicks.
* Fixed console command input not saved to the log. * Fixed console command input not saved to the log.
* Fixed `helper.ModRegistry.GetApi` interface validation errors not mentioning which interface caused the issue. * 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)): * **Breaking changes** (see [migration guide](https://stardewvalleywiki.com/Modding:Migrate_to_Stardew_Valley_1.3)):
* Dropped some deprecated APIs. * Dropped some deprecated APIs.
* `LocationEvents` have been rewritten (see above). * `LocationEvents` have been rewritten (see above).

View File

@ -5,10 +5,14 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;
using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.ContentManagers;
using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.Utilities;
using StardewModdingAPI.Metadata; using StardewModdingAPI.Metadata;
using StardewValley; using StardewValley;
using xTile;
namespace StardewModdingAPI.Framework namespace StardewModdingAPI.Framework
{ {
@ -18,6 +22,9 @@ namespace StardewModdingAPI.Framework
/********* /*********
** Properties ** Properties
*********/ *********/
/// <summary>An asset key prefix for assets from SMAPI mod folders.</summary>
private readonly string ManagedPrefix = "SMAPI";
/// <summary>Encapsulates monitoring and logging.</summary> /// <summary>Encapsulates monitoring and logging.</summary>
private readonly IMonitor Monitor; private readonly IMonitor Monitor;
@ -28,7 +35,7 @@ namespace StardewModdingAPI.Framework
private readonly Reflector Reflection; private readonly Reflector Reflection;
/// <summary>The loaded content managers (including the <see cref="MainContentManager"/>).</summary> /// <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> /// <summary>Whether the content coordinator has been disposed.</summary>
private bool IsDisposed; private bool IsDisposed;
@ -38,7 +45,7 @@ namespace StardewModdingAPI.Framework
** Accessors ** Accessors
*********/ *********/
/// <summary>The primary content manager used for most assets.</summary> /// <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> /// <summary>The current language as a constant.</summary>
public LocalizedContentManager.LanguageCode Language => this.MainContentManager.Language; public LocalizedContentManager.LanguageCode Language => this.MainContentManager.Language;
@ -68,28 +75,110 @@ namespace StardewModdingAPI.Framework
this.Reflection = reflection; this.Reflection = reflection;
this.FullRootDirectory = Path.Combine(Constants.ExecutionPath, rootDirectory); this.FullRootDirectory = Path.Combine(Constants.ExecutionPath, rootDirectory);
this.ContentManagers.Add( 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 defers loading to the content core.</summary> /// <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> /// <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> public GameContentManager CreateGameContentManager(string name)
/// <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)
{ {
SContentManager manager = new SContentManager(name, this.MainContentManager.ServiceProvider, rootDirectory ?? this.MainContentManager.RootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.OnDisposing, isModFolder); 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 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="rootDirectory">The root directory to search for content (or <c>null</c> for the default).</param>
public ModContentManager CreateModContentManager(string name, string rootDirectory)
{
ModContentManager manager = new ModContentManager(name, this.MainContentManager.ServiceProvider, rootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.OnDisposing);
this.ContentManagers.Add(manager); this.ContentManagers.Add(manager);
return manager; return manager;
} }
/// <summary>Get the current content locale.</summary> /// <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>Convert an absolute file path into a appropriate asset name.</summary> /// <summary>Get whether this asset is mapped to a mod folder.</summary>
/// <param name="absolutePath">The absolute path to the file.</param> /// <param name="key">The asset key.</param>
public string GetAssetNameFromFilePath(string absolutePath) => this.MainContentManager.GetAssetNameFromFilePath(absolutePath, ContentSource.GameContent); 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;
// 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> /// <summary>Purge assets from the cache that match one of the interceptors.</summary>
/// <param name="editors">The asset editors for which to purge matching assets.</param> /// <param name="editors">The asset editors for which to purge matching assets.</param>
@ -129,7 +218,7 @@ namespace StardewModdingAPI.Framework
string locale = this.GetLocale(); string locale = this.GetLocale();
return this.InvalidateCache((assetName, type) => 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); return predicate(info);
}); });
} }
@ -142,7 +231,7 @@ namespace StardewModdingAPI.Framework
{ {
// invalidate cache // invalidate cache
HashSet<string> removedAssetNames = new HashSet<string>(); 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)) foreach (string name in contentManager.InvalidateCache(predicate, dispose))
removedAssetNames.Add(name); removedAssetNames.Add(name);
@ -172,7 +261,7 @@ namespace StardewModdingAPI.Framework
this.IsDisposed = true; this.IsDisposed = true;
this.Monitor.Log("Disposing the content coordinator. Content managers will no longer be usable after this point.", LogLevel.Trace); 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(); contentManager.Dispose();
this.ContentManagers.Clear(); this.ContentManagers.Clear();
this.MainContentManager = null; this.MainContentManager = null;
@ -184,7 +273,7 @@ namespace StardewModdingAPI.Framework
*********/ *********/
/// <summary>A callback invoked when a content manager is disposed.</summary> /// <summary>A callback invoked when a content manager is disposed.</summary>
/// <param name="contentManager">The content manager being disposed.</param> /// <param name="contentManager">The content manager being disposed.</param>
private void OnDisposing(SContentManager contentManager) private void OnDisposing(IContentManager contentManager)
{ {
if (this.IsDisposed) if (this.IsDisposed)
return; return;

View File

@ -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);
}
}

View File

@ -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);
}
}
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}
}

View File

@ -7,6 +7,7 @@ using System.IO;
using System.Linq; using System.Linq;
using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Graphics;
using StardewModdingAPI.Framework.ContentManagers;
using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Exceptions;
using StardewModdingAPI.Framework.Utilities; using StardewModdingAPI.Framework.Utilities;
using StardewValley; using StardewValley;
@ -25,8 +26,11 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <summary>SMAPI's core content logic.</summary> /// <summary>SMAPI's core content logic.</summary>
private readonly ContentCoordinator ContentCore; private readonly ContentCoordinator ContentCore;
/// <summary>The content manager for this mod.</summary> /// <summary>A content manager for this mod which manages files from the game's Content folder.</summary>
private readonly SContentManager ContentManager; 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> /// <summary>The absolute path to the mod folder.</summary>
private readonly string ModFolderPath; private readonly string ModFolderPath;
@ -42,10 +46,10 @@ namespace StardewModdingAPI.Framework.ModHelpers
** Accessors ** Accessors
*********/ *********/
/// <summary>The game's current locale code (like <c>pt-BR</c>).</summary> /// <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> /// <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> /// <summary>The observable implementation of <see cref="AssetEditors"/>.</summary>
internal ObservableCollection<IAssetEditor> ObservableAssetEditors { get; } = new ObservableCollection<IAssetEditor>(); internal ObservableCollection<IAssetEditor> ObservableAssetEditors { get; } = new ObservableCollection<IAssetEditor>();
@ -65,16 +69,16 @@ namespace StardewModdingAPI.Framework.ModHelpers
*********/ *********/
/// <summary>Construct an instance.</summary> /// <summary>Construct an instance.</summary>
/// <param name="contentCore">SMAPI's core content logic.</param> /// <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="modFolderPath">The absolute path to the mod folder.</param>
/// <param name="modID">The unique ID of the relevant mod.</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="modName">The friendly mod name for use in errors.</param>
/// <param name="monitor">Encapsulates monitoring and logging.</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) : base(modID)
{ {
this.ContentCore = contentCore; 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.ModFolderPath = modFolderPath;
this.ModName = modName; this.ModName = modName;
this.Monitor = monitor; this.Monitor = monitor;
@ -92,24 +96,22 @@ namespace StardewModdingAPI.Framework.ModHelpers
try try
{ {
this.AssertValidAssetKeyFormat(key); this.AssertAndNormaliseAssetName(key);
switch (source) switch (source)
{ {
case ContentSource.GameContent: case ContentSource.GameContent:
return this.ContentCore.MainContentManager.Load<T>(key); return this.GameContentManager.Load<T>(key);
case ContentSource.ModFolder: case ContentSource.ModFolder:
// get file // get file
FileInfo file = this.GetModFile(key); FileInfo file = this.GetModFile(key);
if (!file.Exists) if (!file.Exists)
throw GetContentError($"there's no matching file at path '{file.FullName}'."); throw GetContentError($"there's no matching file at path '{file.FullName}'.");
string internalKey = this.GetInternalModAssetKey(file);
// get asset path
string assetName = this.ContentManager.GetAssetNameFromFilePath(file.FullName, ContentSource.ModFolder);
// try cache // try cache
if (this.ContentManager.IsLoaded(assetName)) if (this.ModContentManager.IsLoaded(internalKey))
return this.ContentManager.Load<T>(assetName); return this.ModContentManager.Load<T>(internalKey);
// fix map tilesheets // fix map tilesheets
if (file.Extension.ToLower() == ".tbin") if (file.Extension.ToLower() == ".tbin")
@ -121,15 +123,15 @@ namespace StardewModdingAPI.Framework.ModHelpers
// fetch & cache // fetch & cache
FormatManager formatManager = FormatManager.Instance; FormatManager formatManager = FormatManager.Instance;
Map map = formatManager.LoadMap(file.FullName); Map map = formatManager.LoadMap(file.FullName);
this.FixCustomTilesheetPaths(map, key); this.FixCustomTilesheetPaths(map, relativeMapPath: key);
// inject map // inject map
this.ContentManager.Inject(assetName, map); this.ModContentManager.Inject(internalKey, map);
return (T)(object)map; return (T)(object)map;
} }
// load through content manager // load through content manager
return this.ContentManager.Load<T>(assetName); return this.ModContentManager.Load<T>(internalKey);
default: default:
throw GetContentError($"unknown content source '{source}'."); throw GetContentError($"unknown content source '{source}'.");
@ -146,7 +148,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
[Pure] [Pure]
public string NormaliseAssetName(string assetName) 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> /// <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) switch (source)
{ {
case ContentSource.GameContent: case ContentSource.GameContent:
return this.ContentManager.NormaliseAssetName(key); return this.GameContentManager.AssertAndNormaliseAssetName(key);
case ContentSource.ModFolder: case ContentSource.ModFolder:
FileInfo file = this.GetModFile(key); FileInfo file = this.GetModFile(key);
return this.ContentManager.NormaliseAssetName(this.ContentManager.GetAssetNameFromFilePath(file.FullName, ContentSource.GameContent)); return this.GetInternalModAssetKey(file);
default: default:
throw new NotSupportedException($"Unknown content source '{source}'."); throw new NotSupportedException($"Unknown content source '{source}'.");
@ -205,16 +207,24 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <param name="key">The asset key to check.</param> /// <param name="key">The asset key to check.</param>
/// <exception cref="ArgumentException">The asset key is empty or contains invalid characters.</exception> /// <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.")] [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)) if (Path.IsPathRooted(key))
throw new ArgumentException("The asset key must not be an absolute path."); 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> /// <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="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> /// <exception cref="ContentLoadException">A map tilesheet couldn't be resolved.</exception>
/// <remarks> /// <remarks>
/// The game's logic for tilesheets in <see cref="Game1.setGraphicsForSeason"/> is a bit specialised. It boils /// 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. /// While that doesn't exactly match the game logic, it's close enough that it's unlikely to make a difference.
/// </remarks> /// </remarks>
private void FixCustomTilesheetPaths(Map map, string mapKey) private void FixCustomTilesheetPaths(Map map, string relativeMapPath)
{ {
// get map info // get map info
if (!map.TileSheets.Any()) if (!map.TileSheets.Any())
return; return;
mapKey = this.ContentManager.NormaliseAssetName(mapKey); // Mono's Path.GetDirectoryName doesn't handle Windows dir separators relativeMapPath = this.ModContentManager.AssertAndNormaliseAssetName(relativeMapPath); // 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 string relativeMapFolder = Path.GetDirectoryName(relativeMapPath) ?? ""; // folder path containing the map, relative to the mod folder
// fix tilesheets // fix tilesheets
foreach (TileSheet tilesheet in map.TileSheets) foreach (TileSheet tilesheet in map.TileSheets)
@ -341,7 +351,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
private FileInfo GetModFile(string path) private FileInfo GetModFile(string path)
{ {
// try exact match // 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); FileInfo file = new FileInfo(path);
// try with default extension // try with default extension
@ -360,7 +370,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
private FileInfo GetContentFolderFile(string key) private FileInfo GetContentFolderFile(string key)
{ {
// get file path // get file path
string path = Path.Combine(this.ContentManager.FullRootDirectory, key); string path = Path.Combine(this.GameContentManager.FullRootDirectory, key);
if (!path.EndsWith(".xnb")) if (!path.EndsWith(".xnb"))
path += ".xnb"; path += ".xnb";

View File

@ -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;
}
}
}

View File

@ -202,7 +202,7 @@ namespace StardewModdingAPI.Framework
this.ContentCore = new ContentCoordinator(serviceProvider, rootDirectory, Thread.CurrentThread.CurrentUICulture, SGame.MonitorDuringInitialisation, SGame.ReflectorDuringInitialisation); this.ContentCore = new ContentCoordinator(serviceProvider, rootDirectory, Thread.CurrentThread.CurrentUICulture, SGame.MonitorDuringInitialisation, SGame.ReflectorDuringInitialisation);
SGame.MonitorDuringInitialisation = null; SGame.MonitorDuringInitialisation = null;
this.NextContentManagerIsMain = true; this.NextContentManagerIsMain = true;
return this.ContentCore.CreateContentManager("Game1._temporaryContent", isModFolder: false); return this.ContentCore.CreateGameContentManager("Game1._temporaryContent");
} }
// Game1.content initialising from LoadContent // Game1.content initialising from LoadContent
@ -213,7 +213,7 @@ namespace StardewModdingAPI.Framework
} }
// any other content manager // 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> /// <summary>The method called when the game is updating its state. This happens roughly 60 times per second.</summary>

View File

@ -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> /// <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> /// <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> /// <summary>Normalise path separators in a file path.</summary>

View File

@ -755,8 +755,7 @@ namespace StardewModdingAPI
// load mod as content pack // load mod as content pack
IManifest manifest = metadata.Manifest; IManifest manifest = metadata.Manifest;
IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName); 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, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor);
IContentHelper contentHelper = new ContentHelper(this.ContentCore, contentManager, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor);
IContentPack contentPack = new ContentPack(metadata.DirectoryPath, manifest, contentHelper, jsonHelper); IContentPack contentPack = new ContentPack(metadata.DirectoryPath, manifest, contentHelper, jsonHelper);
metadata.SetMod(contentPack, monitor); metadata.SetMod(contentPack, monitor);
this.ModRegistry.Add(metadata); this.ModRegistry.Add(metadata);
@ -844,8 +843,7 @@ namespace StardewModdingAPI
IModHelper modHelper; IModHelper modHelper;
{ {
ICommandHelper commandHelper = new CommandHelper(manifest.UniqueID, metadata.DisplayName, this.CommandManager); 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, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor);
IContentHelper contentHelper = new ContentHelper(contentCore, contentManager, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor);
IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, metadata.DisplayName, this.Reflection, this.DeprecationManager); IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, metadata.DisplayName, this.Reflection, this.DeprecationManager);
IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry, proxyFactory, monitor); IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry, proxyFactory, monitor);
IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(manifest.UniqueID, this.GameInstance.Multiplayer); IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(manifest.UniqueID, this.GameInstance.Multiplayer);
@ -854,8 +852,7 @@ namespace StardewModdingAPI
IContentPack CreateTransitionalContentPack(string packDirPath, IManifest packManifest) IContentPack CreateTransitionalContentPack(string packDirPath, IManifest packManifest)
{ {
IMonitor packMonitor = this.GetSecondaryMonitor(packManifest.Name); IMonitor packMonitor = this.GetSecondaryMonitor(packManifest.Name);
SContentManager packContentManager = this.ContentCore.CreateContentManager($"Mods.{packManifest.UniqueID}", isModFolder: true, rootDirectory: packDirPath); IContentHelper packContentHelper = new ContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor);
IContentHelper packContentHelper = new ContentHelper(contentCore, packContentManager, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor);
return new ContentPack(packDirPath, packManifest, packContentHelper, this.JsonHelper); return new ContentPack(packDirPath, packManifest, packContentHelper, this.JsonHelper);
} }

View File

@ -87,6 +87,10 @@
</Compile> </Compile>
<Compile Include="Events\EventArgsLocationBuildingsChanged.cs" /> <Compile Include="Events\EventArgsLocationBuildingsChanged.cs" />
<Compile Include="Events\MultiplayerEvents.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\EventManager.cs" />
<Compile Include="Framework\Events\ManagedEvent.cs" /> <Compile Include="Framework\Events\ManagedEvent.cs" />
<Compile Include="Events\SpecialisedEvents.cs" /> <Compile Include="Events\SpecialisedEvents.cs" />
@ -123,7 +127,6 @@
<Compile Include="Framework\ModLoading\Rewriters\VirtualEntryCallRemover.cs" /> <Compile Include="Framework\ModLoading\Rewriters\VirtualEntryCallRemover.cs" />
<Compile Include="Framework\ModLoading\Rewriters\MethodParentRewriter.cs" /> <Compile Include="Framework\ModLoading\Rewriters\MethodParentRewriter.cs" />
<Compile Include="Framework\ModLoading\Rewriters\TypeReferenceRewriter.cs" /> <Compile Include="Framework\ModLoading\Rewriters\TypeReferenceRewriter.cs" />
<Compile Include="Framework\SContentManager.cs" />
<Compile Include="Framework\Exceptions\SAssemblyLoadFailedException.cs" /> <Compile Include="Framework\Exceptions\SAssemblyLoadFailedException.cs" />
<Compile Include="Framework\ModLoading\AssemblyLoadStatus.cs" /> <Compile Include="Framework\ModLoading\AssemblyLoadStatus.cs" />
<Compile Include="Framework\Reflection\InterfaceProxyBuilder.cs" /> <Compile Include="Framework\Reflection\InterfaceProxyBuilder.cs" />