From b07d2340a9a6da22ee0fd95f2c6ccca3939cb7ab Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Tue, 22 Mar 2022 23:00:18 -0400 Subject: [PATCH] encapsulate & cache asset operation groups (#766) This is needed for the upcoming Stardew Valley 1.6 to avoid duplicate checks between DoesAssetExist and Load calls, and to make sure the answer doesn't change between them. --- docs/release-notes.md | 1 + .../Framework/Content/AssetOperationGroup.cs | 33 ++++++ src/SMAPI/Framework/ContentCoordinator.cs | 100 ++++++++++++++++-- .../ContentManagers/GameContentManager.cs | 44 ++------ .../Utilities/TickCacheDictionary.cs | 51 +++++++++ 5 files changed, 180 insertions(+), 49 deletions(-) create mode 100644 src/SMAPI/Framework/Content/AssetOperationGroup.cs create mode 100644 src/SMAPI/Framework/Utilities/TickCacheDictionary.cs diff --git a/docs/release-notes.md b/docs/release-notes.md index 2598dad5..b9385e3f 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -16,6 +16,7 @@ * Added `Constants.ContentPath`. * Added `IAssetName Name` field to the info received by `IAssetEditor` and `IAssetLoader` methods. _This adds methods for working with asset names, parsed locales, etc._ + * If an asset is loaded multiple times in the same tick, `IAssetLoader.CanLoad` and `IAssetEditor.CanEdit` are now cached unless invalidated via `helper.Content.InvalidateCache`. * Fixed the `SDate` constructor being case-sensitive. * Fixed support for using locale codes from custom languages in asset names (e.g. `Data/Achievements.eo-EU`). * Fixed issue where suppressing `[Left|Right]Thumbstick[Down|Left]` keys would suppress the opposite direction instead. diff --git a/src/SMAPI/Framework/Content/AssetOperationGroup.cs b/src/SMAPI/Framework/Content/AssetOperationGroup.cs new file mode 100644 index 00000000..a2fcb722 --- /dev/null +++ b/src/SMAPI/Framework/Content/AssetOperationGroup.cs @@ -0,0 +1,33 @@ +namespace StardewModdingAPI.Framework.Content +{ + /// A set of operations to apply to an asset for a given or implementation. + internal class AssetOperationGroup + { + /********* + ** Accessors + *********/ + /// The mod applying the changes. + public IModMetadata Mod { get; } + + /// The load operations to apply. + public AssetLoadOperation[] LoadOperations { get; } + + /// The edit operations to apply. + public AssetEditOperation[] EditOperations { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod applying the changes. + /// The load operations to apply. + /// The edit operations to apply. + public AssetOperationGroup(IModMetadata mod, AssetLoadOperation[] loadOperations, AssetEditOperation[] editOperations) + { + this.Mod = mod; + this.LoadOperations = loadOperations; + this.EditOperations = editOperations; + } + } +} diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs index fbbbe2d2..bf944e23 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -10,6 +10,8 @@ using Microsoft.Xna.Framework.Content; using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Framework.Utilities; +using StardewModdingAPI.Internal; using StardewModdingAPI.Metadata; using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; @@ -68,6 +70,9 @@ namespace StardewModdingAPI.Framework /// The language enum values indexed by locale code. private Lazy> LocaleCodes; + /// The cached asset load/edit operations to apply, indexed by asset name. + private readonly TickCacheDictionary AssetOperationsByKey = new(); + /********* ** Accessors @@ -351,17 +356,17 @@ namespace StardewModdingAPI.Framework public IEnumerable InvalidateCache(Func predicate, bool dispose = false) { // invalidate cache & track removed assets - IDictionary removedAssets = new Dictionary(); + IDictionary invalidatedAssets = new Dictionary(); this.ContentManagerLock.InReadLock(() => { // cached assets foreach (IContentManager contentManager in this.ContentManagers) { - foreach (var entry in contentManager.InvalidateCache((key, type) => predicate(contentManager, key, type), dispose)) + foreach ((string key, object asset) in contentManager.InvalidateCache((key, type) => predicate(contentManager, key, type), dispose)) { - AssetName assetName = this.ParseAssetName(entry.Key); - if (!removedAssets.ContainsKey(assetName)) - removedAssets[assetName] = entry.Value.GetType(); + AssetName assetName = this.ParseAssetName(key); + if (!invalidatedAssets.ContainsKey(assetName)) + invalidatedAssets[assetName] = asset.GetType(); } } @@ -376,18 +381,22 @@ namespace StardewModdingAPI.Framework // get map path AssetName mapPath = this.ParseAssetName(this.MainContentManager.AssertAndNormalizeAssetName(location.mapPath.Value)); - if (!removedAssets.ContainsKey(mapPath) && predicate(this.MainContentManager, mapPath.Name, typeof(Map))) - removedAssets[mapPath] = typeof(Map); + if (!invalidatedAssets.ContainsKey(mapPath) && predicate(this.MainContentManager, mapPath.Name, typeof(Map))) + invalidatedAssets[mapPath] = typeof(Map); } } }); + // clear cached editor checks + foreach (IAssetName name in invalidatedAssets.Keys) + this.AssetOperationsByKey.Remove(name); + // reload core game assets - if (removedAssets.Any()) + if (invalidatedAssets.Any()) { // propagate changes to the game this.CoreAssets.Propagate( - assets: removedAssets.ToDictionary(p => p.Key, p => p.Value), + assets: invalidatedAssets.ToDictionary(p => p.Key, p => p.Value), ignoreWorld: Context.IsWorldFullyUnloaded, out IDictionary propagated, out bool updatedNpcWarps @@ -396,7 +405,7 @@ namespace StardewModdingAPI.Framework // log summary StringBuilder report = new(); { - IAssetName[] invalidatedKeys = removedAssets.Keys.ToArray(); + IAssetName[] invalidatedKeys = invalidatedAssets.Keys.ToArray(); IAssetName[] propagatedKeys = propagated.Where(p => p.Value).Select(p => p.Key).ToArray(); string FormatKeyList(IEnumerable keys) => string.Join(", ", keys.Select(p => p.Name).OrderBy(p => p, StringComparer.OrdinalIgnoreCase)); @@ -414,7 +423,18 @@ namespace StardewModdingAPI.Framework else this.Monitor.Log("Invalidated 0 cache entries."); - return removedAssets.Keys; + return invalidatedAssets.Keys; + } + + /// Get the asset load and edit operations to apply to a given asset if it's (re)loaded now. + /// The asset type. + /// The asset info to load or edit. + public IEnumerable GetAssetOperations(IAssetInfo info) + { + return this.AssetOperationsByKey.GetOrSet( + info.Name, + () => this.GetAssetOperationsWithoutCache(info).ToArray() + ); } /// Get all loaded instances of an asset name. @@ -534,5 +554,63 @@ namespace StardewModdingAPI.Framework return map; } + + /// Get the asset load and edit operations to apply to a given asset if it's (re)loaded now, ignoring the cache. + /// The asset type. + /// The asset info to load or edit. + private IEnumerable GetAssetOperationsWithoutCache(IAssetInfo info) + { + // legacy load operations + foreach (ModLinked loader in this.Loaders) + { + // check if loader applies + try + { + if (!loader.Data.CanLoad(info)) + continue; + } + catch (Exception ex) + { + loader.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{info.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + continue; + } + + // add operation + yield return new AssetOperationGroup( + mod: loader.Mod, + loadOperations: new[] + { + new AssetLoadOperation(loader.Mod, assetInfo => loader.Data.Load(assetInfo)) + }, + editOperations: Array.Empty() + ); + } + + // legacy edit operations + foreach (var editor in this.Editors) + { + // check if editor applies + try + { + if (!editor.Data.CanEdit(info)) + continue; + } + catch (Exception ex) + { + editor.Mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{info.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + continue; + } + + // add operation + yield return new AssetOperationGroup( + mod: editor.Mod, + loadOperations: Array.Empty(), + editOperations: new[] + { + new AssetEditOperation(editor.Mod, assetData => editor.Data.Edit(assetData)) + } + ); + } + } } } diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs index 7ed1fcda..642e526c 100644 --- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs @@ -26,12 +26,6 @@ namespace StardewModdingAPI.Framework.ContentManagers /// 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(); - /// Interceptors which provide the initial versions of matching assets. - private IList> Loaders => this.Coordinator.Loaders; - - /// Interceptors which edit matching assets after they're loaded. - private IList> Editors => this.Coordinator.Editors; - /// Maps asset names to their localized form, like LooseSprites\Billboard => LooseSprites\Billboard.fr-FR (localized) or Maps\AnimalShop => Maps\AnimalShop (not localized). private IDictionary LocalizedAssetNames => LocalizedContentManager.localizedAssetNames; @@ -370,22 +364,9 @@ namespace StardewModdingAPI.Framework.ContentManagers /// The basic asset metadata. private IEnumerable GetLoaders(IAssetInfo info) { - return this.Loaders - .Where(loader => - { - try - { - return loader.Data.CanLoad(info); - } - catch (Exception ex) - { - loader.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{info.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); - return false; - } - }) - .Select( - loader => new AssetLoadOperation(loader.Mod, assetInfo => loader.Data.Load(assetInfo)) - ); + return this.Coordinator + .GetAssetOperations(info) + .SelectMany(p => p.LoadOperations); } /// Get the asset editors to apply to an asset. @@ -393,22 +374,9 @@ namespace StardewModdingAPI.Framework.ContentManagers /// The basic asset metadata. private IEnumerable GetEditors(IAssetInfo info) { - return this.Editors - .Where(editor => - { - try - { - return editor.Data.CanEdit(info); - } - catch (Exception ex) - { - editor.Mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{info.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); - return false; - } - }) - .Select( - editor => new AssetEditOperation(editor.Mod, assetData => editor.Data.Edit(assetData)) - ); + return this.Coordinator + .GetAssetOperations(info) + .SelectMany(p => p.EditOperations); } /// Assert that at most one loader will be applied to an asset. diff --git a/src/SMAPI/Framework/Utilities/TickCacheDictionary.cs b/src/SMAPI/Framework/Utilities/TickCacheDictionary.cs new file mode 100644 index 00000000..1613a480 --- /dev/null +++ b/src/SMAPI/Framework/Utilities/TickCacheDictionary.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using StardewValley; + +namespace StardewModdingAPI.Framework.Utilities +{ + /// An in-memory dictionary cache that stores data for the duration of a game update tick. + /// The dictionary key type. + /// The dictionary value type. + internal class TickCacheDictionary + { + /********* + ** Fields + *********/ + /// The last game tick for which data was cached. + private int LastGameTick = -1; + + /// The underlying cached data. + private readonly Dictionary Cache = new(); + + + /********* + ** Public methods + *********/ + /// Get a value from the cache, fetching it first if it's not cached yet. + /// The unique key for the cached value. + /// Get the latest data if it's not in the cache yet. + public TValue GetOrSet(TKey cacheKey, Func get) + { + // clear cache on new tick + if (Game1.ticks != this.LastGameTick) + { + this.Cache.Clear(); + this.LastGameTick = Game1.ticks; + } + + // fetch value + if (!this.Cache.TryGetValue(cacheKey, out TValue cached)) + this.Cache[cacheKey] = cached = get(); + return cached; + } + + /// Remove an entry from the cache. + /// The unique key for the cached value. + /// Returns whether the key was present in the dictionary. + public bool Remove(TKey cacheKey) + { + return this.Cache.Remove(cacheKey); + } + } +}