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