From 61b023916eb92237b3b10b30b77792139de1097d Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Wed, 9 May 2018 23:58:58 -0400 Subject: rewrite content logic to decentralise cache (#488) This is necessary due to changes in Stardew Valley 1.3, which now changes loaded assets and expects those changes to be persisted but not propagated to other content managers. --- src/SMAPI/Framework/SContentManager.cs | 617 +++++++++++++++++++++++++++++++++ 1 file changed, 617 insertions(+) create mode 100644 src/SMAPI/Framework/SContentManager.cs (limited to 'src/SMAPI/Framework/SContentManager.cs') diff --git a/src/SMAPI/Framework/SContentManager.cs b/src/SMAPI/Framework/SContentManager.cs new file mode 100644 index 00000000..8f008041 --- /dev/null +++ b/src/SMAPI/Framework/SContentManager.cs @@ -0,0 +1,617 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Contracts; +using System.Globalization; +using System.IO; +using System.Linq; +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 StardewValley; + +namespace StardewModdingAPI.Framework +{ + /// A minimal content manager which defers to SMAPI's core content logic. + internal class SContentManager : LocalizedContentManager + { + /********* + ** Properties + *********/ + /// The central coordinator which manages content managers. + private readonly ContentCoordinator Coordinator; + + /// The underlying asset cache. + private readonly ContentCache Cache; + + /// Encapsulates monitoring and logging. + private readonly IMonitor Monitor; + + /// A lookup which indicates whether the asset is localisable (i.e. the filename contains the locale), if previously loaded. + private readonly IDictionary IsLocalisableLookup; + + /// The language enum values indexed by locale code. + private readonly IDictionary LanguageCodes; + + /// The assets currently being intercepted by instances. This is used to prevent infinite loops when a loader loads a new asset. + private readonly ContextHash AssetsBeingLoaded = new ContextHash(); + + /// The path prefix for assets in mod folders. + private readonly string ModContentPrefix; + + /// Interceptors which provide the initial versions of matching assets. + private IDictionary> Loaders => this.Coordinator.Loaders; + + /// Interceptors which edit matching assets after they're loaded. + private IDictionary> Editors => this.Coordinator.Editors; + + + /********* + ** Accessors + *********/ + /// A name for the mod manager. Not guaranteed to be unique. + public string Name { get; } + + /// Whether this content manager is wrapped around a mod folder. + public bool IsModFolder { get; } + + /// The current language as a constant. + public LocalizedContentManager.LanguageCode Language => this.GetCurrentLanguage(); + + /// The absolute path to the . + public string FullRootDirectory => Path.Combine(Constants.ExecutionPath, this.RootDirectory); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// A name for the mod manager. Not guaranteed to be unique. + /// The service provider to use to locate services. + /// The root directory to search for content. + /// The current culture for which to localise content. + /// The central coordinator which manages content managers. + /// Encapsulates monitoring and logging. + /// Simplifies access to private code. + /// Whether this content manager is wrapped around a mod folder. + public SContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, bool isModFolder) + : base(serviceProvider, rootDirectory, currentCulture) + { + // init + this.Name = name; + this.IsModFolder = isModFolder; + this.Coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator)); + this.Cache = new ContentCache(this, reflection); + this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); + this.ModContentPrefix = this.GetAssetNameFromFilePath(Constants.ModPath, ContentSource.GameContent); + + // get asset data + this.LanguageCodes = this.GetKeyLocales().ToDictionary(p => p.Value, p => p.Key, StringComparer.InvariantCultureIgnoreCase); + this.IsLocalisableLookup = reflection.GetField>(this, "_localizedAsset").GetValue(); + + } + + /// 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. + public override T Load(string assetName) + { + return this.Load(assetName, LocalizedContentManager.CurrentLanguageCode); + } + + /// 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. + /// The language code for which to load content. + public override T Load(string assetName, LanguageCode language) + { + // normalise asset key + this.AssertValidAssetKeyFormat(assetName); + assetName = this.NormaliseAssetName(assetName); + + // load game content + if (!this.IsModFolder && !assetName.StartsWith(this.ModContentPrefix)) + return this.LoadImpl(assetName, language); + + // load mod content + SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading content asset '{assetName}': {reasonPhrase}"); + try + { + // try cache + if (this.IsLoaded(assetName)) + return this.LoadImpl(assetName, language); + + // get file + FileInfo file = this.GetModFile(assetName); + if (!file.Exists) + throw GetContentError("the specified path doesn't exist."); + + // load content + switch (file.Extension.ToLower()) + { + // XNB file + case ".xnb": + return this.LoadImpl(assetName, language); + + // 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.Inject(assetName, texture); + 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)) + { + if (ex.GetInnermostException() is DllNotFoundException dllEx && dllEx.Message == "libgdiplus.dylib") + throw GetContentError("couldn't find libgdiplus, which is needed to load mod images. Make sure Mono is installed and you're running the game through the normal launcher."); + throw new SContentLoadException($"The content manager failed loading content asset '{assetName}'.", ex); + } + } + + /// Load the base asset without localisation. + /// The type of asset to load. + /// The asset path relative to the loader root directory, not including the .xnb extension. + public override T LoadBase(string assetName) + { + return this.Load(assetName, LanguageCode.en); + } + + /// 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) + { + assetName = this.NormaliseAssetName(assetName); + this.Cache[assetName] = value; + } + + /// Create a new content manager for temporary use. + public override LocalizedContentManager CreateTemporary() + { + return this.Coordinator.CreateContentManager("(temporary)", isModFolder: false); + } + + /// Normalise path separators in a file path. For asset keys, see instead. + /// The file path to normalise. + [Pure] + public string NormalisePathSeparators(string path) + { + return this.Cache.NormalisePathSeparators(path); + } + + /// Normalise an asset name so it's consistent with the underlying cache. + /// The asset key. + [Pure] + public string NormaliseAssetName(string assetName) + { + 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."); + } + + /// Convert an absolute file path into an appropriate asset name. + /// The absolute path to the file. + /// The folder to which to get a relative path. + public string GetAssetNameFromFilePath(string absolutePath, ContentSource relativeTo) + { +#if SMAPI_FOR_WINDOWS + // XNA doesn't allow absolute asset paths, so get a path relative to the source folder + string sourcePath = relativeTo == ContentSource.GameContent ? this.Coordinator.FullRootDirectory : this.FullRootDirectory; + return this.GetRelativePath(sourcePath, absolutePath); +#else + // MonoGame is weird about relative paths on Mac, but allows absolute paths + return absolutePath; +#endif + } + + /**** + ** Content loading + ****/ + /// Get the current content locale. + public string GetLocale() + { + return this.GetLocale(this.GetCurrentLanguage()); + } + + /// The locale for a language. + /// The language. + public string GetLocale(LocalizedContentManager.LanguageCode language) + { + return this.LanguageCodeString(language); + } + + /// 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) + { + assetName = this.Cache.NormaliseKey(assetName); + return this.IsNormalisedKeyLoaded(assetName); + } + + /// Get the cached asset keys. + public IEnumerable GetAssetKeys() + { + return this.Cache.Keys + .Select(this.GetAssetName) + .Distinct(); + } + + /**** + ** Cache invalidation + ****/ + /// 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 the number of invalidated assets. + public IEnumerable InvalidateCache(Func predicate, bool dispose = false) + { + HashSet removeAssetNames = new HashSet(StringComparer.InvariantCultureIgnoreCase); + this.Cache.Remove((key, type) => + { + this.ParseCacheKey(key, out string assetName, out _); + if (removeAssetNames.Contains(assetName) || predicate(assetName, type)) + { + removeAssetNames.Add(assetName); + return true; + } + return false; + }); + + return removeAssetNames; + } + + + /********* + ** Private methods + *********/ + /**** + ** Asset name/key handling + ****/ + /// Get a directory or file path relative to the content root. + /// The source file path. + /// The target file path. + private string GetRelativePath(string sourcePath, string targetPath) + { + return PathUtilities.GetRelativePath(sourcePath, targetPath); + } + + /// Get the locale codes (like ja-JP) used in asset keys. + private IDictionary GetKeyLocales() + { + // create locale => code map + IDictionary map = new Dictionary(); + foreach (LocalizedContentManager.LanguageCode code in Enum.GetValues(typeof(LocalizedContentManager.LanguageCode))) + map[code] = this.GetLocale(code); + + return map; + } + + /// Get the asset name from a cache key. + /// The input cache key. + private string GetAssetName(string cacheKey) + { + this.ParseCacheKey(cacheKey, out string assetName, out string _); + return assetName; + } + + /// Parse a cache key into its component parts. + /// The input cache key. + /// The original asset name. + /// The asset locale code (or null if not localised). + private void ParseCacheKey(string cacheKey, out string assetName, out string localeCode) + { + // handle localised key + if (!string.IsNullOrWhiteSpace(cacheKey)) + { + int lastSepIndex = cacheKey.LastIndexOf(".", StringComparison.InvariantCulture); + if (lastSepIndex >= 0) + { + string suffix = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1); + if (this.LanguageCodes.ContainsKey(suffix)) + { + assetName = cacheKey.Substring(0, lastSepIndex); + localeCode = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1); + return; + } + } + } + + // handle simple key + assetName = cacheKey; + localeCode = null; + } + + /**** + ** Cache handling + ****/ + /// Get whether an asset has already been loaded. + /// The normalised asset name. + private bool IsNormalisedKeyLoaded(string normalisedAssetName) + { + // default English + if (this.Language == LocalizedContentManager.LanguageCode.en) + return this.Cache.ContainsKey(normalisedAssetName); + + // translated + if (!this.IsLocalisableLookup.TryGetValue(normalisedAssetName, out bool localisable)) + return false; + return localisable + ? this.Cache.ContainsKey($"{normalisedAssetName}.{this.GetLocale(this.GetCurrentLanguage())}") + : this.Cache.ContainsKey(normalisedAssetName); + } + + /**** + ** 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 language code for which to load content. + private T LoadImpl(string assetName, LocalizedContentManager.LanguageCode language) + { + // skip if already loaded + if (this.IsNormalisedKeyLoaded(assetName)) + return base.Load(assetName, language); + + // 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, language); + } + else + { + data = this.AssetsBeingLoaded.Track(assetName, () => + { + string locale = this.GetLocale(language); + IAssetInfo info = new AssetInfo(locale, assetName, typeof(T), this.NormaliseAssetName); + IAssetData asset = + this.ApplyLoader(info) + ?? new AssetDataForObject(info, base.Load(assetName, language), this.NormaliseAssetName); + asset = this.ApplyEditors(info, asset); + return (T)asset.Data; + }); + } + + // update cache & return data + this.Inject(assetName, data); + return data; + } + + /// 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(file.FullName + ".xnb"); + if (result.Exists) + file = result; + } + + return file; + } + + /// Load the initial asset from the registered . + /// The basic asset metadata. + /// Returns the loaded asset metadata, or null if no loader matched. + private IAssetData ApplyLoader(IAssetInfo info) + { + // find matching loaders + var loaders = this.GetInterceptors(this.Loaders) + .Where(entry => + { + try + { + return entry.Value.CanLoad(info); + } + catch (Exception ex) + { + entry.Key.LogAsMod($"Mod failed when checking whether it could load asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + return false; + } + }) + .ToArray(); + + // validate loaders + if (!loaders.Any()) + return null; + if (loaders.Length > 1) + { + string[] loaderNames = loaders.Select(p => p.Key.DisplayName).ToArray(); + this.Monitor.Log($"Multiple mods want to provide the '{info.AssetName}' asset ({string.Join(", ", loaderNames)}), but an asset can't be loaded multiple times. SMAPI will use the default asset instead; uninstall one of the mods to fix this. (Message for modders: you should usually use {typeof(IAssetEditor)} instead to avoid conflicts.)", LogLevel.Warn); + return null; + } + + // fetch asset from loader + IModMetadata mod = loaders[0].Key; + IAssetLoader loader = loaders[0].Value; + T data; + try + { + data = loader.Load(info); + this.Monitor.Log($"{mod.DisplayName} loaded asset '{info.AssetName}'.", LogLevel.Trace); + } + catch (Exception ex) + { + mod.LogAsMod($"Mod crashed when loading asset '{info.AssetName}'. SMAPI will use the default asset instead. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + return null; + } + + // validate asset + if (data == null) + { + mod.LogAsMod($"Mod incorrectly set asset '{info.AssetName}' to a null value; ignoring override.", LogLevel.Error); + return null; + } + + // return matched asset + return new AssetDataForObject(info, data, this.NormaliseAssetName); + } + + /// Apply any to a loaded asset. + /// The asset type. + /// The basic asset metadata. + /// The loaded asset. + private IAssetData ApplyEditors(IAssetInfo info, IAssetData asset) + { + IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.NormaliseAssetName); + + // edit asset + foreach (var entry in this.GetInterceptors(this.Editors)) + { + // check for match + IModMetadata mod = entry.Key; + IAssetEditor editor = entry.Value; + try + { + if (!editor.CanEdit(info)) + continue; + } + catch (Exception ex) + { + mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + continue; + } + + // try edit + object prevAsset = asset.Data; + try + { + editor.Edit(asset); + this.Monitor.Log($"{mod.DisplayName} intercepted {info.AssetName}.", LogLevel.Trace); + } + catch (Exception ex) + { + mod.LogAsMod($"Mod crashed when editing asset '{info.AssetName}', which may cause errors in-game. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + } + + // validate edit + if (asset.Data == null) + { + mod.LogAsMod($"Mod incorrectly set asset '{info.AssetName}' to a null value; ignoring override.", LogLevel.Warn); + asset = GetNewData(prevAsset); + } + else if (!(asset.Data is T)) + { + mod.LogAsMod($"Mod incorrectly set asset '{asset.AssetName}' to incompatible type '{asset.Data.GetType()}', expected '{typeof(T)}'; ignoring override.", LogLevel.Warn); + asset = GetNewData(prevAsset); + } + } + + // return result + return asset; + } + + /// Get all registered interceptors from a list. + private IEnumerable> GetInterceptors(IDictionary> entries) + { + foreach (var entry in entries) + { + IModMetadata mod = entry.Key; + IList interceptors = entry.Value; + + // registered editors + foreach (T interceptor in interceptors) + yield return new KeyValuePair(mod, interceptor); + } + } + + /// 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; + } + } +} -- cgit