using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; 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; #if SMAPI_DEPRECATED using StardewModdingAPI.Internal; #endif using StardewModdingAPI.Metadata; using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities.PathLookups; using StardewModdingAPI.Utilities; using StardewValley; using StardewValley.GameData; using xTile; namespace StardewModdingAPI.Framework { /// <summary>The central logic for creating content managers, invalidating caches, and propagating asset changes.</summary> internal class ContentCoordinator : IDisposable { /********* ** Fields *********/ /// <summary>An asset key prefix for assets from SMAPI mod folders.</summary> private readonly string ManagedPrefix = "SMAPI"; /// <summary>Whether to use raw image data when possible, instead of initializing an XNA Texture2D instance through the GPU.</summary> private readonly bool UseRawImageLoading; /// <summary>Get a file lookup for the given directory.</summary> private readonly Func<string, IFileLookup> GetFileLookup; /// <summary>Encapsulates monitoring and logging.</summary> private readonly IMonitor Monitor; /// <summary>Provides metadata for core game assets.</summary> private readonly CoreAssetPropagator CoreAssets; /// <summary>Simplifies access to private code.</summary> private readonly Reflector Reflection; /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> private readonly JsonHelper JsonHelper; /// <summary>A callback to invoke the first time *any* game content manager loads an asset.</summary> private readonly Action OnLoadingFirstAsset; /// <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, AssetOperationGroup?> RequestAssetOperations; /// <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(); /// <summary>A cache of ordered tilesheet IDs used by vanilla maps.</summary> 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<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(); #if SMAPI_DEPRECATED /// <summary>A cache of asset operation groups created for legacy <see cref="IAssetLoader"/> implementations.</summary> [Obsolete("This only exists to support legacy code and will be removed in SMAPI 4.0.0.")] private readonly Dictionary<IAssetLoader, Dictionary<Type, AssetLoadOperation>> LegacyLoaderCache = new(ReferenceEqualityComparer.Instance); /// <summary>A cache of asset operation groups created for legacy <see cref="IAssetEditor"/> implementations.</summary> [Obsolete("This only exists to support legacy code and will be removed in SMAPI 4.0.0.")] private readonly Dictionary<IAssetEditor, Dictionary<Type, AssetEditOperation>> LegacyEditorCache = new(ReferenceEqualityComparer.Instance); #endif /********* ** Accessors *********/ /// <summary>The primary content manager used for most assets.</summary> public GameContentManager MainContentManager { get; private set; } /// <summary>The current language as a constant.</summary> public LocalizedContentManager.LanguageCode Language => this.MainContentManager.Language; #if SMAPI_DEPRECATED /// <summary>Interceptors which provide the initial versions of matching assets.</summary> [Obsolete("This only exists to support legacy code and will be removed in SMAPI 4.0.0.")] public IList<ModLinked<IAssetLoader>> Loaders { get; } = new List<ModLinked<IAssetLoader>>(); /// <summary>Interceptors which edit matching assets after they're loaded.</summary> [Obsolete("This only exists to support legacy code and will be removed in SMAPI 4.0.0.")] public IList<ModLinked<IAssetEditor>> Editors { get; } = new List<ModLinked<IAssetEditor>>(); #endif /// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary> public string FullRootDirectory { get; } /// <summary>A lookup which tracks whether each given asset name has a localized form.</summary> /// <remarks>This is a per-screen equivalent to the base game's <see cref="LocalizedContentManager.localizedAssetNames"/> field, since mods may provide different assets per-screen.</remarks> public PerScreen<Dictionary<string, string>> LocalizedAssetNames { get; } = new(() => new()); /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> /// <param name="serviceProvider">The service provider to use to locate services.</param> /// <param name="rootDirectory">The root directory to search for content.</param> /// <param name="currentCulture">The current culture for which to localize content.</param> /// <param name="monitor">Encapsulates monitoring and logging.</param> /// <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="getFileLookup">Get a file lookup for the given directory.</param> /// <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> /// <param name="useRawImageLoading">Whether to use raw image data when possible, instead of initializing an XNA Texture2D instance through the GPU.</param> public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, Action<BaseContentManager, IAssetName> onAssetLoaded, Func<string, IFileLookup> getFileLookup, Action<IList<IAssetName>> onAssetsInvalidated, Func<IAssetInfo, AssetOperationGroup?> requestAssetOperations, bool useRawImageLoading) { this.GetFileLookup = getFileLookup; this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); this.Reflection = reflection; this.JsonHelper = jsonHelper; this.OnLoadingFirstAsset = onLoadingFirstAsset; this.OnAssetLoaded = onAssetLoaded; this.OnAssetsInvalidated = onAssetsInvalidated; this.RequestAssetOperations = requestAssetOperations; this.FullRootDirectory = Path.Combine(Constants.GamePath, rootDirectory); this.UseRawImageLoading = useRawImageLoading; this.ContentManagers.Add( this.MainContentManager = new GameContentManager( name: "Game1.content", serviceProvider: serviceProvider, rootDirectory: rootDirectory, currentCulture: currentCulture, coordinator: this, monitor: monitor, reflection: reflection, onDisposing: this.OnDisposing, onLoadingFirstAsset: onLoadingFirstAsset, onAssetLoaded: onAssetLoaded ) ); var contentManagerForAssetPropagation = new GameContentManagerForAssetPropagation( name: nameof(GameContentManagerForAssetPropagation), serviceProvider: serviceProvider, rootDirectory: rootDirectory, currentCulture: currentCulture, coordinator: this, monitor: monitor, reflection: reflection, onDisposing: this.OnDisposing, onLoadingFirstAsset: onLoadingFirstAsset, onAssetLoaded: onAssetLoaded ); this.ContentManagers.Add(contentManagerForAssetPropagation); this.VanillaContentManager = new LocalizedContentManager(serviceProvider, rootDirectory); this.CoreAssets = new CoreAssetPropagator(this.MainContentManager, contentManagerForAssetPropagation, this.Monitor, reflection, 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> /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param> public GameContentManager CreateGameContentManager(string name) { return this.ContentManagerLock.InWriteLock(() => { GameContentManager manager = new( name: name, serviceProvider: this.MainContentManager.ServiceProvider, rootDirectory: this.MainContentManager.RootDirectory, currentCulture: this.MainContentManager.CurrentCulture, coordinator: this, monitor: this.Monitor, reflection: this.Reflection, onDisposing: this.OnDisposing, onLoadingFirstAsset: this.OnLoadingFirstAsset, onAssetLoaded: this.OnAssetLoaded ); this.ContentManagers.Add(manager); return manager; }); } /// <summary>Get a new content manager which handles reading files from a SMAPI mod folder with support for unpacked files.</summary> /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param> /// <param name="modName">The mod display name to show in errors.</param> /// <param name="rootDirectory">The root directory to search for content (or <c>null</c> for the default).</param> /// <param name="gameContentManager">The game content manager used for map tilesheets not provided by the mod.</param> public ModContentManager CreateModContentManager(string name, string modName, string rootDirectory, IContentManager gameContentManager) { return this.ContentManagerLock.InWriteLock(() => { ModContentManager manager = new( name: name, gameContentManager: gameContentManager, serviceProvider: this.MainContentManager.ServiceProvider, rootDirectory: rootDirectory, modName: modName, currentCulture: this.MainContentManager.CurrentCulture, coordinator: this, monitor: this.Monitor, reflection: this.Reflection, jsonHelper: this.JsonHelper, onDisposing: this.OnDisposing, fileLookup: this.GetFileLookup(rootDirectory), useRawImageLoading: this.UseRawImageLoading ); this.ContentManagers.Add(manager); return manager; }); } /// <summary>Get the current content locale.</summary> public string GetLocale() { return this.MainContentManager.GetLocale(LocalizedContentManager.CurrentLanguageCode); } /// <summary>Perform any updates needed when the game loads custom languages from <c>Data/AdditionalLanguages</c>.</summary> public void OnAdditionalLanguagesInitialized() { // 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; } /// <summary>Perform any updates needed when the locale changes.</summary> public void OnLocaleChanged() { // reset baseline cache this.ContentManagerLock.InReadLock(() => { this.VanillaContentManager.Unload(); }); // forget localized flags (to match the logic in Game1.TranslateFields, which is called on language change) this.LocalizedAssetNames.Value.Clear(); } /// <summary>Clean up when the player is returning to the title screen.</summary> /// <remarks>This is called after the player returns to the title screen, but before <see cref="Game1.CleanupReturningToTitle"/> runs.</remarks> public void OnReturningToTitleScreen() { // The game clears LocalizedContentManager.localizedAssetNames after returning to the title screen. That // causes an inconsistency in the SMAPI asset cache, which leads to an edge case where assets already // provided by mods via IAssetLoader when playing in non-English are ignored. // // For example, let's say a mod provides the 'Data\mail' asset through IAssetLoader when playing in // Portuguese. Here's the normal load process after it's loaded: // 1. The game requests Data\mail. // 2. SMAPI sees that it's already cached, and calls LoadRaw to bypass asset interception. // 3. LoadRaw sees that there's a localized key mapping, and gets the mapped key. // 4. In this case "Data\mail" is mapped to "Data\mail" since it was loaded by a mod, so it loads that // asset. // // When the game clears localizedAssetNames, that process goes wrong in step 4: // 3. LoadRaw sees that there's no localized key mapping *and* the locale is non-English, so it attempts // to load from the localized key format. // 4. In this case that's 'Data\mail.pt-BR', so it successfully loads that asset. // 5. Since we've bypassed asset interception at this point, it's loaded directly from the base content // manager without mod changes. // // To avoid issues, we just remove affected assets from the cache here so they'll be reloaded normally. // 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, _, _) => contentManager is GameContentManager); // clear the localized assets lookup (to match the logic in Game1.CleanupReturningToTitle) foreach ((_, Dictionary<string, string> localizedAssets) in this.LocalizedAssetNames.GetActiveValues()) localizedAssets.Clear(); } /// <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 name.</param> public bool IsManagedAssetKey(IAssetName key) { return key.StartsWith(this.ManagedPrefix); } /// <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 asset name within the mod folder.</param> /// <returns>Returns whether the asset was parsed successfully.</returns> public bool TryParseManagedAssetKey(string key, [NotNullWhen(true)] out string? contentManagerID, [NotNullWhen(true)] out IAssetName? relativePath) { contentManagerID = null; relativePath = null; // not a managed asset if (!key.StartsWith(this.ManagedPrefix)) return false; // parse string[] parts = PathUtilities.GetSegments(key, 3); if (parts.Length != 3) // managed key prefix, mod id, relative path return false; contentManagerID = Path.Combine(parts[0], parts[1]); relativePath = this.ParseAssetName(parts[2], allowLocales: false); return true; } /// <summary>Get the managed asset key prefix for a mod.</summary> /// <param name="modID">The mod's unique ID.</param> public string GetManagedAssetPrefix(string modID) { 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 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(() => 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.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<IAssetName> InvalidateCache(Func<IAssetInfo, bool> predicate, bool dispose = false) { string locale = this.GetLocale(); 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); } /// <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 names.</returns> public IEnumerable<IAssetName> InvalidateCache(Func<IContentManager, string, Type, bool> predicate, bool dispose = false) { // invalidate cache & track removed assets IDictionary<IAssetName, Type> invalidatedAssets = new Dictionary<IAssetName, Type>(); this.ContentManagerLock.InReadLock(() => { // cached assets foreach (IContentManager contentManager in this.ContentManagers) { foreach ((string key, object asset) in contentManager.GetCachedAssets()) { if (!predicate(contentManager, key, asset.GetType())) continue; AssetName assetName = this.ParseAssetName(key, allowLocales: true); contentManager.InvalidateCache(assetName, dispose); if (!invalidatedAssets.ContainsKey(assetName)) invalidatedAssets[assetName] = asset.GetType(); } } // forget localized flags // A mod might provide a localized variant of a normally non-localized asset (like // `Maps/MovieTheater.fr-FR`). When the asset is invalidated, we need to recheck // whether the asset is localized in case it stops providing it. { Dictionary<string, string> localizedAssetNames = this.LocalizedAssetNames.Value; foreach (IAssetName assetName in invalidatedAssets.Keys) { localizedAssetNames.Remove(assetName.Name); if (localizedAssetNames.TryGetValue(assetName.BaseName, out string? targetForBaseKey) && targetForBaseKey == assetName.Name) localizedAssetNames.Remove(assetName.BaseName); } } // special case: maps may be loaded through a temporary content manager that's removed while the map is still in use. // This notably affects the town and farmhouse maps. if (Game1.locations != null) { foreach (GameLocation location in Game1.locations) { if (location.map == null || string.IsNullOrWhiteSpace(location.mapPath.Value)) continue; // get map path 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); } } }); // 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: invalidatedAssets.ToDictionary(p => p.Key, p => p.Value), ignoreWorld: Context.IsWorldFullyUnloaded, out IDictionary<IAssetName, bool> propagated, out bool updatedWarpRoutes ); // log summary StringBuilder report = new(); { 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)); report.AppendLine($"Invalidated {invalidatedKeys.Length} asset names ({FormatKeyList(invalidatedKeys)})."); report.AppendLine(propagated.Count > 0 ? $"Propagated {propagatedKeys.Length} core assets ({FormatKeyList(propagatedKeys)})." : "Propagated 0 core assets." ); if (updatedWarpRoutes) report.AppendLine("Updated NPC warp route cache."); } this.Monitor.Log(report.ToString().TrimEnd()); } else this.Monitor.Log("Invalidated 0 cache entries."); return invalidatedAssets.Keys; } #if SMAPI_DEPRECATED /// <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 AssetOperationGroup? GetAssetOperations<T>(IAssetInfo info) where T : notnull #else /// <summary>Get the asset load and edit operations to apply to a given asset if it's (re)loaded now.</summary> /// <param name="info">The asset info to load or edit.</param> public AssetOperationGroup? GetAssetOperations(IAssetInfo info) #endif { return this.AssetOperationsByKey.GetOrSet( info.Name, #if SMAPI_DEPRECATED () => this.GetAssetOperationsWithoutCache<T>(info) #else () => this.RequestAssetOperations(info) #endif ); } /// <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(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))) { object value = content.LoadExact<object>(assetName, useCache: true); values.Add(value); } return values; }); } /// <summary>Get the tilesheet ID order used by the unmodified version of a map asset.</summary> /// <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)) { 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; this.VanillaTilesheets[assetName] = tilesheets; this.VanillaContentManager.Unload(); } return tilesheets ?? Array.Empty<TilesheetReference>(); } /// <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) { if (language == LocalizedContentManager.LanguageCode.mod && LocalizedContentManager.CurrentModLanguage == null) return null; return this.MainContentManager.LanguageCodeString(language); } /// <summary>Dispose held resources.</summary> public void Dispose() { if (this.IsDisposed) return; this.IsDisposed = true; this.Monitor.Log("Disposing the content coordinator. Content managers will no longer be usable after this point."); foreach (IContentManager contentManager in this.ContentManagers) contentManager.Dispose(); this.ContentManagers.Clear(); this.MainContentManager = null!; // instance no longer usable this.ContentManagerLock.Dispose(); } /********* ** Private methods *********/ /// <summary>A callback invoked when a content manager is disposed.</summary> /// <param name="contentManager">The content manager being disposed.</param> private void OnDisposing(IContentManager contentManager) { if (this.IsDisposed) return; this.ContentManagerLock.InWriteLock(() => this.ContentManagers.Remove(contentManager) ); } /// <summary>Get a vanilla asset without interception.</summary> /// <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, [NotNullWhen(true)] out T? asset) where T : notnull { try { asset = this.VanillaContentManager.Load<T>(assetName); return true; } catch { asset = default; return false; } } /// <summary>Get the language enums (like <see cref="LocalizedContentManager.LanguageCode.ja"/>) indexed by locale code (like <c>ja-JP</c>).</summary> /// <param name="customLanguages">The custom languages to add to the lookup.</param> private Dictionary<string, LocalizedContentManager.LanguageCode> GetLocaleCodes(IEnumerable<ModLanguage?> customLanguages) { 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); if (locale != null) map[locale] = code; } return map; } #if SMAPI_DEPRECATED /// <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 AssetOperationGroup? GetAssetOperationsWithoutCache<T>(IAssetInfo info) where T : notnull { // new content API AssetOperationGroup? group = this.RequestAssetOperations(info); // legacy load operations if (this.Editors.Count > 0 || this.Loaders.Count > 0) { IAssetInfo legacyInfo = this.GetLegacyAssetInfo(info); foreach (ModLinked<IAssetLoader> loader in this.Loaders) { // check if loader applies Context.HeuristicModsRunningCode.Push(loader.Mod); 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; } finally { Context.HeuristicModsRunningCode.TryPop(out _); } // add operation group ??= new AssetOperationGroup(new List<AssetLoadOperation>(), new List<AssetEditOperation>()); group.LoadOperations.Add( this.GetOrCreateLegacyOperation( cache: this.LegacyLoaderCache, editor: loader.Data, dataType: info.DataType, create: () => new AssetLoadOperation( Mod: loader.Mod, OnBehalfOf: null, Priority: AssetLoadPriority.Exclusive, GetData: assetInfo => loader.Data.Load<T>(this.GetLegacyAssetInfo(assetInfo)) ) ) ); } // legacy edit operations foreach (var editor in this.Editors) { // check if editor applies Context.HeuristicModsRunningCode.Push(editor.Mod); 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; } finally { Context.HeuristicModsRunningCode.TryPop(out _); } // 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 group ??= new AssetOperationGroup(new List<AssetLoadOperation>(), new List<AssetEditOperation>()); group.EditOperations.Add( this.GetOrCreateLegacyOperation( cache: this.LegacyEditorCache, editor: editor.Data, dataType: info.DataType, create: () => new AssetEditOperation( Mod: editor.Mod, OnBehalfOf: null, Priority: priority, ApplyEdit: assetData => editor.Data.Edit<T>(this.GetLegacyAssetData(assetData)) ) ) ); } } return group; } /// <summary>Get a cached asset operation group for a legacy <see cref="IAssetLoader"/> or <see cref="IAssetEditor"/> instance, creating it if needed.</summary> /// <typeparam name="TInterceptor">The editor type (one of <see cref="IAssetLoader"/> or <see cref="IAssetEditor"/>).</typeparam> /// <typeparam name="TOperation">The operation model type.</typeparam> /// <param name="cache">The cached operation groups for the interceptor type.</param> /// <param name="editor">The legacy asset interceptor.</param> /// <param name="dataType">The asset data type.</param> /// <param name="create">Create the asset operation group if it's not cached yet.</param> private TOperation GetOrCreateLegacyOperation<TInterceptor, TOperation>(Dictionary<TInterceptor, Dictionary<Type, TOperation>> cache, TInterceptor editor, Type dataType, Func<TOperation> create) where TInterceptor : class { if (!cache.TryGetValue(editor, out Dictionary<Type, TOperation>? cacheByType)) cache[editor] = cacheByType = new Dictionary<Type, TOperation>(); if (!cacheByType.TryGetValue(dataType, out TOperation? operation)) cacheByType[dataType] = operation = create(); return operation; } /// <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; } #endif } }