centralise most content-loading logic to fix map tilesheet edge case (#373)

This commit is contained in:
Jesse Plamondon-Willard 2017-10-25 17:14:58 -04:00
parent fd2b9c72a8
commit a1eeece49b
3 changed files with 348 additions and 246 deletions

View File

@ -1,10 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Graphics;
using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Exceptions;
@ -74,12 +72,12 @@ namespace StardewModdingAPI.Framework.ModHelpers
this.ContentManager = contentManager; this.ContentManager = contentManager;
this.ModFolderPath = modFolderPath; this.ModFolderPath = modFolderPath;
this.ModName = modName; this.ModName = modName;
this.ModFolderPathFromContent = this.GetRelativePath(contentManager.FullRootDirectory, modFolderPath); this.ModFolderPathFromContent = this.ContentManager.GetRelativePath(modFolderPath);
this.Monitor = monitor; this.Monitor = monitor;
} }
/// <summary>Load content from the game folder or mod folder (if not already cached), and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary> /// <summary>Load content from the game folder or mod folder (if not already cached), and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary>
/// <typeparam name="T">The expected data type. The main supported types are <see cref="Texture2D"/> and dictionaries; other types may be supported by the game's content pipeline.</typeparam> /// <typeparam name="T">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, and dictionaries; other types may be supported by the game's content pipeline.</typeparam>
/// <param name="key">The asset key to fetch (if the <paramref name="source"/> is <see cref="ContentSource.GameContent"/>), or the local path to a content file relative to the mod folder.</param> /// <param name="key">The asset key to fetch (if the <paramref name="source"/> is <see cref="ContentSource.GameContent"/>), or the local path to a content file relative to the mod folder.</param>
/// <param name="source">Where to search for a matching content asset.</param> /// <param name="source">Where to search for a matching content asset.</param>
/// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception>
@ -88,9 +86,9 @@ namespace StardewModdingAPI.Framework.ModHelpers
{ {
SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}: {reasonPhrase}."); SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}: {reasonPhrase}.");
this.AssertValidAssetKeyFormat(key);
try try
{ {
this.ContentManager.AssertValidAssetKeyFormat(key);
switch (source) switch (source)
{ {
case ContentSource.GameContent: case ContentSource.GameContent:
@ -103,26 +101,14 @@ namespace StardewModdingAPI.Framework.ModHelpers
throw GetContentError($"there's no matching file at path '{file.FullName}'."); throw GetContentError($"there's no matching file at path '{file.FullName}'.");
// get asset path // get asset path
string assetPath = this.GetModAssetPath(key, file.FullName); string assetName = this.GetModAssetPath(key, file.FullName);
// try cache // try cache
if (this.ContentManager.IsLoaded(assetPath)) if (this.ContentManager.IsLoaded(assetName))
return this.ContentManager.Load<T>(assetPath); return this.ContentManager.Load<T>(assetName);
// load content // fix map tilesheets
switch (file.Extension.ToLower()) if (file.Extension.ToLower() == ".tbin")
{
// XNB file
case ".xnb":
{
T asset = this.ContentManager.Load<T>(assetPath);
if (asset is Map)
this.FixLocalMapTilesheets(asset as Map, key);
return asset;
}
// unpacked map
case ".tbin":
{ {
// validate // validate
if (typeof(T) != typeof(Map)) if (typeof(T) != typeof(Map))
@ -134,28 +120,12 @@ namespace StardewModdingAPI.Framework.ModHelpers
this.FixLocalMapTilesheets(map, key); this.FixLocalMapTilesheets(map, key);
// inject map // inject map
this.ContentManager.Inject(assetPath, map); this.ContentManager.Inject(assetName, map, this.ContentManager);
return (T)(object)map; return (T)(object)map;
} }
// unpacked image // load through content manager
case ".png": return this.ContentManager.Load<T>(assetName);
// 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.ContentManager.Inject(assetPath, texture);
return (T)(object)texture;
}
default:
throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.png', '.tbin', or '.xnb'.");
}
default: default:
throw GetContentError($"unknown content source '{source}'."); throw GetContentError($"unknown content source '{source}'.");
@ -264,8 +234,8 @@ namespace StardewModdingAPI.Framework.ModHelpers
try try
{ {
string key = string key =
this.TryLoadTilesheetImageSource(relativeMapFolder, seasonalImageSource) this.GetTilesheetAssetName(relativeMapFolder, seasonalImageSource)
?? this.TryLoadTilesheetImageSource(relativeMapFolder, imageSource); ?? this.GetTilesheetAssetName(relativeMapFolder, imageSource);
if (key != null) if (key != null)
{ {
tilesheet.ImageSource = key; tilesheet.ImageSource = key;
@ -282,34 +252,23 @@ namespace StardewModdingAPI.Framework.ModHelpers
} }
} }
/// <summary>Load a tilesheet image source if the file exists.</summary> /// <summary>Get the actual asset name for a tilesheet.</summary>
/// <param name="relativeMapFolder">The folder path containing the map, relative to the mod folder.</param> /// <param name="modRelativeMapFolder">The folder path containing the map, relative to the mod folder.</param>
/// <param name="imageSource">The tilesheet image source to load.</param> /// <param name="imageSource">The tilesheet image source to load.</param>
/// <returns>Returns the loaded asset key (if it was loaded successfully).</returns> /// <returns>Returns the asset name.</returns>
/// <remarks>See remarks on <see cref="FixLocalMapTilesheets"/>.</remarks> /// <remarks>See remarks on <see cref="FixLocalMapTilesheets"/>.</remarks>
private string TryLoadTilesheetImageSource(string relativeMapFolder, string imageSource) private string GetTilesheetAssetName(string modRelativeMapFolder, string imageSource)
{ {
if (imageSource == null) if (imageSource == null)
return null; return null;
// check relative to map file // check relative to map file
{ {
string localKey = Path.Combine(relativeMapFolder, imageSource); string localKey = Path.Combine(modRelativeMapFolder, imageSource);
FileInfo localFile = this.GetModFile(localKey); FileInfo localFile = this.GetModFile(localKey);
if (localFile.Exists) if (localFile.Exists)
{
try
{
this.Load<Texture2D>(localKey);
}
catch (Exception ex)
{
throw new ContentLoadException($"The local '{imageSource}' tilesheet couldn't be loaded.", ex);
}
return this.GetActualAssetKey(localKey); return this.GetActualAssetKey(localKey);
} }
}
// check relative to content folder // check relative to content folder
{ {
@ -343,18 +302,6 @@ namespace StardewModdingAPI.Framework.ModHelpers
return null; return null;
} }
/// <summary>Assert that the given key has a valid format.</summary>
/// <param name="key">The asset key to check.</param>
/// <exception cref="ArgumentException">The asset key is empty or contains invalid characters.</exception>
[SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "Parameter is only used for assertion checks by design.")]
private 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.");
}
/// <summary>Get a file from the mod folder.</summary> /// <summary>Get a file from the mod folder.</summary>
/// <param name="path">The asset path relative to the mod folder.</param> /// <param name="path">The asset path relative to the mod folder.</param>
private FileInfo GetModFile(string path) private FileInfo GetModFile(string path)
@ -400,81 +347,5 @@ namespace StardewModdingAPI.Framework.ModHelpers
return absolutePath; return absolutePath;
#endif #endif
} }
/// <summary>Get a directory path relative to a given root.</summary>
/// <param name="rootPath">The root path from which the path should be relative.</param>
/// <param name="targetPath">The target file path.</param>
private string GetRelativePath(string rootPath, string targetPath)
{
// convert to URIs
Uri from = new Uri(rootPath + "/");
Uri to = new Uri(targetPath + "/");
if (from.Scheme != to.Scheme)
throw new InvalidOperationException($"Can't get path for '{targetPath}' relative to '{rootPath}'.");
// get relative path
return Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString())
.Replace(Path.DirectorySeparatorChar == '/' ? '\\' : '/', Path.DirectorySeparatorChar); // use correct separator for platform
}
/// <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

