From f74321addc79a5616cc0f43e4f5f4b8154fac827 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 22 Oct 2017 13:13:14 -0400 Subject: fix SMAPI blocking reflection access to vanilla members on overridden types (#371) --- src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs | 99 ++++++++++++++-------- 1 file changed, 66 insertions(+), 33 deletions(-) (limited to 'src/SMAPI/Framework/ModHelpers') diff --git a/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs b/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs index 8d435416..8788b142 100644 --- a/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs @@ -1,4 +1,5 @@ using System; +using System.Reflection; using StardewModdingAPI.Framework.Reflection; namespace StardewModdingAPI.Framework.ModHelpers @@ -42,8 +43,9 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Returns the field wrapper, or null if the field doesn't exist and is false. public IPrivateField GetPrivateField(object obj, string name, bool required = true) { - this.AssertAccessAllowed(obj); - return this.Reflector.GetPrivateField(obj, name, required); + return this.AssertAccessAllowed( + this.Reflector.GetPrivateField(obj, name, required) + ); } /// Get a private static field. @@ -53,8 +55,9 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Whether to throw an exception if the private field is not found. public IPrivateField GetPrivateField(Type type, string name, bool required = true) { - this.AssertAccessAllowed(type); - return this.Reflector.GetPrivateField(type, name, required); + return this.AssertAccessAllowed( + this.Reflector.GetPrivateField(type, name, required) + ); } /**** @@ -67,8 +70,9 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Whether to throw an exception if the private property is not found. public IPrivateProperty GetPrivateProperty(object obj, string name, bool required = true) { - this.AssertAccessAllowed(obj); - return this.Reflector.GetPrivateProperty(obj, name, required); + return this.AssertAccessAllowed( + this.Reflector.GetPrivateProperty(obj, name, required) + ); } /// Get a private static property. @@ -78,8 +82,9 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Whether to throw an exception if the private property is not found. public IPrivateProperty GetPrivateProperty(Type type, string name, bool required = true) { - this.AssertAccessAllowed(type); - return this.Reflector.GetPrivateProperty(type, name, required); + return this.AssertAccessAllowed( + this.Reflector.GetPrivateProperty(type, name, required) + ); } /**** @@ -98,7 +103,6 @@ namespace StardewModdingAPI.Framework.ModHelpers /// public TValue GetPrivateValue(object obj, string name, bool required = true) { - this.AssertAccessAllowed(obj); IPrivateField field = this.GetPrivateField(obj, name, required); return field != null ? field.GetValue() @@ -117,7 +121,6 @@ namespace StardewModdingAPI.Framework.ModHelpers /// public TValue GetPrivateValue(Type type, string name, bool required = true) { - this.AssertAccessAllowed(type); IPrivateField field = this.GetPrivateField(type, name, required); return field != null ? field.GetValue() @@ -133,8 +136,9 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Whether to throw an exception if the private field is not found. public IPrivateMethod GetPrivateMethod(object obj, string name, bool required = true) { - this.AssertAccessAllowed(obj); - return this.Reflector.GetPrivateMethod(obj, name, required); + return this.AssertAccessAllowed( + this.Reflector.GetPrivateMethod(obj, name, required) + ); } /// Get a private static method. @@ -143,8 +147,9 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Whether to throw an exception if the private field is not found. public IPrivateMethod GetPrivateMethod(Type type, string name, bool required = true) { - this.AssertAccessAllowed(type); - return this.Reflector.GetPrivateMethod(type, name, required); + return this.AssertAccessAllowed( + this.Reflector.GetPrivateMethod(type, name, required) + ); } /**** @@ -157,8 +162,9 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Whether to throw an exception if the private field is not found. public IPrivateMethod GetPrivateMethod(object obj, string name, Type[] argumentTypes, bool required = true) { - this.AssertAccessAllowed(obj); - return this.Reflector.GetPrivateMethod(obj, name, argumentTypes, required); + return this.AssertAccessAllowed( + this.Reflector.GetPrivateMethod(obj, name, argumentTypes, required) + ); } /// Get a private static method. @@ -168,33 +174,60 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Whether to throw an exception if the private field is not found. public IPrivateMethod GetPrivateMethod(Type type, string name, Type[] argumentTypes, bool required = true) { - this.AssertAccessAllowed(type); - return this.Reflector.GetPrivateMethod(type, name, argumentTypes, required); + return this.AssertAccessAllowed( + this.Reflector.GetPrivateMethod(type, name, argumentTypes, required) + ); } /********* ** Private methods *********/ - /// Assert that mods can use the reflection helper to access the given type. - /// The type being accessed. - private void AssertAccessAllowed(Type type) + /// Assert that mods can use the reflection helper to access the given member. + /// The field value type. + /// The field being accessed. + /// Returns the same field instance for convenience. + private IPrivateField AssertAccessAllowed(IPrivateField field) { - // validate type namespace - if (type.Namespace != null) - { - string rootSmapiNamespace = typeof(Program).Namespace; - if (type.Namespace == rootSmapiNamespace || type.Namespace.StartsWith(rootSmapiNamespace + ".")) - throw new InvalidOperationException($"SMAPI blocked access by {this.ModName} to its internals through the reflection API. Accessing the SMAPI internals is strongly discouraged since they're subject to change, which means the mod can break without warning."); - } + this.AssertAccessAllowed(field?.FieldInfo); + return field; } - /// Assert that mods can use the reflection helper to access the given type. - /// The object being accessed. - private void AssertAccessAllowed(object obj) + /// Assert that mods can use the reflection helper to access the given member. + /// The property value type. + /// The property being accessed. + /// Returns the same property instance for convenience. + private IPrivateProperty AssertAccessAllowed(IPrivateProperty property) { - if (obj != null) - this.AssertAccessAllowed(obj.GetType()); + this.AssertAccessAllowed(property?.PropertyInfo); + return property; + } + + /// Assert that mods can use the reflection helper to access the given member. + /// The method being accessed. + /// Returns the same method instance for convenience. + private IPrivateMethod AssertAccessAllowed(IPrivateMethod method) + { + this.AssertAccessAllowed(method?.MethodInfo); + return method; + } + + /// Assert that mods can use the reflection helper to access the given member. + /// The member being accessed. + private void AssertAccessAllowed(MemberInfo member) + { + if (member == null) + return; + + // get type which defines the member + Type declaringType = member.DeclaringType; + if (declaringType == null) + throw new InvalidOperationException($"Can't validate access to {member.MemberType} {member.Name} because it has no declaring type."); // should never happen + + // validate access + string rootNamespace = typeof(Program).Namespace; + if (declaringType.Namespace == rootNamespace || declaringType.Namespace?.StartsWith(rootNamespace + ".") == true) + throw new InvalidOperationException($"SMAPI blocked access by {this.ModName} to its internals through the reflection API. Accessing the SMAPI internals is strongly discouraged since they're subject to change, which means the mod can break without warning. (Detected access to {declaringType.FullName}.{member.Name}.)"); } } } -- cgit From a1eeece49b937c942e2cc002bd1863295d943fde Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Wed, 25 Oct 2017 17:14:58 -0400 Subject: centralise most content-loading logic to fix map tilesheet edge case (#373) --- src/SMAPI/Framework/ModHelpers/ContentHelper.cs | 189 ++---------- src/SMAPI/Framework/SContentManager.cs | 382 +++++++++++++++++++----- src/SMAPI/IContentHelper.cs | 3 +- 3 files changed, 338 insertions(+), 236 deletions(-) (limited to 'src/SMAPI/Framework/ModHelpers') diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs index 4f5bd2f0..2dd8a2e3 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs @@ -1,10 +1,8 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; -using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.Exceptions; @@ -74,12 +72,12 @@ namespace StardewModdingAPI.Framework.ModHelpers this.ContentManager = contentManager; this.ModFolderPath = modFolderPath; this.ModName = modName; - this.ModFolderPathFromContent = this.GetRelativePath(contentManager.FullRootDirectory, modFolderPath); + this.ModFolderPathFromContent = this.ContentManager.GetRelativePath(modFolderPath); this.Monitor = monitor; } /// Load content from the game folder or mod folder (if not already cached), and return it. When loading a .png file, this must be called outside the game's draw loop. - /// The expected data type. The main supported types are and dictionaries; other types may be supported by the game's content pipeline. + /// The expected data type. The main supported types are , , and dictionaries; other types may be supported by the game's content pipeline. /// The asset key to fetch (if the is ), or the local path to a content file relative to the mod folder. /// Where to search for a matching content asset. /// The is empty or contains invalid characters. @@ -88,9 +86,9 @@ namespace StardewModdingAPI.Framework.ModHelpers { SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}: {reasonPhrase}."); - this.AssertValidAssetKeyFormat(key); try { + this.ContentManager.AssertValidAssetKeyFormat(key); switch (source) { case ContentSource.GameContent: @@ -103,60 +101,32 @@ namespace StardewModdingAPI.Framework.ModHelpers throw GetContentError($"there's no matching file at path '{file.FullName}'."); // get asset path - string assetPath = this.GetModAssetPath(key, file.FullName); + string assetName = this.GetModAssetPath(key, file.FullName); // try cache - if (this.ContentManager.IsLoaded(assetPath)) - return this.ContentManager.Load(assetPath); + if (this.ContentManager.IsLoaded(assetName)) + return this.ContentManager.Load(assetName); - // load content - switch (file.Extension.ToLower()) + // fix map tilesheets + if (file.Extension.ToLower() == ".tbin") { - // XNB file - case ".xnb": - { - T asset = this.ContentManager.Load(assetPath); - if (asset is Map) - this.FixLocalMapTilesheets(asset as Map, key); - return asset; - } - - // unpacked map - case ".tbin": - { - // validate - if (typeof(T) != typeof(Map)) - throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'."); - - // fetch & cache - FormatManager formatManager = FormatManager.Instance; - Map map = formatManager.LoadMap(file.FullName); - this.FixLocalMapTilesheets(map, key); - - // inject map - this.ContentManager.Inject(assetPath, map); - return (T)(object)map; - } - - // 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.ContentManager.Inject(assetPath, texture); - return (T)(object)texture; - } - - default: - throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.png', '.tbin', or '.xnb'."); + // validate + if (typeof(T) != typeof(Map)) + throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'."); + + // fetch & cache + FormatManager formatManager = FormatManager.Instance; + Map map = formatManager.LoadMap(file.FullName); + this.FixLocalMapTilesheets(map, key); + + // inject map + this.ContentManager.Inject(assetName, map, this.ContentManager); + return (T)(object)map; } + // load through content manager + return this.ContentManager.Load(assetName); + default: throw GetContentError($"unknown content source '{source}'."); } @@ -264,8 +234,8 @@ namespace StardewModdingAPI.Framework.ModHelpers try { string key = - this.TryLoadTilesheetImageSource(relativeMapFolder, seasonalImageSource) - ?? this.TryLoadTilesheetImageSource(relativeMapFolder, imageSource); + this.GetTilesheetAssetName(relativeMapFolder, seasonalImageSource) + ?? this.GetTilesheetAssetName(relativeMapFolder, imageSource); if (key != null) { tilesheet.ImageSource = key; @@ -282,33 +252,22 @@ namespace StardewModdingAPI.Framework.ModHelpers } } - /// Load a tilesheet image source if the file exists. - /// The folder path containing the map, relative to the mod folder. + /// Get the actual asset name for a tilesheet. + /// The folder path containing the map, relative to the mod folder. /// The tilesheet image source to load. - /// Returns the loaded asset key (if it was loaded successfully). + /// Returns the asset name. /// See remarks on . - private string TryLoadTilesheetImageSource(string relativeMapFolder, string imageSource) + private string GetTilesheetAssetName(string modRelativeMapFolder, string imageSource) { if (imageSource == null) return null; // check relative to map file { - string localKey = Path.Combine(relativeMapFolder, imageSource); + string localKey = Path.Combine(modRelativeMapFolder, imageSource); FileInfo localFile = this.GetModFile(localKey); if (localFile.Exists) - { - try - { - this.Load(localKey); - } - catch (Exception ex) - { - throw new ContentLoadException($"The local '{imageSource}' tilesheet couldn't be loaded.", ex); - } - return this.GetActualAssetKey(localKey); - } } // check relative to content folder @@ -343,18 +302,6 @@ namespace StardewModdingAPI.Framework.ModHelpers return null; } - /// Assert that the given key has a valid format. - /// The asset key to check. - /// The asset key is empty or contains invalid characters. - [SuppressMessage("ReSharper", "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."); - } - /// Get a file from the mod folder. /// The asset path relative to the mod folder. private FileInfo GetModFile(string path) @@ -400,81 +347,5 @@ namespace StardewModdingAPI.Framework.ModHelpers return absolutePath; #endif } - - /// Get a directory path relative to a given root. - /// The root path from which the path should be relative. - /// The target file path. - 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 - } - - /// Premultiply a texture's alpha values to avoid transparency issues in the game. This is only possible if the game isn't currently drawing. - /// The texture to premultiply. - /// Returns a premultiplied texture. - /// Based on code by Layoric. - private Texture2D PremultiplyTransparency(Texture2D texture) - { - // validate - if (Context.IsInDrawLoop) - throw new NotSupportedException("Can't load a PNG file while the game is drawing to the screen. Make sure you load content outside the draw loop."); - - // process texture - SpriteBatch spriteBatch = Game1.spriteBatch; - GraphicsDevice gpu = Game1.graphics.GraphicsDevice; - using (RenderTarget2D renderTarget = new RenderTarget2D(Game1.graphics.GraphicsDevice, texture.Width, texture.Height)) - { - // create blank render target to premultiply - gpu.SetRenderTarget(renderTarget); - gpu.Clear(Color.Black); - - // multiply each color by the source alpha, and write just the color values into the final texture - spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState - { - ColorDestinationBlend = Blend.Zero, - ColorWriteChannels = ColorWriteChannels.Red | ColorWriteChannels.Green | ColorWriteChannels.Blue, - AlphaDestinationBlend = Blend.Zero, - AlphaSourceBlend = Blend.SourceAlpha, - ColorSourceBlend = Blend.SourceAlpha - }); - spriteBatch.Draw(texture, texture.Bounds, Color.White); - spriteBatch.End(); - - // copy the alpha values from the source texture into the final one without multiplying them - spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState - { - ColorWriteChannels = ColorWriteChannels.Alpha, - AlphaDestinationBlend = Blend.Zero, - ColorDestinationBlend = Blend.Zero, - AlphaSourceBlend = Blend.One, - ColorSourceBlend = Blend.One - }); - spriteBatch.Draw(texture, texture.Bounds, Color.White); - spriteBatch.End(); - - // release GPU - gpu.SetRenderTarget(null); - - // extract premultiplied data - Color[] data = new Color[texture.Width * texture.Height]; - renderTarget.GetData(data); - - // unset texture from GPU to regain control - gpu.Textures[0] = null; - - // update texture with premultiplied data - texture.SetData(data); - } - - return texture; - } } } diff --git a/src/SMAPI/Framework/SContentManager.cs b/src/SMAPI/Framework/SContentManager.cs index 0b6daaa6..10d854d9 100644 --- a/src/SMAPI/Framework/SContentManager.cs +++ b/src/SMAPI/Framework/SContentManager.cs @@ -1,13 +1,17 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Contracts; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Threading; +using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.Content; +using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Utilities; using StardewModdingAPI.Metadata; @@ -55,6 +59,9 @@ namespace StardewModdingAPI.Framework /// A lookup of the content managers which loaded each asset. private readonly IDictionary> ContentManagersByAssetKey = new Dictionary>(); + /// The path prefix for assets in mod folders. + private readonly string ModContentPrefix; + /// A lock used to prevents concurrent changes to the cache while data is being read. private readonly ReaderWriterLockSlim Lock = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion); @@ -78,6 +85,9 @@ namespace StardewModdingAPI.Framework /********* ** Public methods *********/ + /**** + ** Constructor + ****/ /// Construct an instance. /// The service provider to use to locate services. /// The root directory to search for content. @@ -92,12 +102,16 @@ namespace StardewModdingAPI.Framework this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); this.Cache = new ContentCache(this, reflection, SContentManager.PossiblePathSeparators, SContentManager.PreferredPathSeparator); this.GetKeyLocale = reflection.GetPrivateMethod(this, "languageCode"); + this.ModContentPrefix = this.GetRelativePath(Constants.ModPath); // get asset data this.CoreAssets = new CoreAssets(this.NormaliseAssetName); this.KeyLocales = this.GetKeyLocales(reflection); } + /**** + ** Asset key/name handling + ****/ /// Normalise path separators in a file path. For asset keys, see instead. /// The file path to normalise. [Pure] @@ -114,6 +128,42 @@ namespace StardewModdingAPI.Framework return this.Cache.NormaliseKey(assetName); } + /// Assert that the given key has a valid format. + /// The asset key to check. + /// The asset key is empty or contains invalid characters. + [SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")] + public void AssertValidAssetKeyFormat(string key) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("The asset key or local path is empty."); + if (key.Intersect(Path.GetInvalidPathChars()).Any()) + throw new ArgumentException("The asset key or local path contains invalid characters."); + } + + /// Get a directory path relative to the content root. + /// The target file path. + 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 + ****/ + /// Get the current content locale. + public string GetLocale() + { + return this.GetKeyLocale.Invoke(); + } + /// Get whether the content manager has already loaded and cached the given asset. /// The asset path relative to the loader root directory, not including the .xnb extension. public bool IsLoaded(string assetName) @@ -122,86 +172,105 @@ namespace StardewModdingAPI.Framework return this.WithReadLock(() => this.IsNormalisedKeyLoaded(assetName)); } - /// Load an asset that has been processed by the content pipeline. - /// The type of asset to load. - /// The asset path relative to the loader root directory, not including the .xnb extension. + /// Get the cached asset keys. + public IEnumerable GetAssetKeys() + { + return this.WithReadLock(() => + this.Cache.Keys + .Select(this.GetAssetName) + .Distinct() + ); + } + + /// Load an asset through the content pipeline. When loading a .png file, this must be called outside the game's draw loop. + /// The expected asset type. + /// The asset path relative to the content directory. public override T Load(string assetName) { return this.LoadFor(assetName, this); } - /// Load an asset that has been processed by the content pipeline. - /// The type of asset to load. - /// The asset path relative to the loader root directory, not including the .xnb extension. + /// Load an asset through the content pipeline. When loading a .png file, this must be called outside the game's draw loop. + /// The expected asset type. + /// The asset path relative to the content directory. /// The content manager instance for which to load the asset. + /// The is empty or contains invalid characters. + /// The content asset couldn't be loaded (e.g. because it doesn't exist). public T LoadFor(string assetName, ContentManager instance) { + // normalise asset key + this.AssertValidAssetKeyFormat(assetName); assetName = this.NormaliseAssetName(assetName); - return this.WithWriteLock(() => + + // load game content + if (!assetName.StartsWith(this.ModContentPrefix)) + return this.LoadImpl(assetName, instance); + + // load mod content + SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading content asset '{assetName}': {reasonPhrase}."); + try { - // skip if already loaded - if (this.IsNormalisedKeyLoaded(assetName)) + return this.WithWriteLock(() => { - this.TrackAssetLoader(assetName, instance); - return base.Load(assetName); - } + // try cache + if (this.IsLoaded(assetName)) + return this.LoadImpl(assetName, instance); - // load asset - T data; - if (this.AssetsBeingLoaded.Contains(assetName)) - { - this.Monitor.Log($"Broke loop while loading asset '{assetName}'.", LogLevel.Warn); - this.Monitor.Log($"Bypassing mod loaders for this asset. Stack trace:\n{Environment.StackTrace}", LogLevel.Trace); - data = base.Load(assetName); - } - else - { - data = this.AssetsBeingLoaded.Track(assetName, () => - { - IAssetInfo info = new AssetInfo(this.GetLocale(), assetName, typeof(T), this.NormaliseAssetName); - IAssetData asset = this.ApplyLoader(info) ?? new AssetDataForObject(info, base.Load(assetName), this.NormaliseAssetName); - asset = this.ApplyEditors(info, asset); - return (T)asset.Data; - }); - } + // get file + FileInfo file = this.GetModFile(assetName); + if (!file.Exists) + throw GetContentError("the specified path doesn't exist."); - // update cache & return data - this.Cache[assetName] = data; - this.TrackAssetLoader(assetName, instance); - return data; - }); + // load content + switch (file.Extension.ToLower()) + { + // XNB file + case ".xnb": + return this.LoadImpl(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); + } } /// Inject an asset into the cache. /// The type of asset to inject. /// The asset path relative to the loader root directory, not including the .xnb extension. /// The asset value. - public void Inject(string assetName, T value) - { - this.WithWriteLock(() => - { - assetName = this.NormaliseAssetName(assetName); - this.Cache[assetName] = value; - this.TrackAssetLoader(assetName, this); - }); - } - - /// Get the current content locale. - public string GetLocale() - { - return this.GetKeyLocale.Invoke(); - } - - /// Get the cached asset keys. - public IEnumerable GetAssetKeys() + /// The content manager instance for which to load the asset. + public void Inject(string assetName, T value, ContentManager instance) { - return this.WithReadLock(() => - this.Cache.Keys - .Select(this.GetAssetName) - .Distinct() - ); + this.WithWriteLock(() => this.InjectWithoutLock(assetName, value, instance)); } + /**** + ** Cache invalidation + ****/ /// Purge assets from the cache that match one of the interceptors. /// The asset editors for which to purge matching assets. /// The asset loaders for which to purge matching assets. @@ -279,6 +348,9 @@ namespace StardewModdingAPI.Framework }); } + /**** + ** Disposal + ****/ /// Dispose assets for the given content manager shim. /// The content manager whose assets to dispose. internal void DisposeFor(ContentManagerShim shim) @@ -297,6 +369,9 @@ namespace StardewModdingAPI.Framework /********* ** Private methods *********/ + /**** + ** Disposal + ****/ /// Dispose held resources. /// Whether the content manager is disposing (rather than finalising). protected override void Dispose(bool disposing) @@ -305,24 +380,9 @@ namespace StardewModdingAPI.Framework base.Dispose(disposing); } - /// Get whether an asset has already been loaded. - /// The normalised asset name. - private bool IsNormalisedKeyLoaded(string normalisedAssetName) - { - return this.Cache.ContainsKey(normalisedAssetName) - || this.Cache.ContainsKey($"{normalisedAssetName}.{this.GetKeyLocale.Invoke()}"); // translated asset - } - - /// Track that a content manager loaded an asset. - /// The asset key that was loaded. - /// The content manager that loaded the asset. - private void TrackAssetLoader(string key, ContentManager manager) - { - if (!this.ContentManagersByAssetKey.TryGetValue(key, out HashSet hash)) - hash = this.ContentManagersByAssetKey[key] = new HashSet(); - hash.Add(manager); - } - + /**** + ** Asset name/key handling + ****/ /// Get the locale codes (like ja-JP) used in asset keys. /// Simplifies access to private game code. private IDictionary GetKeyLocales(Reflector reflection) @@ -385,6 +445,113 @@ namespace StardewModdingAPI.Framework localeCode = null; } + /**** + ** Cache handling + ****/ + /// Get whether an asset has already been loaded. + /// The normalised asset name. + private bool IsNormalisedKeyLoaded(string normalisedAssetName) + { + return this.Cache.ContainsKey(normalisedAssetName) + || this.Cache.ContainsKey($"{normalisedAssetName}.{this.GetKeyLocale.Invoke()}"); // translated asset + } + + /// Track that a content manager loaded an asset. + /// The asset key that was loaded. + /// The content manager that loaded the asset. + private void TrackAssetLoader(string key, ContentManager manager) + { + if (!this.ContentManagersByAssetKey.TryGetValue(key, out HashSet hash)) + hash = this.ContentManagersByAssetKey[key] = new HashSet(); + hash.Add(manager); + } + + /**** + ** Content loading + ****/ + /// Load an asset name without heuristics to support mod content. + /// The type of asset to load. + /// The asset path relative to the loader root directory, not including the .xnb extension. + /// The content manager instance for which to load the asset. + private T LoadImpl(string assetName, ContentManager instance) + { + return this.WithWriteLock(() => + { + // skip if already loaded + if (this.IsNormalisedKeyLoaded(assetName)) + { + this.TrackAssetLoader(assetName, instance); + return base.Load(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(assetName); + } + else + { + data = this.AssetsBeingLoaded.Track(assetName, () => + { + IAssetInfo info = new AssetInfo(this.GetLocale(), assetName, typeof(T), this.NormaliseAssetName); + IAssetData asset = this.ApplyLoader(info) ?? new AssetDataForObject(info, base.Load(assetName), this.NormaliseAssetName); + asset = this.ApplyEditors(info, asset); + return (T)asset.Data; + }); + } + + // update cache & return data + this.InjectWithoutLock(assetName, data, instance); + return data; + }); + } + + /// Inject an asset into the cache without acquiring a write lock. This should only be called from within a write lock. + /// The type of asset to inject. + /// The asset path relative to the loader root directory, not including the .xnb extension. + /// The asset value. + /// The content manager instance for which to load the asset. + private void InjectWithoutLock(string assetName, T value, ContentManager instance) + { + assetName = this.NormaliseAssetName(assetName); + this.Cache[assetName] = value; + this.TrackAssetLoader(assetName, instance); + } + + /// Get a file from the mod folder. + /// The asset path relative to the content folder. + private FileInfo GetModFile(string path) + { + // try exact match + FileInfo file = new FileInfo(Path.Combine(this.FullRootDirectory, path)); + + // try with default extension + if (!file.Exists && file.Extension.ToLower() != ".xnb") + { + FileInfo result = new FileInfo(path + ".xnb"); + if (result.Exists) + file = result; + } + + return file; + } + + /// Get a file from the game's content folder. + /// The asset key. + 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); + } + /// Load the initial asset from the registered . /// The basic asset metadata. /// Returns the loaded asset metadata, or null if no loader matched. @@ -510,6 +677,69 @@ namespace StardewModdingAPI.Framework } } + /// Premultiply a texture's alpha values to avoid transparency issues in the game. This is only possible if the game isn't currently drawing. + /// The texture to premultiply. + /// Returns a premultiplied texture. + /// Based on code by Layoric. + private Texture2D PremultiplyTransparency(Texture2D texture) + { + // validate + if (Context.IsInDrawLoop) + throw new NotSupportedException("Can't load a PNG file while the game is drawing to the screen. Make sure you load content outside the draw loop."); + + // process texture + SpriteBatch spriteBatch = Game1.spriteBatch; + GraphicsDevice gpu = Game1.graphics.GraphicsDevice; + using (RenderTarget2D renderTarget = new RenderTarget2D(Game1.graphics.GraphicsDevice, texture.Width, texture.Height)) + { + // create blank render target to premultiply + gpu.SetRenderTarget(renderTarget); + gpu.Clear(Color.Black); + + // multiply each color by the source alpha, and write just the color values into the final texture + spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState + { + ColorDestinationBlend = Blend.Zero, + ColorWriteChannels = ColorWriteChannels.Red | ColorWriteChannels.Green | ColorWriteChannels.Blue, + AlphaDestinationBlend = Blend.Zero, + AlphaSourceBlend = Blend.SourceAlpha, + ColorSourceBlend = Blend.SourceAlpha + }); + spriteBatch.Draw(texture, texture.Bounds, Color.White); + spriteBatch.End(); + + // copy the alpha values from the source texture into the final one without multiplying them + spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState + { + ColorWriteChannels = ColorWriteChannels.Alpha, + AlphaDestinationBlend = Blend.Zero, + ColorDestinationBlend = Blend.Zero, + AlphaSourceBlend = Blend.One, + ColorSourceBlend = Blend.One + }); + spriteBatch.Draw(texture, texture.Bounds, Color.White); + spriteBatch.End(); + + // release GPU + gpu.SetRenderTarget(null); + + // extract premultiplied data + Color[] data = new Color[texture.Width * texture.Height]; + renderTarget.GetData(data); + + // unset texture from GPU to regain control + gpu.Textures[0] = null; + + // update texture with premultiplied data + texture.SetData(data); + } + + return texture; + } + + /**** + ** Concurrency logic + ****/ /// Acquire a read lock which prevents concurrent writes to the cache while it's open. /// The action's return value. /// The action to perform. diff --git a/src/SMAPI/IContentHelper.cs b/src/SMAPI/IContentHelper.cs index b78b165b..7900809f 100644 --- a/src/SMAPI/IContentHelper.cs +++ b/src/SMAPI/IContentHelper.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using StardewValley; +using xTile; namespace StardewModdingAPI { @@ -29,7 +30,7 @@ namespace StardewModdingAPI ** Public methods *********/ /// Load content from the game folder or mod folder (if not already cached), and return it. When loading a .png file, this must be called outside the game's draw loop. - /// The expected data type. The main supported types are and dictionaries; other types may be supported by the game's content pipeline. + /// The expected data type. The main supported types are , , and dictionaries; other types may be supported by the game's content pipeline. /// The asset key to fetch (if the is ), or the local path to a content file relative to the mod folder. /// Where to search for a matching content asset. /// The is empty or contains invalid characters. -- cgit From f63484e5e76306a08e2f2f2c2f1224cc6b0af1ba Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Fri, 27 Oct 2017 01:17:25 -0400 Subject: minor cleanup (#373) --- src/SMAPI/Framework/ModHelpers/ContentHelper.cs | 26 +++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) (limited to 'src/SMAPI/Framework/ModHelpers') diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs index 2dd8a2e3..ae812e71 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs @@ -117,7 +117,7 @@ namespace StardewModdingAPI.Framework.ModHelpers // fetch & cache FormatManager formatManager = FormatManager.Instance; Map map = formatManager.LoadMap(file.FullName); - this.FixLocalMapTilesheets(map, key); + this.FixCustomTilesheetPaths(map, key); // inject map this.ContentManager.Inject(assetName, map, this.ContentManager); @@ -180,25 +180,27 @@ namespace StardewModdingAPI.Framework.ModHelpers /********* ** Private methods *********/ - /// Fix the tilesheets for a map loaded from the mod folder. + /// Fix custom map tilesheet paths so they can be found by the content manager. /// The map whose tilesheets to fix. /// The map asset key within the mod folder. - /// The map tilesheets could not be loaded. + /// A map tilesheet couldn't be resolved. /// - /// The game's logic for tilesheets in is a bit specialised. It boils down to this: - /// * If the location is indoors or the desert, or the image source contains 'path' or 'object', it's loaded as-is relative to the Content folder. + /// The game's logic for tilesheets in is a bit specialised. It boils + /// down to this: + /// * If the location is indoors or the desert, or the image source contains 'path' or 'object', it's loaded + /// as-is relative to the Content folder. /// * Else it's loaded from Content\Maps with a seasonal prefix. /// /// That logic doesn't work well in our case, mainly because we have no location metadata at this point. /// Instead we use a more heuristic approach: check relative to the map file first, then relative to - /// Content\Maps, then Content. If the image source filename contains a seasonal prefix, we try - /// for a seasonal variation and then an exact match. + /// Content\Maps, then Content. If the image source filename contains a seasonal prefix, try for a + /// seasonal variation and then an exact match. /// /// While that doesn't exactly match the game logic, it's close enough that it's unlikely to make a difference. /// - private void FixLocalMapTilesheets(Map map, string mapKey) + private void FixCustomTilesheetPaths(Map map, string mapKey) { - // check map info + // get map info if (!map.TileSheets.Any()) return; mapKey = this.ContentManager.NormaliseAssetName(mapKey); // Mono's Path.GetDirectoryName doesn't handle Windows dir separators @@ -209,7 +211,7 @@ namespace StardewModdingAPI.Framework.ModHelpers { string imageSource = tilesheet.ImageSource; - // validate + // validate tilesheet path if (Path.IsPathRooted(imageSource) || imageSource.Split(SContentManager.PossiblePathSeparators).Contains("..")) throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded. Tilesheet paths must be a relative path without directory climbing (../)."); @@ -256,7 +258,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// The folder path containing the map, relative to the mod folder. /// The tilesheet image source to load. /// Returns the asset name. - /// See remarks on . + /// See remarks on . private string GetTilesheetAssetName(string modRelativeMapFolder, string imageSource) { if (imageSource == null) @@ -286,7 +288,7 @@ namespace StardewModdingAPI.Framework.ModHelpers catch { // ignore file-not-found errors - // TODO: while it's useful to suppress a asset-not-found error here to avoid + // 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 -- cgit From 08c30eeffd8cc62d00db33d91e3a9a6ab1d376a3 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Mon, 30 Oct 2017 00:02:20 -0400 Subject: let mods invalidate assets matching a predicate (#363) --- docs/release-notes.md | 11 ++++++----- src/SMAPI/Framework/ModHelpers/ContentHelper.cs | 13 +++++++++++-- src/SMAPI/Framework/SContentManager.cs | 26 ++++++++++++++++++------- src/SMAPI/IContentHelper.cs | 5 +++++ 4 files changed, 41 insertions(+), 14 deletions(-) (limited to 'src/SMAPI/Framework/ModHelpers') diff --git a/docs/release-notes.md b/docs/release-notes.md index 99b9b965..c192d4ee 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,15 +1,16 @@ # Release notes ## 2.1 (upcoming) * For players: - * Fixed compatibility check crashing for players with Stardew Valley 1.08. - * Fixed the game's test messages being shown in the console and log. - * Fixed TrainerMod's `player_setlevel` command not also setting XP. - * Renamed the default _TrainerMod_ mod to _Console Commands_ to clarify its purpose. * Added a log parser service at [log.smapi.io](https://log.smapi.io). * Added better Steam instructions to the SMAPI installer. + * Renamed the default _TrainerMod_ mod to _Console Commands_ to clarify its purpose. + * Hid the game's test messages from the console log. + * Fixed compatibility check crashing for players with Stardew Valley 1.08. + * Fixed TrainerMod's `player_setlevel` command not also setting XP. * For modders: - * Added support for public code in reflection API, to simplify mod integrations. + * The reflection API now works with public code to simplify mod integrations. + * The content API now lets you invalidated multiple assets at once. * Improved input events: * Added `e.IsActionButton` and `e.IsUseToolButton`. * Added `ToSButton()` extension for the game's `Game1.options` button type. diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs index ae812e71..711897eb 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs @@ -163,9 +163,9 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Returns whether the given asset key was cached. public bool InvalidateCache(string key) { - this.Monitor.Log($"Requested cache invalidation for '{key}'.", LogLevel.Trace); string actualKey = this.GetActualAssetKey(key, ContentSource.GameContent); - return this.ContentManager.InvalidateCache((otherKey, type) => otherKey.Equals(actualKey, StringComparison.InvariantCultureIgnoreCase)); + this.Monitor.Log($"Requested cache invalidation for '{actualKey}'.", LogLevel.Trace); + return this.ContentManager.InvalidateCache(asset => asset.AssetNameEquals(actualKey)); } /// Remove all assets of the given type from the cache so they're reloaded on the next request. This can be a very expensive operation and should only be used in very specific cases. This will reload core game assets if needed, but references to the former assets will still show the previous content. @@ -177,6 +177,15 @@ namespace StardewModdingAPI.Framework.ModHelpers return this.ContentManager.InvalidateCache((key, type) => typeof(T).IsAssignableFrom(type)); } + /// Remove matching assets from the content cache so they're reloaded on the next request. This will reload core game assets if needed, but references to the former asset will still show the previous content. + /// A predicate matching the assets to invalidate. + /// Returns whether any cache entries were invalidated. + public bool InvalidateCache(Func predicate) + { + this.Monitor.Log("Requested cache invalidation for all assets matching a predicate.", LogLevel.Trace); + return this.ContentManager.InvalidateCache(predicate); + } + /********* ** Private methods *********/ diff --git a/src/SMAPI/Framework/SContentManager.cs b/src/SMAPI/Framework/SContentManager.cs index 54ebba83..a755a6df 100644 --- a/src/SMAPI/Framework/SContentManager.cs +++ b/src/SMAPI/Framework/SContentManager.cs @@ -287,18 +287,30 @@ namespace StardewModdingAPI.Framework throw new InvalidOperationException("SMAPI could not access the interceptor methods."); // should never happen // invalidate matching keys - return this.InvalidateCache((assetName, assetType) => + return this.InvalidateCache(asset => { - IAssetInfo info = new AssetInfo(this.GetLocale(), assetName, assetType, this.NormaliseAssetName); - // check loaders - MethodInfo canLoadGeneric = canLoad.MakeGenericMethod(assetType); - if (loaders.Any(loader => (bool)canLoadGeneric.Invoke(loader, new object[] { info }))) + MethodInfo canLoadGeneric = canLoad.MakeGenericMethod(asset.DataType); + if (loaders.Any(loader => (bool)canLoadGeneric.Invoke(loader, new object[] { asset }))) return true; // check editors - MethodInfo canEditGeneric = canEdit.MakeGenericMethod(assetType); - return editors.Any(editor => (bool)canEditGeneric.Invoke(editor, new object[] { info })); + MethodInfo canEditGeneric = canEdit.MakeGenericMethod(asset.DataType); + return editors.Any(editor => (bool)canEditGeneric.Invoke(editor, new object[] { asset })); + }); + } + + /// Purge matched assets from the cache. + /// Matches the asset keys to invalidate. + /// Whether to dispose invalidated assets. This should only be true when they're being invalidated as part of a dispose, to avoid crashing the game. + /// Returns whether any cache entries were invalidated. + public bool InvalidateCache(Func predicate, bool dispose = false) + { + string locale = this.GetLocale(); + return this.InvalidateCache((assetName, type) => + { + IAssetInfo info = new AssetInfo(locale, assetName, type, this.NormaliseAssetName); + return predicate(info); }); } diff --git a/src/SMAPI/IContentHelper.cs b/src/SMAPI/IContentHelper.cs index 7900809f..e3362502 100644 --- a/src/SMAPI/IContentHelper.cs +++ b/src/SMAPI/IContentHelper.cs @@ -53,5 +53,10 @@ namespace StardewModdingAPI /// The asset type to remove from the cache. /// Returns whether any assets were invalidated. bool InvalidateCache(); + + /// Remove matching assets from the content cache so they're reloaded on the next request. This will reload core game assets if needed, but references to the former asset will still show the previous content. + /// A predicate matching the assets to invalidate. + /// Returns whether any cache entries were invalidated. + bool InvalidateCache(Func predicate); } } -- cgit From a0a72e310d29bb9148e4c33a058823cd33bbb98d Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Mon, 30 Oct 2017 19:26:45 -0400 Subject: explicitly disallow absolute paths as asset keys in content API (#381) --- docs/release-notes.md | 7 ++++--- src/SMAPI/Framework/ModHelpers/ContentHelper.cs | 14 +++++++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) (limited to 'src/SMAPI/Framework/ModHelpers') diff --git a/docs/release-notes.md b/docs/release-notes.md index 6a827b0f..2a50c045 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -19,9 +19,10 @@ * Fixed `e.SuppressButton()` not correctly suppressing keyboard buttons. * Fixed `e.IsClick` (now `e.IsActionButton`) ignoring custom key bindings. * `SemanticVersion` can now be constructed from a `System.Version`. - * Fixed custom map tilesheets not working unless they're explicitly loaded first. - * Fixed mods which implement `IAssetLoader` directly not being allowed to load files due to incorrect conflict detection. - * Fixed SMAPI blocking reflection access to vanilla members on overridden types. + * Fixed reflection API blocking access to vanilla members on overridden types. + * Fixed content API allowing absolute paths as asset keys. + * Fixed content API failing to load custom map tilesheets that aren't preloaded. + * Fixed content API incorrectly detecting duplicate loaders when a mod implements `IAssetLoader` directly. * For SMAPI developers: * Added the SMAPI installer version and platform to the window title to simplify troubleshooting. diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs index 711897eb..be9594ee 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using Microsoft.Xna.Framework.Content; @@ -88,7 +89,7 @@ namespace StardewModdingAPI.Framework.ModHelpers try { - this.ContentManager.AssertValidAssetKeyFormat(key); + this.AssertValidAssetKeyFormat(key); switch (source) { case ContentSource.GameContent: @@ -189,6 +190,17 @@ namespace StardewModdingAPI.Framework.ModHelpers /********* ** Private methods *********/ + /// Assert that the given key has a valid format. + /// The asset key to check. + /// The asset key is empty or contains invalid characters. + [SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")] + private void AssertValidAssetKeyFormat(string key) + { + this.ContentManager.AssertValidAssetKeyFormat(key); + if (Path.IsPathRooted(key)) + throw new ArgumentException("The asset key must not be an absolute path."); + } + /// Fix custom map tilesheet paths so they can be found by the content manager. /// The map whose tilesheets to fix. /// The map asset key within the mod folder. -- cgit