using System;
using System.Globalization;
using System.IO;
using System.Linq;
using BmFont;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;
using StardewModdingAPI.Framework.Exceptions;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Toolkit.Serialization;
using StardewModdingAPI.Toolkit.Utilities;
using StardewValley;
using xTile;
using xTile.Format;
using xTile.Tiles;
namespace StardewModdingAPI.Framework.ContentManagers
{
/// A content manager which handles reading files from a SMAPI mod folder with support for unpacked files.
internal class ModContentManager : BaseContentManager
{
/*********
** Fields
*********/
/// Encapsulates SMAPI's JSON file parsing.
private readonly JsonHelper JsonHelper;
/// The mod display name to show in errors.
private readonly string ModName;
/// The game content manager used for map tilesheets not provided by the mod.
private readonly IContentManager GameContentManager;
/// A case-insensitive lookup of relative paths within the .
private readonly CaseInsensitivePathLookup RelativePathCache;
/// If a map tilesheet's image source has no file extensions, the file extensions to check for in the local mod folder.
private readonly string[] LocalTilesheetExtensions = { ".png", ".xnb" };
/*********
** Public methods
*********/
/// Construct an instance.
/// A name for the mod manager. Not guaranteed to be unique.
/// The game content manager used for map tilesheets not provided by the mod.
/// The service provider to use to locate services.
/// The mod display name to show in errors.
/// The root directory to search for content.
/// The current culture for which to localize content.
/// The central coordinator which manages content managers.
/// Encapsulates monitoring and logging.
/// Simplifies access to private code.
/// Encapsulates SMAPI's JSON file parsing.
/// A callback to invoke when the content manager is being disposed.
/// Whether to enable more aggressive memory optimizations.
/// A case-insensitive lookup of relative paths within the .
public ModContentManager(string name, IContentManager gameContentManager, IServiceProvider serviceProvider, string modName, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onDisposing, bool aggressiveMemoryOptimizations, CaseInsensitivePathLookup relativePathCache)
: base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: true, aggressiveMemoryOptimizations: aggressiveMemoryOptimizations)
{
this.GameContentManager = gameContentManager;
this.RelativePathCache = relativePathCache;
this.JsonHelper = jsonHelper;
this.ModName = modName;
this.TryLocalizeKeys = false;
}
///
public override bool DoesAssetExist(IAssetName assetName)
{
if (base.DoesAssetExist(assetName))
return true;
FileInfo file = this.GetModFile(assetName.Name);
return file.Exists;
}
///
public override T LoadExact(IAssetName assetName, bool useCache)
{
// disable caching
// This is necessary to avoid assets being shared between content managers, which can
// cause changes to an asset through one content manager affecting the same asset in
// others (or even fresh content managers). See https://www.patreon.com/posts/27247161
// for more background info.
if (useCache)
throw new InvalidOperationException("Mod content managers don't support asset caching.");
// resolve managed asset key
{
if (this.Coordinator.TryParseManagedAssetKey(assetName.Name, out string? contentManagerID, out IAssetName? relativePath))
{
if (contentManagerID != this.Name)
throw this.GetLoadError(assetName, "can't load a different mod's managed asset key through this mod content manager.");
assetName = relativePath;
}
}
// get local asset
T asset;
try
{
// get file
FileInfo file = this.GetModFile(assetName.Name);
if (!file.Exists)
throw this.GetLoadError(assetName, "the specified path doesn't exist.");
// load content
asset = file.Extension.ToLower() switch
{
".fnt" => this.LoadFont(assetName, file),
".json" => this.LoadDataFile(assetName, file),
".png" => this.LoadImageFile(assetName, file),
".tbin" or ".tmx" => this.LoadMapFile(assetName, file),
".xnb" => this.LoadXnbFile(assetName),
_ => this.HandleUnknownFileType(assetName, file)
};
}
catch (Exception ex) when (ex is not SContentLoadException)
{
throw this.GetLoadError(assetName, "an unexpected occurred.", ex);
}
// track & return asset
this.TrackAsset(assetName, asset, useCache: false);
return asset;
}
///
public override LocalizedContentManager CreateTemporary()
{
throw new NotSupportedException("Can't create a temporary mod content manager.");
}
/// Get the underlying key in the game's content cache for an asset. This does not validate whether the asset exists.
/// The local path to a content file relative to the mod folder.
/// The is empty or contains invalid characters.
public IAssetName GetInternalAssetKey(string key)
{
FileInfo file = this.GetModFile(key);
string relativePath = Path.GetRelativePath(this.RootDirectory, file.FullName);
string internalKey = Path.Combine(this.Name, relativePath);
return this.Coordinator.ParseAssetName(internalKey, allowLocales: false);
}
/*********
** Private methods
*********/
/// Load an unpacked font file (.fnt).
/// The type of asset to load.
/// The asset name relative to the loader root directory.
/// The file to load.
private T LoadFont(IAssetName assetName, FileInfo file)
{
// validate
if (!typeof(T).IsAssignableFrom(typeof(XmlSource)))
throw this.GetLoadError(assetName, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(XmlSource)}'.");
// load
string source = File.ReadAllText(file.FullName);
return (T)(object)new XmlSource(source);
}
/// Load an unpacked data file (.json).
/// The type of asset to load.
/// The asset name relative to the loader root directory.
/// The file to load.
private T LoadDataFile(IAssetName assetName, FileInfo file)
{
if (!this.JsonHelper.ReadJsonFileIfExists(file.FullName, out T? asset))
throw this.GetLoadError(assetName, "the JSON file is invalid."); // should never happen since we check for file existence before calling this method
return asset;
}
/// Load an unpacked image file (.json).
/// The type of asset to load.
/// The asset name relative to the loader root directory.
/// The file to load.
private T LoadImageFile(IAssetName assetName, FileInfo file)
{
// validate
if (typeof(T) != typeof(Texture2D))
throw this.GetLoadError(assetName, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'.");
// load
using FileStream stream = File.OpenRead(file.FullName);
Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream);
texture = this.PremultiplyTransparency(texture);
return (T)(object)texture;
}
/// Load an unpacked image file (.tbin or .tmx).
/// The type of asset to load.
/// The asset name relative to the loader root directory.
/// The file to load.
private T LoadMapFile(IAssetName assetName, FileInfo file)
{
// validate
if (typeof(T) != typeof(Map))
throw this.GetLoadError(assetName, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'.");
// load
FormatManager formatManager = FormatManager.Instance;
Map map = formatManager.LoadMap(file.FullName);
map.assetPath = assetName.Name;
this.FixTilesheetPaths(map, relativeMapPath: assetName.Name, fixEagerPathPrefixes: false);
return (T)(object)map;
}
/// Load a packed file (.xnb).
/// The type of asset to load.
/// The asset name relative to the loader root directory.
private T LoadXnbFile(IAssetName assetName)
{
// the underlying content manager adds a .xnb extension implicitly, so
// we need to strip it here to avoid trying to load a '.xnb.xnb' file.
IAssetName loadName = assetName.Name.EndsWith(".xnb", StringComparison.OrdinalIgnoreCase)
? this.Coordinator.ParseAssetName(assetName.Name[..^".xnb".Length], allowLocales: false)
: assetName;
// load asset
T asset = this.RawLoad(loadName, useCache: false);
if (asset is Map map)
{
map.assetPath = loadName.Name;
this.FixTilesheetPaths(map, relativeMapPath: loadName.Name, fixEagerPathPrefixes: true);
}
return asset;
}
/// Handle a request to load a file type that isn't supported by SMAPI.
/// The expected file type.
/// The asset name relative to the loader root directory.
/// The file to load.
private T HandleUnknownFileType(IAssetName assetName, FileInfo file)
{
throw this.GetLoadError(assetName, $"unknown file extension '{file.Extension}'; must be one of '.fnt', '.json', '.png', '.tbin', '.tmx', or '.xnb'.");
}
/// Get an error which indicates that an asset couldn't be loaded.
/// The asset name that failed to load.
/// The reason the file couldn't be loaded.
/// The underlying exception, if applicable.
private SContentLoadException GetLoadError(IAssetName assetName, string reasonPhrase, Exception? exception = null)
{
return new($"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}", exception);
}
/// Get a file from the mod folder.
/// The asset path relative to the content folder.
private FileInfo GetModFile(string path)
{
// map to case-insensitive path if needed
path = this.RelativePathCache.GetFilePath(path);
// try exact match
FileInfo file = new(Path.Combine(this.FullRootDirectory, path));
// try with default extension
if (!file.Exists)
{
foreach (string extension in this.LocalTilesheetExtensions)
{
FileInfo result = new(file.FullName + extension);
if (result.Exists)
{
file = result;
break;
}
}
}
return file;
}
/// Premultiply a texture's alpha values to avoid transparency issues in the game.
/// The texture to premultiply.
/// Returns a premultiplied texture.
/// Based on code by David Gouveia.
private Texture2D PremultiplyTransparency(Texture2D texture)
{
// premultiply pixels
Color[] data = new Color[texture.Width * texture.Height];
texture.GetData(data);
bool changed = false;
for (int i = 0; i < data.Length; i++)
{
Color pixel = data[i];
if (pixel.A is (byte.MinValue or byte.MaxValue))
continue; // no need to change fully transparent/opaque pixels
data[i] = new Color(pixel.R * pixel.A / byte.MaxValue, pixel.G * pixel.A / byte.MaxValue, pixel.B * pixel.A / byte.MaxValue, pixel.A); // slower version: Color.FromNonPremultiplied(data[i].ToVector4())
changed = true;
}
if (changed)
texture.SetData(data);
return texture;
}
/// Fix custom map tilesheet paths so they can be found by the content manager.
/// The map whose tilesheets to fix.
/// The relative map path within the mod folder.
/// Whether to undo the game's eager tilesheet path prefixing for maps loaded from an .xnb file, which incorrectly prefixes tilesheet paths with the map's local asset key folder.
/// A map tilesheet couldn't be resolved.
private void FixTilesheetPaths(Map map, string relativeMapPath, bool fixEagerPathPrefixes)
{
// get map info
relativeMapPath = this.AssertAndNormalizeAssetName(relativeMapPath); // Mono's Path.GetDirectoryName doesn't handle Windows dir separators
string relativeMapFolder = Path.GetDirectoryName(relativeMapPath) ?? ""; // folder path containing the map, relative to the mod folder
// fix tilesheets
this.Monitor.VerboseLog($"Fixing tilesheet paths for map '{relativeMapPath}' from mod '{this.ModName}'...");
foreach (TileSheet tilesheet in map.TileSheets)
{
// get image source
tilesheet.ImageSource = this.NormalizePathSeparators(tilesheet.ImageSource);
string imageSource = tilesheet.ImageSource;
// reverse incorrect eager tilesheet path prefixing
if (fixEagerPathPrefixes && relativeMapFolder.Length > 0 && imageSource.StartsWith(relativeMapFolder))
imageSource = imageSource.Substring(relativeMapFolder.Length + 1);
// validate tilesheet path
string errorPrefix = $"{this.ModName} loaded map '{relativeMapPath}' with invalid tilesheet path '{imageSource}'.";
if (Path.IsPathRooted(imageSource) || PathUtilities.GetSegments(imageSource).Contains(".."))
throw new SContentLoadException($"{errorPrefix} Tilesheet paths must be a relative path without directory climbing (../).");
// load best match
try
{
if (!this.TryGetTilesheetAssetName(relativeMapFolder, imageSource, out IAssetName? assetName, out string? error))
throw new SContentLoadException($"{errorPrefix} {error}");
if (assetName is not null)
{
if (!assetName.IsEquivalentTo(tilesheet.ImageSource))
this.Monitor.VerboseLog($" Mapped tilesheet '{tilesheet.ImageSource}' to '{assetName}'.");
tilesheet.ImageSource = assetName.Name;
}
}
catch (Exception ex) when (ex is not SContentLoadException)
{
throw new SContentLoadException($"{errorPrefix} The tilesheet couldn't be loaded.", ex);
}
}
}
/// Get the actual asset name for a tilesheet.
/// The folder path containing the map, relative to the mod folder.
/// The tilesheet path to load.
/// The found asset name.
/// A message indicating why the file couldn't be loaded.
/// Returns whether the asset name was found.
/// See remarks on .
private bool TryGetTilesheetAssetName(string modRelativeMapFolder, string relativePath, out IAssetName? assetName, out string? error)
{
assetName = null;
error = null;
// nothing to do
if (string.IsNullOrWhiteSpace(relativePath))
{
assetName = null;
return true;
}
// special case: local filenames starting with a dot should be ignored
// For example, this lets mod authors have a '.spring_town.png' file in their map folder so it can be
// opened in Tiled, while still mapping it to the vanilla 'Maps/spring_town' asset at runtime.
{
string filename = Path.GetFileName(relativePath);
if (filename.StartsWith("."))
relativePath = Path.Combine(Path.GetDirectoryName(relativePath) ?? "", filename.TrimStart('.'));
}
// get relative to map file
{
string localKey = Path.Combine(modRelativeMapFolder, relativePath);
if (this.GetModFile(localKey).Exists)
{
assetName = this.GetInternalAssetKey(localKey);
return true;
}
}
// get from game assets
IAssetName contentKey = this.Coordinator.ParseAssetName(this.GetContentKeyForTilesheetImageSource(relativePath), allowLocales: false);
try
{
this.GameContentManager.LoadLocalized(contentKey, this.GameContentManager.Language, useCache: true); // no need to bypass cache here, since we're not storing the asset
assetName = contentKey;
return true;
}
catch
{
// ignore file-not-found errors
// TODO: while it's useful to suppress an asset-not-found error here to avoid
// confusion, this is a pretty naive approach. Even if the file doesn't exist,
// the file may have been loaded through an IAssetLoader which failed. So even
// if the content file doesn't exist, that doesn't mean the error here is a
// content-not-found error. Unfortunately XNA doesn't provide a good way to
// detect the error type.
if (this.GetContentFolderFileExists(contentKey.Name))
throw;
}
// not found
error = "The tilesheet couldn't be found relative to either map file or the game's content folder.";
return false;
}
/// Get whether a file from the game's content folder exists.
/// The asset key.
private bool GetContentFolderFileExists(string key)
{
// get file path
string path = Path.Combine(this.GameContentManager.FullRootDirectory, key);
if (!path.EndsWith(".xnb"))
path += ".xnb";
// get file
return new FileInfo(path).Exists;
}
/// Get the asset key for a tilesheet in the game's Maps content folder.
/// The tilesheet image source.
private string GetContentKeyForTilesheetImageSource(string relativePath)
{
string key = relativePath;
string topFolder = PathUtilities.GetSegments(key, limit: 2)[0];
// convert image source relative to map file into asset key
if (!topFolder.Equals("Maps", StringComparison.OrdinalIgnoreCase))
key = Path.Combine("Maps", key);
// remove file extension from unpacked file
if (key.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
key = key.Substring(0, key.Length - 4);
return key;
}
}
}