@ -1,13 +1,17 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.Contracts; using System.Diagnostics.Contracts;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Threading; using System.Threading;
using Microsoft.Xna.Framework;
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.Exceptions;
using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.Utilities; using StardewModdingAPI.Framework.Utilities;
using StardewModdingAPI.Metadata; using StardewModdingAPI.Metadata;
@ -55,6 +59,9 @@ namespace StardewModdingAPI.Framework
/// <summary>A lookup of the content managers which loaded each asset.</summary> /// <summary>A lookup of the content managers which loaded each asset.</summary>
private readonly IDictionary<string, HashSet<ContentManager>> ContentManagersByAssetKey = new Dictionary<string, HashSet<ContentManager>>(); private readonly IDictionary<string, HashSet<ContentManager>> ContentManagersByAssetKey = new Dictionary<string, HashSet<ContentManager>>();
/// <summary>The path prefix for assets in mod folders.</summary>
private readonly string ModContentPrefix;
/// <summary>A lock used to prevents concurrent changes to the cache while data is being read.</summary> /// <summary>A lock used to prevents concurrent changes to the cache while data is being read.</summary>
private readonly ReaderWriterLockSlim Lock = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion); private readonly ReaderWriterLockSlim Lock = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion);
@ -78,6 +85,9 @@ namespace StardewModdingAPI.Framework
/********* /*********
** Public methods ** Public methods
*********/ *********/
/****
** Constructor
****/
/// <summary>Construct an instance.</summary> /// <summary>Construct an instance.</summary>
/// <param name="serviceProvider">The service provider to use to locate services.</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="rootDirectory">The root directory to search for content.</param>
@ -92,12 +102,16 @@ namespace StardewModdingAPI.Framework
this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
this.Cache = new ContentCache(this, reflection, SContentManager.PossiblePathSeparators, SContentManager.PreferredPathSeparator); this.Cache = new ContentCache(this, reflection, SContentManager.PossiblePathSeparators, SContentManager.PreferredPathSeparator);
this.GetKeyLocale = reflection.GetPrivateMethod(this, "languageCode"); this.GetKeyLocale = reflection.GetPrivateMethod(this, "languageCode");
this.ModContentPrefix = this.GetRelativePath(Constants.ModPath);
// get asset data // get asset data
this.CoreAssets = new CoreAssets(this.NormaliseAssetName); this.CoreAssets = new CoreAssets(this.NormaliseAssetName);
this.KeyLocales = this.GetKeyLocales(reflection); this.KeyLocales = this.GetKeyLocales(reflection);
} }
/****
** Asset key/name handling
****/
/// <summary>Normalise path separators in a file path. For asset keys, see <see cref="NormaliseAssetName"/> instead.</summary> /// <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> /// <param name="path">The file path to normalise.</param>
[Pure] [Pure]
@ -114,6 +128,42 @@ namespace StardewModdingAPI.Framework
return this.Cache.NormaliseKey(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="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.")]
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.");
}
/// <summary>Get a directory path relative to the content root.</summary>
/// <param name="targetPath">The target file path.</param>
public string GetRelativePath(string targetPath)
{
// convert to URIs
Uri from = new Uri(this.FullRootDirectory + "/");
Uri to = new Uri(targetPath + "/");
if (from.Scheme != to.Scheme)
throw new InvalidOperationException($"Can't get path for '{targetPath}' relative to '{this.FullRootDirectory}'.");
// get relative path
return Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString())
.Replace(Path.DirectorySeparatorChar == '/' ? '\\' : '/', Path.DirectorySeparatorChar); // use correct separator for platform
}
/****
** Content loading
****/
/// <summary>Get the current content locale.</summary>
public string GetLocale()
{
return this.GetKeyLocale.Invoke<string>();
}
/// <summary>Get whether the content manager has already loaded and cached the given asset.</summary> /// <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> /// <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) public bool IsLoaded(string assetName)
@ -122,76 +172,6 @@ namespace StardewModdingAPI.Framework
return this.WithReadLock(() => this.IsNormalisedKeyLoaded(assetName)); return this.WithReadLock(() => this.IsNormalisedKeyLoaded(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>
public override T Load<T>(string assetName)
{
return this.LoadFor<T>(assetName, this);
}
/// <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="instance">The content manager instance for which to load the asset.</param>
public T LoadFor<T>(string assetName, ContentManager instance)
{
assetName = this.NormaliseAssetName(assetName);
return this.WithWriteLock(() =>
{
// skip if already loaded
if (this.IsNormalisedKeyLoaded(assetName))
{
this.TrackAssetLoader(assetName, instance);
return base.Load<T>(assetName);
}
// 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);
}
else
{
data = this.AssetsBeingLoaded.Track(assetName, () =>
{
IAssetInfo info = new AssetInfo(this.GetLocale(), assetName, typeof(T), this.NormaliseAssetName);
IAssetData asset = this.ApplyLoader<T>(info) ?? new AssetDataForObject(info, base.Load<T>(assetName), this.NormaliseAssetName);
asset = this.ApplyEditors<T>(info, asset);
return (T)asset.Data;
});
}
// update cache & return data
this.Cache[assetName] = data;
this.TrackAssetLoader(assetName, instance);
return data;
});
}
/// <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)
{
this.WithWriteLock(() =>
{
assetName = this.NormaliseAssetName(assetName);
this.Cache[assetName] = value;
this.TrackAssetLoader(assetName, this);
});
}
/// <summary>Get the current content locale.</summary>
public string GetLocale()
{
return this.GetKeyLocale.Invoke<string>();
}
/// <summary>Get the cached asset keys.</summary> /// <summary>Get the cached asset keys.</summary>
public IEnumerable<string> GetAssetKeys() public IEnumerable<string> GetAssetKeys()
{ {
@ -202,6 +182,95 @@ namespace StardewModdingAPI.Framework
); );
} }
/// <summary>Load an asset through the content pipeline. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary>
/// <typeparam name="T">The expected asset type.</typeparam>
/// <param name="assetName">The asset path relative to the content directory.</param>
public override T Load<T>(string assetName)
{
return this.LoadFor<T>(assetName, this);
}
/// <summary>Load an asset through the content pipeline. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary>
/// <typeparam name="T">The expected asset type.</typeparam>
/// <param name="assetName">The asset path relative to the content directory.</param>
/// <param name="instance">The content manager instance for which to load the asset.</param>
/// <exception cref="ArgumentException">The <paramref name="assetName"/> is empty or contains invalid characters.</exception>
/// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception>
public T LoadFor<T>(string assetName, ContentManager instance)
{
// normalise asset key
this.AssertValidAssetKeyFormat(assetName);
assetName = this.NormaliseAssetName(assetName);
// load game content
if (!assetName.StartsWith(this.ModContentPrefix))
return this.LoadImpl<T>(assetName, instance);
// load mod content
SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading content asset '{assetName}': {reasonPhrase}.");
try
{
return this.WithWriteLock(() =>
{
// try cache
if (this.IsLoaded(assetName))
return this.LoadImpl<T>(assetName, instance);
// 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, instance);
// 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.InjectWithoutLock(assetName, texture, instance);
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))
{
throw new SContentLoadException($"The content manager failed loading content asset '{assetName}'.", ex);
}
}
/// <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>
/// <param name="instance">The content manager instance for which to load the asset.</param>
public void Inject<T>(string assetName, T value, ContentManager instance)
{
this.WithWriteLock(() => this.InjectWithoutLock(assetName, value, instance));
}
/****
** Cache invalidation
****/
/// <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>
/// <param name="loaders">The asset loaders for which to purge matching assets.</param> /// <param name="loaders">The asset loaders for which to purge matching assets.</param>
@ -279,6 +348,9 @@ namespace StardewModdingAPI.Framework
}); });
} }
/****
** Disposal
****/
/// <summary>Dispose assets for the given content manager shim.</summary> /// <summary>Dispose assets for the given content manager shim.</summary>
/// <param name="shim">The content manager whose assets to dispose.</param> /// <param name="shim">The content manager whose assets to dispose.</param>
internal void DisposeFor(ContentManagerShim shim) internal void DisposeFor(ContentManagerShim shim)
@ -297,6 +369,9 @@ namespace StardewModdingAPI.Framework
/********* /*********
** Private methods ** Private methods
*********/ *********/
/****
** Disposal
****/
/// <summary>Dispose held resources.</summary> /// <summary>Dispose held resources.</summary>
/// <param name="disposing">Whether the content manager is disposing (rather than finalising).</param> /// <param name="disposing">Whether the content manager is disposing (rather than finalising).</param>
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
@ -305,24 +380,9 @@ namespace StardewModdingAPI.Framework
base.Dispose(disposing); base.Dispose(disposing);
} }
/// <summary>Get whether an asset has already been loaded.</summary> /****
/// <param name="normalisedAssetName">The normalised asset name.</param> ** Asset name/key handling
private bool IsNormalisedKeyLoaded(string normalisedAssetName) ****/
{
return this.Cache.ContainsKey(normalisedAssetName)
|| this.Cache.ContainsKey($"{normalisedAssetName}.{this.GetKeyLocale.Invoke<string>()}"); // translated asset
}
/// <summary>Track that a content manager loaded an asset.</summary>
/// <param name="key">The asset key that was loaded.</param>
/// <param name="manager">The content manager that loaded the asset.</param>
private void TrackAssetLoader(string key, ContentManager manager)
{
if (!this.ContentManagersByAssetKey.TryGetValue(key, out HashSet<ContentManager> hash))
hash = this.ContentManagersByAssetKey[key] = new HashSet<ContentManager>();
hash.Add(manager);
}
/// <summary>Get the locale codes (like <c>ja-JP</c>) used in asset keys.</summary> /// <summary>Get the locale codes (like <c>ja-JP</c>) used in asset keys.</summary>
/// <param name="reflection">Simplifies access to private game code.</param> /// <param name="reflection">Simplifies access to private game code.</param>
private IDictionary<string, LanguageCode> GetKeyLocales(Reflector reflection) private IDictionary<string, LanguageCode> GetKeyLocales(Reflector reflection)
@ -385,6 +445,113 @@ namespace StardewModdingAPI.Framework
localeCode = null; 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)
{
return this.Cache.ContainsKey(normalisedAssetName)
|| this.Cache.ContainsKey($"{normalisedAssetName}.{this.GetKeyLocale.Invoke<string>()}"); // translated asset
}
/// <summary>Track that a content manager loaded an asset.</summary>
/// <param name="key">The asset key that was loaded.</param>
/// <param name="manager">The content manager that loaded the asset.</param>
private void TrackAssetLoader(string key, ContentManager manager)
{
if (!this.ContentManagersByAssetKey.TryGetValue(key, out HashSet<ContentManager> hash))
hash = this.ContentManagersByAssetKey[key] = new HashSet<ContentManager>();
hash.Add(manager);
}
/****
** 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="instance">The content manager instance for which to load the asset.</param>
private T LoadImpl<T>(string assetName, ContentManager instance)
{
return this.WithWriteLock(() =>
{
// skip if already loaded
if (this.IsNormalisedKeyLoaded(assetName))
{
this.TrackAssetLoader(assetName, instance);
return base.Load<T>(assetName);
}
// 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);
}
else
{
data = this.AssetsBeingLoaded.Track(assetName, () =>
{
IAssetInfo info = new AssetInfo(this.GetLocale(), assetName, typeof(T), this.NormaliseAssetName);
IAssetData asset = this.ApplyLoader<T>(info) ?? new AssetDataForObject(info, base.Load<T>(assetName), this.NormaliseAssetName);
asset = this.ApplyEditors<T>(info, asset);
return (T)asset.Data;
});
}
// update cache & return data
this.InjectWithoutLock(assetName, data, instance);
return data;
});
}
/// <summary>Inject an asset into the cache without acquiring a write lock. This should only be called from within a write lock.</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>
/// <param name="instance">The content manager instance for which to load the asset.</param>
private void InjectWithoutLock<T>(string assetName, T value, ContentManager instance)
{
assetName = this.NormaliseAssetName(assetName);
this.Cache[assetName] = value;
this.TrackAssetLoader(assetName, instance);
}
/// <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(path + ".xnb");
if (result.Exists)
file = result;
}
return file;
}
/// <summary>Get a file from the game's content folder.</summary>
/// <param name="key">The asset key.</param>
private FileInfo GetContentFolderFile(string key)
{
// get file path
string path = Path.Combine(this.FullRootDirectory, key);
if (!path.EndsWith(".xnb"))
path += ".xnb";
// get file
return new FileInfo(path);
}
/// <summary>Load the initial asset from the registered <see cref="Loaders"/>.</summary> /// <summary>Load the initial asset from the registered <see cref="Loaders"/>.</summary>
/// <param name="info">The basic asset metadata.</param> /// <param name="info">The basic asset metadata.</param>
/// <returns>Returns the loaded asset metadata, or <c>null</c> if no loader matched.</returns> /// <returns>Returns the loaded asset metadata, or <c>null</c> if no loader matched.</returns>
@ -510,6 +677,69 @@ namespace StardewModdingAPI.Framework
} }
} }
/// <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;
}
/****
** Concurrency logic
****/
/// <summary>Acquire a read lock which prevents concurrent writes to the cache while it's open.</summary> /// <summary>Acquire a read lock which prevents concurrent writes to the cache while it's open.</summary>
/// <typeparam name="T">The action's return value.</typeparam> /// <typeparam name="T">The action's return value.</typeparam>
/// <param name="action">The action to perform.</param> /// <param name="action">The action to perform.</param>

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Graphics;
using StardewValley; using StardewValley;
using xTile;
namespace StardewModdingAPI namespace StardewModdingAPI
{ {
@ -29,7 +30,7 @@ namespace StardewModdingAPI
** Public methods ** Public methods
*********/ *********/
/// <summary>Load content from the game folder or mod folder (if not already cached), and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary> /// <summary>Load content from the game folder or mod folder (if not already cached), and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary>
/// <typeparam name="T">The expected data type. The main supported types are <see cref="Texture2D"/> and dictionaries; other types may be supported by the game's content pipeline.</typeparam> /// <typeparam name="T">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, and dictionaries; other types may be supported by the game's content pipeline.</typeparam>
/// <param name="key">The asset key to fetch (if the <paramref name="source"/> is <see cref="ContentSource.GameContent"/>), or the local path to a content file relative to the mod folder.</param> /// <param name="key">The asset key to fetch (if the <paramref name="source"/> is <see cref="ContentSource.GameContent"/>), or the local path to a content file relative to the mod folder.</param>
/// <param name="source">Where to search for a matching content asset.</param> /// <param name="source">Where to search for a matching content asset.</param>
/// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception>