using System;
using System.Buffers;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using BmFont;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;
using SkiaSharp;
using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.Exceptions;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Toolkit.Serialization;
using StardewModdingAPI.Toolkit.Utilities;
using StardewModdingAPI.Toolkit.Utilities.PathLookups;
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 sealed 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 lookup for files within the .
private readonly IFileLookup FileLookup;
/// If a map tilesheet's image source has no file extensions, the file extensions to check for in the local mod folder.
private static readonly string[] LocalTilesheetExtensions = { ".png", ".xnb" };
/*********
** Accessors
*********/
#if SMAPI_DEPRECATED
/// Whether to enable legacy compatibility mode for PyTK scale-up textures.
internal static bool EnablePyTkLegacyMode;
#endif
/*********
** 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.
/// A lookup for files 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, IFileLookup fileLookup)
: base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: true)
{
this.GameContentManager = gameContentManager;
this.FileLookup = fileLookup;
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)
this.ThrowLoadError(assetName, ContentLoadErrorType.AccessDenied, "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)
this.ThrowLoadError(assetName, ContentLoadErrorType.AssetDoesNotExist, "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)
{
if (ex is SContentLoadException)
throw;
this.ThrowLoadError(assetName, ContentLoadErrorType.Other, "an unexpected error occurred.", ex);
return default;
}
// track & return asset
this.TrackAsset(assetName, asset, useCache: false);
return asset;
}
///
[Obsolete($"Temporary {nameof(ModContentManager)}s are unsupported")]
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)
{
string internalKey = Path.Combine(this.Name, PathUtilities.NormalizeAssetName(key));
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)
{
this.AssertValidType(assetName, file, typeof(XmlSource));
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))
this.ThrowLoadError(assetName, ContentLoadErrorType.InvalidData, "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 (.png).
/// 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)
{
this.AssertValidType(assetName, file, typeof(Texture2D), typeof(IRawTextureData));
bool returnRawData = typeof(T).IsAssignableTo(typeof(IRawTextureData));
#if SMAPI_DEPRECATED
if (!returnRawData && this.ShouldDisableIntermediateRawDataLoad(assetName, file))
{
using FileStream stream = File.OpenRead(file.FullName);
Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream).SetName(assetName);
this.PremultiplyTransparency(texture);
return (T)(object)texture;
}
#endif
IRawTextureData raw = this.LoadRawImageData(file, returnRawData);
if (returnRawData)
return (T)raw;
else
{
Texture2D texture = new Texture2D(Game1.graphics.GraphicsDevice, raw.Width, raw.Height).SetName(assetName);
texture.SetData(raw.Data);
return (T)(object)texture;
}
}
#if SMAPI_DEPRECATED
/// Get whether to disable loading an image as before building a instance. This isn't called if the mod requested directly.
/// The type of asset being loaded.
/// The asset name relative to the loader root directory.
/// The file being loaded.
private bool ShouldDisableIntermediateRawDataLoad(IAssetName assetName, FileInfo file)
{
// disable raw data if PyTK will rescale the image (until it supports raw data)
if (ModContentManager.EnablePyTkLegacyMode)
{
// PyTK intercepts Texture2D file loads to rescale them (e.g. for HD portraits),
// but doesn't support IRawTextureData loads yet. We can't just check if the
// current file has a '.pytk.json' rescale file though, since PyTK may still
// rescale it if the original asset or another edit gets rescaled.
this.Monitor.LogOnce("Enabled compatibility mode for PyTK 1.23.* or earlier. This won't cause any issues, but may impact performance. This will no longer be supported in the upcoming SMAPI 4.0.0.", LogLevel.Warn);
return true;
}
return false;
}
#endif
/// Load the raw image data from a file on disk.
/// The file whose data to load.
/// Whether the data is being loaded for an (true) or (false) instance.
/// This is separate to let framework mods intercept the data before it's loaded, if needed.
[SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The 'forRawData' parameter is only added for mods which may intercept this method.")]
[SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "The 'forRawData' parameter is only added for mods which may intercept this method.")]
private IRawTextureData LoadRawImageData(FileInfo file, bool forRawData)
{
// load raw data
int width;
int height;
SKPMColor[] rawPixels;
{
using FileStream stream = File.OpenRead(file.FullName);
using SKBitmap bitmap = SKBitmap.Decode(stream);
if (bitmap is null)
throw new InvalidDataException($"Failed to load {file.FullName}. This doesn't seem to be a valid PNG image.");
rawPixels = SKPMColor.PreMultiply(bitmap.Pixels);
width = bitmap.Width;
height = bitmap.Height;
}
// convert to XNA pixel format
var pixels = GC.AllocateUninitializedArray(rawPixels.Length);
for (int i = 0; i < pixels.Length; i++)
{
SKPMColor pixel = rawPixels[i];
pixels[i] = pixel.Alpha == 0
? Color.Transparent
: new Color(r: pixel.Red, g: pixel.Green, b: pixel.Blue, alpha: pixel.Alpha);
}
return new RawTextureData(width, height, pixels);
}
/// 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)
{
this.AssertValidType(assetName, file, typeof(Map));
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)
{
if (typeof(IRawTextureData).IsAssignableFrom(typeof(T)))
this.ThrowLoadError(assetName, ContentLoadErrorType.Other, $"can't read XNB file as type {typeof(IRawTextureData)}; that type can only be read from a PNG file.");
// 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)
{
this.ThrowLoadError(assetName, ContentLoadErrorType.InvalidName, $"unknown file extension '{file.Extension}'; must be one of '.fnt', '.json', '.png', '.tbin', '.tmx', or '.xnb'.");
return default;
}
/// Assert that the asset type is compatible with one of the allowed types.
/// The actual asset type.
/// The asset name relative to the loader root directory.
/// The file being loaded.
/// The allowed asset types.
/// The is not compatible with any of the .
private void AssertValidType(IAssetName assetName, FileInfo file, params Type[] validTypes)
{
if (!validTypes.Any(validType => validType.IsAssignableFrom(typeof(TAsset))))
this.ThrowLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(TAsset)}'; must be type '{string.Join("' or '", validTypes.Select(p => p.FullName))}'.");
}
/// Throw an error which indicates that an asset couldn't be loaded.
/// Why loading an asset through the content pipeline failed.
/// The asset name that failed to load.
/// The reason the file couldn't be loaded.
/// The underlying exception, if applicable.
///
[DoesNotReturn]
[DebuggerStepThrough, DebuggerHidden]
[MethodImpl(MethodImplOptions.NoInlining)]
private void ThrowLoadError(IAssetName assetName, ContentLoadErrorType errorType, string reasonPhrase, Exception? exception = null)
{
throw new SContentLoadException(errorType, $"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}", exception);
}
/// Get a file from the mod folder.
/// The expected asset type.
/// The asset path relative to the content folder.
private FileInfo GetModFile(string path)
{
// get exact file
FileInfo file = this.FileLookup.GetFile(path);
// try with default image extensions
if (!file.Exists && typeof(Texture2D).IsAssignableFrom(typeof(T)) && !ModContentManager.LocalTilesheetExtensions.Contains(file.Extension, StringComparer.OrdinalIgnoreCase))
{
foreach (string extension in ModContentManager.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 void PremultiplyTransparency(Texture2D texture)
{
int count = texture.Width * texture.Height;
Color[] data = ArrayPool.Shared.Rent(count);
try
{
texture.GetData(data, 0, count);
bool changed = false;
for (int i = 0; i < count; i++)
{
ref Color pixel = ref 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, 0, count);
}
finally
{
ArrayPool.Shared.Return(data);
}
}
/// 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[(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(ContentLoadErrorType.InvalidData, $"{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(ContentLoadErrorType.InvalidData, $"{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)
{
if (ex is SContentLoadException)
throw;
throw new SContentLoadException(ContentLoadErrorType.InvalidData, $"{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)
{
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
AssetName 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
assetName = null;
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", StringComparison.OrdinalIgnoreCase))
path += ".xnb";
// get file
return File.Exists(path);
}
/// 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[..^4];
return key;
}
}
}