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.
This commit is contained in:
Jesse Plamondon-Willard 2022-03-22 23:00:18 -04:00
parent d3fbdf484a
commit b07d2340a9
No known key found for this signature in database
GPG Key ID: CF8B1456B3E29F49
5 changed files with 180 additions and 49 deletions

View File

@ -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.

View File

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

View File

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

View File

@ -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>

View File

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