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
{
/// A minimal content manager which defers to SMAPI's core content logic.
internal class SContentManager : LocalizedContentManager
{
/*********
** Properties
*********/
/// The central coordinator which manages content managers.
private readonly ContentCoordinator Coordinator;
/// The underlying asset cache.
private readonly ContentCache Cache;
/// Encapsulates monitoring and logging.
private readonly IMonitor Monitor;
/// A lookup which indicates whether the asset is localisable (i.e. the filename contains the locale), if previously loaded.
private readonly IDictionary IsLocalisableLookup;
/// The language enum values indexed by locale code.
private readonly IDictionary LanguageCodes;
/// The assets currently being intercepted by instances. This is used to prevent infinite loops when a loader loads a new asset.
private readonly ContextHash AssetsBeingLoaded = new ContextHash();
/// The path prefix for assets in mod folders.
private readonly string ModContentPrefix;
/// A callback to invoke when the content manager is being disposed.
private readonly Action OnDisposing;
/// Interceptors which provide the initial versions of matching assets.
private IDictionary> Loaders => this.Coordinator.Loaders;
/// Interceptors which edit matching assets after they're loaded.
private IDictionary> Editors => this.Coordinator.Editors;
/// Whether the content coordinator has been disposed.
private bool IsDisposed;
/*********
** Accessors
*********/
/// A name for the mod manager. Not guaranteed to be unique.
public string Name { get; }
/// Whether this content manager is wrapped around a mod folder.
public bool IsModFolder { get; }
/// The current language as a constant.
public LocalizedContentManager.LanguageCode Language => this.GetCurrentLanguage();
/// The absolute path to the .
public string FullRootDirectory => Path.Combine(Constants.ExecutionPath, this.RootDirectory);
/*********
** Public methods
*********/
/// Construct an instance.
/// A name for the mod manager. Not guaranteed to be unique.
/// The service provider to use to locate services.
/// The root directory to search for content.
/// The current culture for which to localise content.
/// The central coordinator which manages content managers.
/// Encapsulates monitoring and logging.
/// Simplifies access to private code.
/// Whether this content manager is wrapped around a mod folder.
/// A callback to invoke when the content manager is being disposed.
public SContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action 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>(this, "_localizedAsset").GetValue();
}
/// Load an asset that has been processed by the content pipeline.
/// The type of asset to load.
/// The asset path relative to the loader root directory, not including the .xnb extension.
public override T Load(string assetName)
{
return this.Load(assetName, LocalizedContentManager.CurrentLanguageCode);
}
/// Load an asset that has been processed by the content pipeline.
/// The type of asset to load.
/// The asset path relative to the loader root directory, not including the .xnb extension.
/// The language code for which to load content.
public override T Load(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(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(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(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);
}
}
/// Load the base asset without localisation.
/// The type of asset to load.
/// The asset path relative to the loader root directory, not including the .xnb extension.
public override T LoadBase(string assetName)
{
return this.Load(assetName, LanguageCode.en);
}
/// Inject an asset into the cache.
/// The type of asset to inject.
/// The asset path relative to the loader root directory, not including the .xnb extension.
/// The asset value.
public void Inject(string assetName, T value)
{
assetName = this.NormaliseAssetName(assetName);
this.Cache[assetName] = value;
}
/// Create a new content manager for temporary use.
public override LocalizedContentManager CreateTemporary()
{
return this.Coordinator.CreateContentManager("(temporary)", isModFolder: false);
}
/// Normalise path separators in a file path. For asset keys, see instead.
/// The file path to normalise.
[Pure]
public string NormalisePathSeparators(string path)
{
return this.Cache.NormalisePathSeparators(path);
}
/// Normalise an asset name so it's consistent with the underlying cache.
/// The asset key.
[Pure]
public string NormaliseAssetName(string assetName)
{
return this.Cache.NormaliseKey(assetName);
}
/// Assert that the given key has a valid format.
/// The asset key to check.
/// The asset key is empty or contains invalid characters.
[SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")]
public void AssertValidAssetKeyFormat(string key)
{
if (string.IsNullOrWhiteSpace(key))
throw new ArgumentException("The asset key or local path is empty.");
if (key.Intersect(Path.GetInvalidPathChars()).Any())
throw new ArgumentException("The asset key or local path contains invalid characters.");
}
/// Convert an absolute file path into an appropriate asset name.
/// The absolute path to the file.
/// The folder to which to get a relative path.
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
****/
/// Get the current content locale.
public string GetLocale()
{
return this.GetLocale(this.GetCurrentLanguage());
}
/// The locale for a language.
/// The language.
public string GetLocale(LocalizedContentManager.LanguageCode language)
{
return this.LanguageCodeString(language);
}
/// Get whether the content manager has already loaded and cached the given asset.
/// The asset path relative to the loader root directory, not including the .xnb extension.
public bool IsLoaded(string assetName)
{
assetName = this.Cache.NormaliseKey(assetName);
return this.IsNormalisedKeyLoaded(assetName);
}
/// Get the cached asset keys.
public IEnumerable GetAssetKeys()
{
return this.Cache.Keys
.Select(this.GetAssetName)
.Distinct();
}
/****
** Cache invalidation
****/
/// Purge matched assets from the cache.
/// Matches the asset keys to invalidate.
/// Whether to dispose invalidated assets. This should only be true when they're being invalidated as part of a dispose, to avoid crashing the game.
/// Returns the number of invalidated assets.
public IEnumerable InvalidateCache(Func predicate, bool dispose = false)
{
HashSet removeAssetNames = new HashSet(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;
}
/// Dispose held resources.
/// Whether the content manager is being disposed (rather than finalized).
protected override void Dispose(bool isDisposing)
{
if (this.IsDisposed)
return;
this.IsDisposed = true;
this.OnDisposing(this);
base.Dispose(isDisposing);
}
///
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
****/
/// Get a directory or file path relative to the content root.
/// The source file path.
/// The target file path.
private string GetRelativePath(string sourcePath, string targetPath)
{
return PathUtilities.GetRelativePath(sourcePath, targetPath);
}
/// Get the locale codes (like ja-JP) used in asset keys.
private IDictionary GetKeyLocales()
{
// create locale => code map
IDictionary map = new Dictionary();
foreach (LocalizedContentManager.LanguageCode code in Enum.GetValues(typeof(LocalizedContentManager.LanguageCode)))
map[code] = this.GetLocale(code);
return map;
}
/// Get the asset name from a cache key.
/// The input cache key.
private string GetAssetName(string cacheKey)
{
this.ParseCacheKey(cacheKey, out string assetName, out string _);
return assetName;
}
/// Parse a cache key into its component parts.
/// The input cache key.
/// The original asset name.
/// The asset locale code (or null if not localised).
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
****/
/// Get whether an asset has already been loaded.
/// The normalised asset name.
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
****/
/// Load an asset name without heuristics to support mod content.
/// The type of asset to load.
/// The asset path relative to the loader root directory, not including the .xnb extension.
/// The language code for which to load content.
private T LoadImpl(string assetName, LocalizedContentManager.LanguageCode language)
{
// skip if already loaded
if (this.IsNormalisedKeyLoaded(assetName))
return base.Load(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(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(info)
?? new AssetDataForObject(info, base.Load(assetName, language), this.NormaliseAssetName);
asset = this.ApplyEditors(info, asset);
return (T)asset.Data;
});
}
// update cache & return data
this.Inject(assetName, data);
return data;
}
/// Get a file from the mod folder.
/// The asset path relative to the content folder.
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;
}
/// Load the initial asset from the registered .
/// The basic asset metadata.
/// Returns the loaded asset metadata, or null if no loader matched.
private IAssetData ApplyLoader(IAssetInfo info)
{
// find matching loaders
var loaders = this.GetInterceptors(this.Loaders)
.Where(entry =>
{
try
{
return entry.Value.CanLoad(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(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);
}
/// Apply any to a loaded asset.
/// The asset type.
/// The basic asset metadata.
/// The loaded asset.
private IAssetData ApplyEditors(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(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(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;
}
/// Get all registered interceptors from a list.
private IEnumerable> GetInterceptors(IDictionary> entries)
{
foreach (var entry in entries)
{
IModMetadata mod = entry.Key;
IList interceptors = entry.Value;
// registered editors
foreach (T interceptor in interceptors)
yield return new KeyValuePair(mod, interceptor);
}
}
/// Premultiply a texture's alpha values to avoid transparency issues in the game. This is only possible if the game isn't currently drawing.
/// The texture to premultiply.
/// Returns a premultiplied texture.
/// Based on code by Layoric.
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;
}
}
}