diff options
Diffstat (limited to 'src/SMAPI/Framework/ContentCoordinator.cs')
-rw-r--r-- | src/SMAPI/Framework/ContentCoordinator.cs | 389 |
1 files changed, 317 insertions, 72 deletions
diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs index 99091f3e..84fff250 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -7,13 +7,17 @@ using System.Linq; using System.Text; using System.Threading; using Microsoft.Xna.Framework.Content; +using StardewModdingAPI.Events; 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; using StardewValley; +using StardewValley.GameData; using xTile; namespace StardewModdingAPI.Framework @@ -45,27 +49,36 @@ namespace StardewModdingAPI.Framework /// <summary>A callback to invoke the first time *any* game content manager loads an asset.</summary> private readonly Action OnLoadingFirstAsset; - /// <summary>The loaded content managers (including the <see cref="MainContentManager"/>).</summary> - private readonly IList<IContentManager> ContentManagers = new List<IContentManager>(); + /// <summary>A callback to invoke when an asset is fully loaded.</summary> + private readonly Action<BaseContentManager, IAssetName> OnAssetLoaded; + + /// <summary>A callback to invoke when any asset names have been invalidated from the cache.</summary> + private readonly Action<IList<IAssetName>> OnAssetsInvalidated; + + /// <summary>Get the load/edit operations to apply to an asset by querying registered <see cref="IContentEvents.AssetRequested"/> event handlers.</summary> + private readonly Func<IAssetInfo, IList<AssetOperationGroup>> RequestAssetOperations; - /// <summary>The language code for language-agnostic mod assets.</summary> - private readonly LocalizedContentManager.LanguageCode DefaultLanguage = Constants.DefaultLanguage; + /// <summary>The loaded content managers (including the <see cref="MainContentManager"/>).</summary> + private readonly List<IContentManager> ContentManagers = new(); /// <summary>Whether the content coordinator has been disposed.</summary> private bool IsDisposed; /// <summary>A lock used to prevent asynchronous changes to the content manager list.</summary> /// <remarks>The game may add content managers in asynchronous threads (e.g. when populating the load screen).</remarks> - private readonly ReaderWriterLockSlim ContentManagerLock = new ReaderWriterLockSlim(); + private readonly ReaderWriterLockSlim ContentManagerLock = new(); /// <summary>A cache of ordered tilesheet IDs used by vanilla maps.</summary> - private readonly IDictionary<string, TilesheetReference[]> VanillaTilesheets = new Dictionary<string, TilesheetReference[]>(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary<string, TilesheetReference[]?> VanillaTilesheets = new(StringComparer.OrdinalIgnoreCase); /// <summary>An unmodified content manager which doesn't intercept assets, used to compare asset data.</summary> private readonly LocalizedContentManager VanillaContentManager; /// <summary>The language enum values indexed by locale code.</summary> - private Lazy<IDictionary<string, LocalizedContentManager.LanguageCode>> LocaleCodes; + 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(); /********* @@ -78,9 +91,11 @@ namespace StardewModdingAPI.Framework public LocalizedContentManager.LanguageCode Language => this.MainContentManager.Language; /// <summary>Interceptors which provide the initial versions of matching assets.</summary> + [Obsolete] public IList<ModLinked<IAssetLoader>> Loaders { get; } = new List<ModLinked<IAssetLoader>>(); /// <summary>Interceptors which edit matching assets after they're loaded.</summary> + [Obsolete] public IList<ModLinked<IAssetEditor>> Editors { get; } = new List<ModLinked<IAssetEditor>>(); /// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary> @@ -98,15 +113,21 @@ namespace StardewModdingAPI.Framework /// <param name="reflection">Simplifies access to private code.</param> /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> /// <param name="onLoadingFirstAsset">A callback to invoke the first time *any* game content manager loads an asset.</param> + /// <param name="onAssetLoaded">A callback to invoke when an asset is fully loaded.</param> /// <param name="aggressiveMemoryOptimizations">Whether to enable more aggressive memory optimizations.</param> - public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, bool aggressiveMemoryOptimizations) + /// <param name="onAssetsInvalidated">A callback to invoke when any asset names have been invalidated from the cache.</param> + /// <param name="requestAssetOperations">Get the load/edit operations to apply to an asset by querying registered <see cref="IContentEvents.AssetRequested"/> event handlers.</param> + public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, Action<BaseContentManager, IAssetName> onAssetLoaded, bool aggressiveMemoryOptimizations, Action<IList<IAssetName>> onAssetsInvalidated, Func<IAssetInfo, IList<AssetOperationGroup>> requestAssetOperations) { this.AggressiveMemoryOptimizations = aggressiveMemoryOptimizations; this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); this.Reflection = reflection; this.JsonHelper = jsonHelper; this.OnLoadingFirstAsset = onLoadingFirstAsset; - this.FullRootDirectory = Path.Combine(Constants.ExecutionPath, rootDirectory); + this.OnAssetLoaded = onAssetLoaded; + this.OnAssetsInvalidated = onAssetsInvalidated; + this.RequestAssetOperations = requestAssetOperations; + this.FullRootDirectory = Path.Combine(Constants.GamePath, rootDirectory); this.ContentManagers.Add( this.MainContentManager = new GameContentManager( name: "Game1.content", @@ -118,6 +139,7 @@ namespace StardewModdingAPI.Framework reflection: reflection, onDisposing: this.OnDisposing, onLoadingFirstAsset: onLoadingFirstAsset, + onAssetLoaded: onAssetLoaded, aggressiveMemoryOptimizations: aggressiveMemoryOptimizations ) ); @@ -131,12 +153,13 @@ namespace StardewModdingAPI.Framework reflection: reflection, onDisposing: this.OnDisposing, onLoadingFirstAsset: onLoadingFirstAsset, + onAssetLoaded: onAssetLoaded, aggressiveMemoryOptimizations: aggressiveMemoryOptimizations ); this.ContentManagers.Add(contentManagerForAssetPropagation); this.VanillaContentManager = new LocalizedContentManager(serviceProvider, rootDirectory); - this.CoreAssets = new CoreAssetPropagator(this.MainContentManager, contentManagerForAssetPropagation, this.Monitor, reflection, aggressiveMemoryOptimizations); - this.LocaleCodes = new Lazy<IDictionary<string, LocalizedContentManager.LanguageCode>>(this.GetLocaleCodes); + this.CoreAssets = new CoreAssetPropagator(this.MainContentManager, contentManagerForAssetPropagation, this.Monitor, reflection, aggressiveMemoryOptimizations, name => this.ParseAssetName(name, allowLocales: true)); + this.LocaleCodes = new Lazy<Dictionary<string, LocalizedContentManager.LanguageCode>>(() => this.GetLocaleCodes(customLanguages: Enumerable.Empty<ModLanguage>())); } /// <summary>Get a new content manager which handles reading files from the game content folder with support for interception.</summary> @@ -145,7 +168,7 @@ namespace StardewModdingAPI.Framework { return this.ContentManagerLock.InWriteLock(() => { - GameContentManager manager = new GameContentManager( + GameContentManager manager = new( name: name, serviceProvider: this.MainContentManager.ServiceProvider, rootDirectory: this.MainContentManager.RootDirectory, @@ -155,6 +178,7 @@ namespace StardewModdingAPI.Framework reflection: this.Reflection, onDisposing: this.OnDisposing, onLoadingFirstAsset: this.OnLoadingFirstAsset, + onAssetLoaded: this.OnAssetLoaded, aggressiveMemoryOptimizations: this.AggressiveMemoryOptimizations ); this.ContentManagers.Add(manager); @@ -171,7 +195,7 @@ namespace StardewModdingAPI.Framework { return this.ContentManagerLock.InWriteLock(() => { - ModContentManager manager = new ModContentManager( + ModContentManager manager = new( name: name, gameContentManager: gameContentManager, serviceProvider: this.MainContentManager.ServiceProvider, @@ -183,7 +207,8 @@ namespace StardewModdingAPI.Framework reflection: this.Reflection, jsonHelper: this.JsonHelper, onDisposing: this.OnDisposing, - aggressiveMemoryOptimizations: this.AggressiveMemoryOptimizations + aggressiveMemoryOptimizations: this.AggressiveMemoryOptimizations, + relativePathCache: CaseInsensitivePathLookup.GetCachedFor(rootDirectory) ); this.ContentManagers.Add(manager); return manager; @@ -196,18 +221,21 @@ namespace StardewModdingAPI.Framework return this.MainContentManager.GetLocale(LocalizedContentManager.CurrentLanguageCode); } - /// <summary>Perform any cleanup needed when the locale changes.</summary> - public void OnLocaleChanged() + /// <summary>Perform any updates needed when the game loads custom languages from <c>Data/AdditionalLanguages</c>.</summary> + public void OnAdditionalLanguagesInitialized() { - // rebuild locale cache (which may change due to custom mod languages) - this.LocaleCodes = new Lazy<IDictionary<string, LocalizedContentManager.LanguageCode>>(this.GetLocaleCodes); + // update locale cache for custom languages, and load it now (since languages added later won't work) + var customLanguages = this.MainContentManager.Load<List<ModLanguage?>>("Data/AdditionalLanguages"); + this.LocaleCodes = new Lazy<Dictionary<string, LocalizedContentManager.LanguageCode>>(() => this.GetLocaleCodes(customLanguages)); + _ = this.LocaleCodes.Value; + } - // reload affected content + /// <summary>Perform any updates needed when the locale changes.</summary> + public void OnLocaleChanged() + { + // reset baseline cache this.ContentManagerLock.InReadLock(() => { - foreach (IContentManager contentManager in this.ContentManagers) - contentManager.OnLocaleChanged(); - this.VanillaContentManager.Unload(); }); } @@ -239,12 +267,28 @@ namespace StardewModdingAPI.Framework // Note that we *must* propagate changes here, otherwise when mods invalidate the cache later to reapply // their changes, the assets won't be found in the cache so no changes will be propagated. if (LocalizedContentManager.CurrentLanguageCode != LocalizedContentManager.LanguageCode.en) - this.InvalidateCache((contentManager, key, type) => contentManager is GameContentManager); + this.InvalidateCache((contentManager, _, _) => contentManager is GameContentManager); + } + + /// <summary>Parse a raw asset name.</summary> + /// <param name="rawName">The raw asset name to parse.</param> + /// <param name="allowLocales">Whether to parse locales in the <paramref name="rawName"/>. If this is false, any locale codes in the name are treated as if they were part of the base name (e.g. for mod files).</param> + /// <exception cref="ArgumentException">The <paramref name="rawName"/> is null or empty.</exception> + public AssetName ParseAssetName(string rawName, bool allowLocales) + { + return !string.IsNullOrWhiteSpace(rawName) + ? AssetName.Parse( + rawName: rawName, + parseLocale: allowLocales + ? locale => this.LocaleCodes.Value.TryGetValue(locale, out LocalizedContentManager.LanguageCode langCode) ? langCode : null + : _ => null + ) + : throw new ArgumentException("The asset name can't be null or empty.", nameof(rawName)); } /// <summary>Get whether this asset is mapped to a mod folder.</summary> - /// <param name="key">The asset key.</param> - public bool IsManagedAssetKey(string key) + /// <param name="key">The asset name.</param> + public bool IsManagedAssetKey(IAssetName key) { return key.StartsWith(this.ManagedPrefix); } @@ -252,9 +296,9 @@ namespace StardewModdingAPI.Framework /// <summary>Parse a managed SMAPI asset key which maps to a mod folder.</summary> /// <param name="key">The asset key.</param> /// <param name="contentManagerID">The unique name for the content manager which should load this asset.</param> - /// <param name="relativePath">The relative path within the mod folder.</param> + /// <param name="relativePath">The asset name within the mod folder.</param> /// <returns>Returns whether the asset was parsed successfully.</returns> - public bool TryParseManagedAssetKey(string key, out string contentManagerID, out string relativePath) + public bool TryParseManagedAssetKey(string key, [NotNullWhen(true)] out string? contentManagerID, [NotNullWhen(true)] out IAssetName? relativePath) { contentManagerID = null; relativePath = null; @@ -268,7 +312,7 @@ namespace StardewModdingAPI.Framework if (parts.Length != 3) // managed key prefix, mod id, relative path return false; contentManagerID = Path.Combine(parts[0], parts[1]); - relativePath = parts[2]; + relativePath = this.ParseAssetName(parts[2], allowLocales: false); return true; } @@ -279,32 +323,52 @@ namespace StardewModdingAPI.Framework return Path.Combine(this.ManagedPrefix, modID.ToLower()); } + /// <summary>Get whether an asset from a mod folder exists.</summary> + /// <typeparam name="T">The expected asset type.</typeparam> + /// <param name="contentManagerID">The unique name for the content manager which should load this asset.</param> + /// <param name="assetName">The asset name within the mod folder.</param> + public bool DoesManagedAssetExist<T>(string contentManagerID, IAssetName assetName) + where T : notnull + { + // get content manager + IContentManager? contentManager = this.ContentManagerLock.InReadLock(() => + this.ContentManagers.FirstOrDefault(p => p.IsNamespaced && p.Name == contentManagerID) + ); + if (contentManager == null) + throw new InvalidOperationException($"The '{contentManagerID}' prefix isn't handled by any mod."); + + // get whether the asset exists + return contentManager.DoesAssetExist<T>(assetName); + } + /// <summary>Get a copy of an asset from a mod folder.</summary> /// <typeparam name="T">The asset type.</typeparam> /// <param name="contentManagerID">The unique name for the content manager which should load this asset.</param> - /// <param name="relativePath">The internal SMAPI asset key.</param> - public T LoadManagedAsset<T>(string contentManagerID, string relativePath) + /// <param name="relativePath">The asset name within the mod folder.</param> + public T LoadManagedAsset<T>(string contentManagerID, IAssetName relativePath) + where T : notnull { // get content manager - IContentManager contentManager = this.ContentManagerLock.InReadLock(() => + IContentManager? contentManager = this.ContentManagerLock.InReadLock(() => this.ContentManagers.FirstOrDefault(p => p.IsNamespaced && p.Name == contentManagerID) ); if (contentManager == null) throw new InvalidOperationException($"The '{contentManagerID}' prefix isn't handled by any mod."); // get fresh asset - return contentManager.Load<T>(relativePath, this.DefaultLanguage, useCache: false); + return contentManager.LoadExact<T>(relativePath, useCache: false); } /// <summary>Purge matched assets from the cache.</summary> /// <param name="predicate">Matches the asset keys to invalidate.</param> /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param> /// <returns>Returns the invalidated asset keys.</returns> - public IEnumerable<string> InvalidateCache(Func<IAssetInfo, bool> predicate, bool dispose = false) + public IEnumerable<IAssetName> InvalidateCache(Func<IAssetInfo, bool> predicate, bool dispose = false) { string locale = this.GetLocale(); - return this.InvalidateCache((contentManager, assetName, type) => + return this.InvalidateCache((_, rawName, type) => { + IAssetName assetName = this.ParseAssetName(rawName, allowLocales: true); IAssetInfo info = new AssetInfo(locale, assetName, type, this.MainContentManager.AssertAndNormalizeAssetName); return predicate(info); }, dispose); @@ -314,19 +378,20 @@ namespace StardewModdingAPI.Framework /// <param name="predicate">Matches the asset keys to invalidate.</param> /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param> /// <returns>Returns the invalidated asset names.</returns> - public IEnumerable<string> InvalidateCache(Func<IContentManager, string, Type, bool> predicate, bool dispose = false) + public IEnumerable<IAssetName> InvalidateCache(Func<IContentManager, string, Type, bool> predicate, bool dispose = false) { // invalidate cache & track removed assets - IDictionary<string, Type> removedAssets = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase); + 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)) { - if (!removedAssets.ContainsKey(entry.Key)) - removedAssets[entry.Key] = entry.Value.GetType(); + AssetName assetName = this.ParseAssetName(key, allowLocales: true); + if (!invalidatedAssets.ContainsKey(assetName)) + invalidatedAssets[assetName] = asset.GetType(); } } @@ -340,31 +405,38 @@ namespace StardewModdingAPI.Framework continue; // get map path - string mapPath = this.MainContentManager.AssertAndNormalizeAssetName(location.mapPath.Value); - if (!removedAssets.ContainsKey(mapPath) && predicate(this.MainContentManager, mapPath, typeof(Map))) - removedAssets[mapPath] = typeof(Map); + AssetName mapPath = this.ParseAssetName(this.MainContentManager.AssertAndNormalizeAssetName(location.mapPath.Value), allowLocales: true); + if (!invalidatedAssets.ContainsKey(mapPath) && predicate(this.MainContentManager, mapPath.Name, typeof(Map))) + invalidatedAssets[mapPath] = typeof(Map); } } }); - // reload core game assets - if (removedAssets.Any()) + // handle invalidation + if (invalidatedAssets.Any()) { + // clear cached editor checks + foreach (IAssetName name in invalidatedAssets.Keys) + this.AssetOperationsByKey.Remove(name); + + // raise event + this.OnAssetsInvalidated(invalidatedAssets.Keys.ToArray()); + // 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<string, bool> propagated, + out IDictionary<IAssetName, bool> propagated, out bool updatedNpcWarps ); // log summary - StringBuilder report = new StringBuilder(); + StringBuilder report = new(); { - string[] invalidatedKeys = removedAssets.Keys.ToArray(); - string[] propagatedKeys = propagated.Where(p => p.Value).Select(p => p.Key).ToArray(); + IAssetName[] invalidatedKeys = invalidatedAssets.Keys.ToArray(); + IAssetName[] propagatedKeys = propagated.Where(p => p.Value).Select(p => p.Key).ToArray(); - string FormatKeyList(IEnumerable<string> keys) => string.Join(", ", keys.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)); + string FormatKeyList(IEnumerable<IAssetName> keys) => string.Join(", ", keys.Select(p => p.Name).OrderBy(p => p, StringComparer.OrdinalIgnoreCase)); report.AppendLine($"Invalidated {invalidatedKeys.Length} asset names ({FormatKeyList(invalidatedKeys)})."); report.AppendLine(propagated.Count > 0 @@ -379,20 +451,32 @@ 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) + where T : notnull + { + return this.AssetOperationsByKey.GetOrSet( + info.Name, + () => this.GetAssetOperationsWithoutCache<T>(info).ToArray() + ); } /// <summary>Get all loaded instances of an asset name.</summary> /// <param name="assetName">The asset name.</param> [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "This method is provided for Content Patcher.")] - public IEnumerable<object> GetLoadedValues(string assetName) + public IEnumerable<object> GetLoadedValues(IAssetName assetName) { return this.ContentManagerLock.InReadLock(() => { List<object> values = new List<object>(); - foreach (IContentManager content in this.ContentManagers.Where(p => !p.IsNamespaced && p.IsLoaded(assetName, p.Language))) + foreach (IContentManager content in this.ContentManagers.Where(p => !p.IsNamespaced && p.IsLoaded(assetName))) { - object value = content.Load<object>(assetName, this.Language, useCache: true); + object value = content.LoadExact<object>(assetName, useCache: true); values.Add(value); } return values; @@ -403,9 +487,9 @@ namespace StardewModdingAPI.Framework /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> public TilesheetReference[] GetVanillaTilesheetIds(string assetName) { - if (!this.VanillaTilesheets.TryGetValue(assetName, out TilesheetReference[] tilesheets)) + if (!this.VanillaTilesheets.TryGetValue(assetName, out TilesheetReference[]? tilesheets)) { - tilesheets = this.TryLoadVanillaAsset(assetName, out Map map) + tilesheets = this.TryLoadVanillaAsset(assetName, out Map? map) ? map.TileSheets.Select((sheet, index) => new TilesheetReference(index, sheet.Id, sheet.ImageSource, sheet.SheetSize, sheet.TileSize)).ToArray() : null; @@ -416,23 +500,14 @@ namespace StardewModdingAPI.Framework return tilesheets ?? Array.Empty<TilesheetReference>(); } - /// <summary>Get the language enum which corresponds to a locale code (e.g. <see cref="LocalizedContentManager.LanguageCode.fr"/> given <c>fr-FR</c>).</summary> - /// <param name="locale">The locale code to search. This must exactly match the language; no fallback is performed.</param> - /// <param name="language">The matched language enum, if any.</param> - /// <returns>Returns whether a valid language was found.</returns> - public bool TryGetLanguageEnum(string locale, out LocalizedContentManager.LanguageCode language) - { - return this.LocaleCodes.Value.TryGetValue(locale, out language); - } - /// <summary>Get the locale code which corresponds to a language enum (e.g. <c>fr-FR</c> given <see cref="LocalizedContentManager.LanguageCode.fr"/>).</summary> /// <param name="language">The language enum to search.</param> - public string GetLocaleCode(LocalizedContentManager.LanguageCode language) + public string? GetLocaleCode(LocalizedContentManager.LanguageCode language) { if (language == LocalizedContentManager.LanguageCode.mod && LocalizedContentManager.CurrentModLanguage == null) return null; - return Game1.content.LanguageCodeString(language); + return this.MainContentManager.LanguageCodeString(language); } /// <summary>Dispose held resources.</summary> @@ -446,7 +521,7 @@ namespace StardewModdingAPI.Framework foreach (IContentManager contentManager in this.ContentManagers) contentManager.Dispose(); this.ContentManagers.Clear(); - this.MainContentManager = null; + this.MainContentManager = null!; // instance no longer usable this.ContentManagerLock.Dispose(); } @@ -471,7 +546,8 @@ namespace StardewModdingAPI.Framework /// <typeparam name="T">The type of asset to load.</typeparam> /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> /// <param name="asset">The loaded asset data.</param> - private bool TryLoadVanillaAsset<T>(string assetName, out T asset) + private bool TryLoadVanillaAsset<T>(string assetName, [NotNullWhen(true)] out T? asset) + where T : notnull { try { @@ -486,17 +562,186 @@ namespace StardewModdingAPI.Framework } /// <summary>Get the language enums (like <see cref="LocalizedContentManager.LanguageCode.ja"/>) indexed by locale code (like <c>ja-JP</c>).</summary> - private IDictionary<string, LocalizedContentManager.LanguageCode> GetLocaleCodes() + /// <param name="customLanguages">The custom languages to add to the lookup.</param> + private Dictionary<string, LocalizedContentManager.LanguageCode> GetLocaleCodes(IEnumerable<ModLanguage?> customLanguages) { - IDictionary<string, LocalizedContentManager.LanguageCode> map = new Dictionary<string, LocalizedContentManager.LanguageCode>(); + var map = new Dictionary<string, LocalizedContentManager.LanguageCode>(StringComparer.OrdinalIgnoreCase); + + // custom languages + foreach (ModLanguage? language in customLanguages) + { + if (!string.IsNullOrWhiteSpace(language?.LanguageCode)) + map[language.LanguageCode] = LocalizedContentManager.LanguageCode.mod; + } + + // vanilla languages (override custom language if they conflict) foreach (LocalizedContentManager.LanguageCode code in Enum.GetValues(typeof(LocalizedContentManager.LanguageCode))) { - string locale = this.GetLocaleCode(code); + string? locale = this.GetLocaleCode(code); if (locale != null) map[locale] = code; } 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) + where T : notnull + { + IAssetInfo legacyInfo = this.GetLegacyAssetInfo(info); + + // new content API + foreach (AssetOperationGroup group in this.RequestAssetOperations(info)) + yield return group; + + // legacy load operations +#pragma warning disable CS0612, CS0618 // deprecated code + foreach (ModLinked<IAssetLoader> loader in this.Loaders) + { + // check if loader applies + try + { + if (!loader.Data.CanLoad<T>(legacyInfo)) + continue; + } + catch (Exception ex) + { + loader.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{legacyInfo.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( + mod: loader.Mod, + priority: AssetLoadPriority.Exclusive, + onBehalfOf: null, + getData: assetInfo => loader.Data.Load<T>( + this.GetLegacyAssetInfo(assetInfo) + ) + ) + }, + editOperations: Array.Empty<AssetEditOperation>() + ); + } + + // legacy edit operations + foreach (var editor in this.Editors) + { + // check if editor applies + try + { + if (!editor.Data.CanEdit<T>(legacyInfo)) + continue; + } + catch (Exception ex) + { + editor.Mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{legacyInfo.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + continue; + } + + // HACK + // + // If two editors have the same priority, they're applied in registration order (so + // whichever was registered first is applied first). Mods often depend on this + // behavior, like Json Assets registering its interceptors before Content Patcher. + // + // Unfortunately the old & new content APIs have separate lists, so new-API + // interceptors always ran before old-API interceptors with the same priority, + // regardless of the registration order *between* APIs. Since the new API works in + // a fundamentally different way (i.e. loads/edits are defined on asset request + // instead of by registering a global 'editor' or 'loader' class), there's no way + // to track registration order between them. + // + // Until we drop the old content API in SMAPI 4.0.0, this sets the priority for + // specific legacy editors to maintain compatibility. + AssetEditPriority priority = editor.Data.GetType().FullName switch + { + "JsonAssets.Framework.ContentInjector1" => AssetEditPriority.Default - 1, // must be applied before Content Patcher + _ => AssetEditPriority.Default + }; + + // add operation + yield return new AssetOperationGroup( + mod: editor.Mod, + loadOperations: Array.Empty<AssetLoadOperation>(), + editOperations: new[] + { + new AssetEditOperation( + mod: editor.Mod, + priority: priority, + onBehalfOf: null, + applyEdit: assetData => editor.Data.Edit<T>( + this.GetLegacyAssetData(assetData) + ) + ) + } + ); + } +#pragma warning restore CS0612, CS0618 + } + + /// <summary>Get an asset info compatible with legacy <see cref="IAssetLoader"/> and <see cref="IAssetEditor"/> instances, which always expect the base name.</summary> + /// <param name="asset">The asset info.</param> + private IAssetInfo GetLegacyAssetInfo(IAssetInfo asset) + { + return new AssetInfo( + locale: this.GetLegacyLocale(asset), + assetName: this.GetLegacyAssetName(asset.Name), + type: asset.DataType, + getNormalizedPath: this.MainContentManager.AssertAndNormalizeAssetName + ); + } + + /// <summary>Get an asset data compatible with legacy <see cref="IAssetLoader"/> and <see cref="IAssetEditor"/> instances, which always expect the base name.</summary> + /// <param name="asset">The asset data.</param> + private IAssetData GetLegacyAssetData(IAssetData asset) + { + return new AssetDataForObject( + locale: this.GetLegacyLocale(asset), + assetName: this.GetLegacyAssetName(asset.Name), + data: asset.Data, + getNormalizedPath: this.MainContentManager.AssertAndNormalizeAssetName, + reflection: this.Reflection, + onDataReplaced: asset.ReplaceWith + ); + } + + /// <summary>Get the <see cref="IAssetInfo.Locale"/> value compatible with legacy <see cref="IAssetLoader"/> and <see cref="IAssetEditor"/> instances, which expect the locale to default to the current game locale or an empty string.</summary> + /// <param name="asset">The non-legacy asset info to map.</param> + private string GetLegacyLocale(IAssetInfo asset) + { + return asset.Locale ?? this.GetLocale(); + } + + /// <summary>Get an asset name compatible with legacy <see cref="IAssetLoader"/> and <see cref="IAssetEditor"/> instances, which always expect the base name.</summary> + /// <param name="asset">The asset name to map.</param> + /// <returns>Returns the legacy asset name if needed, or the <paramref name="asset"/> if no change is needed.</returns> + private IAssetName GetLegacyAssetName(IAssetName asset) + { + // strip _international suffix + const string internationalSuffix = "_international"; + if (asset.Name.EndsWith(internationalSuffix)) + { + return new AssetName( + baseName: asset.Name[..^internationalSuffix.Length], + localeCode: null, + languageCode: null + ); + } + + // else strip locale + if (asset.LocaleCode != null) + return new AssetName(asset.BaseName, null, null); + + // else no change needed + return asset; + } } } |