diff options
-rw-r--r-- | docs/release-notes.md | 1 | ||||
-rw-r--r-- | src/SMAPI/Framework/Content/AssetOperationGroup.cs | 33 | ||||
-rw-r--r-- | src/SMAPI/Framework/ContentCoordinator.cs | 100 | ||||
-rw-r--r-- | src/SMAPI/Framework/ContentManagers/GameContentManager.cs | 44 | ||||
-rw-r--r-- | src/SMAPI/Framework/Utilities/TickCacheDictionary.cs | 51 |
5 files changed, 180 insertions, 49 deletions
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 +{ + /// <summary>A set of operations to apply to an asset for a given <see cref="IAssetEditor"/> or <see cref="IAssetLoader"/> implementation.</summary> + internal class AssetOperationGroup + { + /********* + ** Accessors + *********/ + /// <summary>The mod applying the changes.</summary> + public IModMetadata Mod { get; } + + /// <summary>The load operations to apply.</summary> + public AssetLoadOperation[] LoadOperations { get; } + + /// <summary>The edit operations to apply.</summary> + public AssetEditOperation[] EditOperations { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="mod">The mod applying the changes.</param> + /// <param name="loadOperations">The load operations to apply.</param> + /// <param name="editOperations">The edit operations to apply.</param> + 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 /// <summary>The language enum values indexed by locale code.</summary> private Lazy<Dictionary<string, LocalizedContentManager.LanguageCode>> LocaleCodes; + /// <summary>The cached asset load/edit operations to apply, indexed by asset name.</summary> + private readonly TickCacheDictionary<IAssetName, AssetOperationGroup[]> AssetOperationsByKey = new(); + /********* ** Accessors @@ -351,17 +356,17 @@ namespace StardewModdingAPI.Framework public IEnumerable<IAssetName> InvalidateCache(Func<IContentManager, string, Type, bool> predicate, bool dispose = false) { // invalidate cache & track removed assets - IDictionary<IAssetName, Type> removedAssets = new Dictionary<IAssetName, Type>(); + IDictionary<IAssetName, Type> invalidatedAssets = new Dictionary<IAssetName, Type>(); 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<IAssetName, bool> 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<IAssetName> 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; + } + + /// <summary>Get the asset load and edit operations to apply to a given asset if it's (re)loaded now.</summary> + /// <typeparam name="T">The asset type.</typeparam> + /// <param name="info">The asset info to load or edit.</param> + public IEnumerable<AssetOperationGroup> GetAssetOperations<T>(IAssetInfo info) + { + return this.AssetOperationsByKey.GetOrSet( + info.Name, + () => this.GetAssetOperationsWithoutCache<T>(info).ToArray() + ); } /// <summary>Get all loaded instances of an asset name.</summary> @@ -534,5 +554,63 @@ namespace StardewModdingAPI.Framework return map; } + + /// <summary>Get the asset load and edit operations to apply to a given asset if it's (re)loaded now, ignoring the <see cref="AssetOperationsByKey"/> cache.</summary> + /// <typeparam name="T">The asset type.</typeparam> + /// <param name="info">The asset info to load or edit.</param> + private IEnumerable<AssetOperationGroup> GetAssetOperationsWithoutCache<T>(IAssetInfo info) + { + // legacy load operations + foreach (ModLinked<IAssetLoader> loader in this.Loaders) + { + // check if loader applies + try + { + if (!loader.Data.CanLoad<T>(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<T>(assetInfo)) + }, + editOperations: Array.Empty<AssetEditOperation>() + ); + } + + // legacy edit operations + foreach (var editor in this.Editors) + { + // check if editor applies + try + { + if (!editor.Data.CanEdit<T>(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<AssetLoadOperation>(), + editOperations: new[] + { + new AssetEditOperation(editor.Mod, assetData => editor.Data.Edit<T>(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 /// <summary>The assets currently being intercepted by <see cref="IAssetLoader"/> instances. This is used to prevent infinite loops when a loader loads a new asset.</summary> private readonly ContextHash<string> AssetsBeingLoaded = new(); - /// <summary>Interceptors which provide the initial versions of matching assets.</summary> - private IList<ModLinked<IAssetLoader>> Loaders => this.Coordinator.Loaders; - - /// <summary>Interceptors which edit matching assets after they're loaded.</summary> - private IList<ModLinked<IAssetEditor>> Editors => this.Coordinator.Editors; - /// <summary>Maps asset names to their localized form, like <c>LooseSprites\Billboard => LooseSprites\Billboard.fr-FR</c> (localized) or <c>Maps\AnimalShop => Maps\AnimalShop</c> (not localized).</summary> private IDictionary<string, string> LocalizedAssetNames => LocalizedContentManager.localizedAssetNames; @@ -370,22 +364,9 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="info">The basic asset metadata.</param> private IEnumerable<AssetLoadOperation> GetLoaders<T>(IAssetInfo info) { - return this.Loaders - .Where(loader => - { - try - { - return loader.Data.CanLoad<T>(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<T>(assetInfo)) - ); + return this.Coordinator + .GetAssetOperations<T>(info) + .SelectMany(p => p.LoadOperations); } /// <summary>Get the asset editors to apply to an asset.</summary> @@ -393,22 +374,9 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="info">The basic asset metadata.</param> private IEnumerable<AssetEditOperation> GetEditors<T>(IAssetInfo info) { - return this.Editors - .Where(editor => - { - try - { - return editor.Data.CanEdit<T>(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<T>(assetData)) - ); + return this.Coordinator + .GetAssetOperations<T>(info) + .SelectMany(p => p.EditOperations); } /// <summary>Assert that at most one loader will be applied to an asset.</summary> 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 +{ + /// <summary>An in-memory dictionary cache that stores data for the duration of a game update tick.</summary> + /// <typeparam name="TKey">The dictionary key type.</typeparam> + /// <typeparam name="TValue">The dictionary value type.</typeparam> + internal class TickCacheDictionary<TKey, TValue> + { + /********* + ** Fields + *********/ + /// <summary>The last game tick for which data was cached.</summary> + private int LastGameTick = -1; + + /// <summary>The underlying cached data.</summary> + private readonly Dictionary<TKey, TValue> Cache = new(); + + + /********* + ** Public methods + *********/ + /// <summary>Get a value from the cache, fetching it first if it's not cached yet.</summary> + /// <param name="cacheKey">The unique key for the cached value.</param> + /// <param name="get">Get the latest data if it's not in the cache yet.</param> + public TValue GetOrSet(TKey cacheKey, Func<TValue> 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; + } + + /// <summary>Remove an entry from the cache.</summary> + /// <param name="cacheKey">The unique key for the cached value.</param> + /// <returns>Returns whether the key was present in the dictionary.</returns> + public bool Remove(TKey cacheKey) + { + return this.Cache.Remove(cacheKey); + } + } +} |