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; using StardewModdingAPI.Internal; using StardewModdingAPI.Metadata; using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; using StardewValley.GameData; using xTile; namespace StardewModdingAPI.Framework { /// The central logic for creating content managers, invalidating caches, and propagating asset changes. internal class ContentCoordinator : IDisposable { /********* ** Fields *********/ /// An asset key prefix for assets from SMAPI mod folders. private readonly string ManagedPrefix = "SMAPI"; /// Whether to enable more aggressive memory optimizations. private readonly bool AggressiveMemoryOptimizations; /// Encapsulates monitoring and logging. private readonly IMonitor Monitor; /// Provides metadata for core game assets. private readonly CoreAssetPropagator CoreAssets; /// Simplifies access to private code. private readonly Reflector Reflection; /// Encapsulates SMAPI's JSON file parsing. private readonly JsonHelper JsonHelper; /// A callback to invoke the first time *any* game content manager loads an asset. private readonly Action OnLoadingFirstAsset; /// A callback to invoke when an asset is fully loaded. private readonly Action OnAssetLoaded; /// A callback to invoke when any asset names have been invalidated from the cache. private readonly Action> OnAssetsInvalidated; /// Get the load/edit operations to apply to an asset by querying registered event handlers. private readonly Func> RequestAssetOperations; /// The loaded content managers (including the ). private readonly List ContentManagers = new(); /// The language code for language-agnostic mod assets. private readonly LocalizedContentManager.LanguageCode DefaultLanguage = Constants.DefaultLanguage; /// Whether the content coordinator has been disposed. private bool IsDisposed; /// A lock used to prevent asynchronous changes to the content manager list. /// The game may add content managers in asynchronous threads (e.g. when populating the load screen). private readonly ReaderWriterLockSlim ContentManagerLock = new(); /// A cache of ordered tilesheet IDs used by vanilla maps. private readonly Dictionary VanillaTilesheets = new(StringComparer.OrdinalIgnoreCase); /// An unmodified content manager which doesn't intercept assets, used to compare asset data. private readonly LocalizedContentManager VanillaContentManager; /// The language enum values indexed by locale code. private Lazy> LocaleCodes; /// The cached asset load/edit operations to apply, indexed by asset name. private readonly TickCacheDictionary AssetOperationsByKey = new(); /********* ** Accessors *********/ /// The primary content manager used for most assets. public GameContentManager MainContentManager { get; private set; } /// The current language as a constant. public LocalizedContentManager.LanguageCode Language => this.MainContentManager.Language; /// Interceptors which provide the initial versions of matching assets. public IList> Loaders { get; } = new List>(); /// Interceptors which edit matching assets after they're loaded. public IList> Editors { get; } = new List>(); /// The absolute path to the . public string FullRootDirectory { get; } /********* ** Public methods *********/ /// Construct an instance. /// The service provider to use to locate services. /// The root directory to search for content. /// The current culture for which to localize content. /// Encapsulates monitoring and logging. /// Simplifies access to private code. /// Encapsulates SMAPI's JSON file parsing. /// A callback to invoke the first time *any* game content manager loads an asset. /// A callback to invoke when an asset is fully loaded. /// Whether to enable more aggressive memory optimizations. /// A callback to invoke when any asset names have been invalidated from the cache. /// Get the load/edit operations to apply to an asset by querying registered event handlers. public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, Action onAssetLoaded, bool aggressiveMemoryOptimizations, Action> onAssetsInvalidated, Func> requestAssetOperations) { this.AggressiveMemoryOptimizations = aggressiveMemoryOptimizations; 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.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, aggressiveMemoryOptimizations: aggressiveMemoryOptimizations ) ); 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, aggressiveMemoryOptimizations: aggressiveMemoryOptimizations ); this.ContentManagers.Add(contentManagerForAssetPropagation); this.VanillaContentManager = new LocalizedContentManager(serviceProvider, rootDirectory); this.CoreAssets = new CoreAssetPropagator(this.MainContentManager, contentManagerForAssetPropagation, this.Monitor, reflection, aggressiveMemoryOptimizations, this.ParseAssetName); this.LocaleCodes = new Lazy>(() => this.GetLocaleCodes(customLanguages: Enumerable.Empty())); } /// Get a new content manager which handles reading files from the game content folder with support for interception. /// A name for the mod manager. Not guaranteed to be unique. 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, aggressiveMemoryOptimizations: this.AggressiveMemoryOptimizations ); this.ContentManagers.Add(manager); return manager; }); } /// Get a new content manager which handles reading files from a SMAPI mod folder with support for unpacked files. /// A name for the mod manager. Not guaranteed to be unique. /// The mod display name to show in errors. /// The root directory to search for content (or null for the default). /// The game content manager used for map tilesheets not provided by the mod. 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, aggressiveMemoryOptimizations: this.AggressiveMemoryOptimizations ); this.ContentManagers.Add(manager); return manager; }); } /// Get the current content locale. public string GetLocale() { return this.MainContentManager.GetLocale(LocalizedContentManager.CurrentLanguageCode); } /// Perform any updates needed when the game loads custom languages from Data/AdditionalLanguages. 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>("Data/AdditionalLanguages"); this.LocaleCodes = new Lazy>(() => this.GetLocaleCodes(customLanguages)); _ = this.LocaleCodes.Value; } /// Perform any updates needed when the locale changes. public void OnLocaleChanged() { // reload affected content this.ContentManagerLock.InReadLock(() => { foreach (IContentManager contentManager in this.ContentManagers) contentManager.OnLocaleChanged(); this.VanillaContentManager.Unload(); }); } /// Clean up when the player is returning to the title screen. /// This is called after the player returns to the title screen, but before runs. 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); } /// Parse a raw asset name. /// The raw asset name to parse. /// The is null or empty. public AssetName ParseAssetName(string rawName) { return !string.IsNullOrWhiteSpace(rawName) ? AssetName.Parse(rawName, parseLocale: locale => this.LocaleCodes.Value.TryGetValue(locale, out LocalizedContentManager.LanguageCode langCode) ? langCode : null) : throw new ArgumentException("The asset name can't be null or empty.", nameof(rawName)); } /// Get whether this asset is mapped to a mod folder. /// The asset name. public bool IsManagedAssetKey(IAssetName key) { return key.StartsWith(this.ManagedPrefix); } /// Parse a managed SMAPI asset key which maps to a mod folder. /// The asset key. /// The unique name for the content manager which should load this asset. /// The asset name within the mod folder. /// Returns whether the asset was parsed successfully. public bool TryParseManagedAssetKey(string key, out string contentManagerID, 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]); return true; } /// Get the managed asset key prefix for a mod. /// The mod's unique ID. public string GetManagedAssetPrefix(string modID) { return Path.Combine(this.ManagedPrefix, modID.ToLower()); } /// Get whether an asset from a mod folder exists. /// The unique name for the content manager which should load this asset. /// The asset name within the mod folder. public bool DoesManagedAssetExist(string contentManagerID, IAssetName assetName) { // 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(assetName); } /// Get a copy of an asset from a mod folder. /// The asset type. /// The unique name for the content manager which should load this asset. /// The asset name within the mod folder. public T LoadManagedAsset(string contentManagerID, IAssetName relativePath) { // 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.Load(relativePath, this.DefaultLanguage, useCache: false); } /// Purge matched assets from the cache. /// Matches the asset keys to invalidate. /// Whether to dispose invalidated assets. This should only be true when they're being invalidated as part of a dispose, to avoid crashing the game. /// Returns the invalidated asset keys. public IEnumerable InvalidateCache(Func predicate, bool dispose = false) { string locale = this.GetLocale(); return this.InvalidateCache((_, rawName, type) => { IAssetName assetName = this.ParseAssetName(rawName); IAssetInfo info = new AssetInfo(locale, assetName, type, this.MainContentManager.AssertAndNormalizeAssetName); return predicate(info); }, dispose); } /// Purge matched assets from the cache. /// Matches the asset keys to invalidate. /// Whether to dispose invalidated assets. This should only be true when they're being invalidated as part of a dispose, to avoid crashing the game. /// Returns the invalidated asset names. public IEnumerable InvalidateCache(Func predicate, bool dispose = false) { // invalidate cache & track removed assets IDictionary invalidatedAssets = new Dictionary(); this.ContentManagerLock.InReadLock(() => { // cached assets foreach (IContentManager contentManager in this.ContentManagers) { foreach ((string key, object asset) in contentManager.InvalidateCache((key, type) => predicate(contentManager, key, type), dispose)) { AssetName assetName = this.ParseAssetName(key); if (!invalidatedAssets.ContainsKey(assetName)) invalidatedAssets[assetName] = asset.GetType(); } } // 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)); 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); // propagate changes to the game this.CoreAssets.Propagate( assets: invalidatedAssets.ToDictionary(p => p.Key, p => p.Value), ignoreWorld: Context.IsWorldFullyUnloaded, out IDictionary propagated, out bool updatedNpcWarps ); // 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 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 (updatedNpcWarps) report.AppendLine("Updated NPC pathfinding cache."); } this.Monitor.Log(report.ToString().TrimEnd()); } else this.Monitor.Log("Invalidated 0 cache entries."); return invalidatedAssets.Keys; } /// Get the asset load and edit operations to apply to a given asset if it's (re)loaded now. /// The asset type. /// The asset info to load or edit. public IEnumerable GetAssetOperations(IAssetInfo info) { return this.AssetOperationsByKey.GetOrSet( info.Name, () => this.GetAssetOperationsWithoutCache(info).ToArray() ); } /// Get all loaded instances of an asset name. /// The asset name. [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "This method is provided for Content Patcher.")] public IEnumerable GetLoadedValues(IAssetName assetName) { return this.ContentManagerLock.InReadLock(() => { List values = new List(); foreach (IContentManager content in this.ContentManagers.Where(p => !p.IsNamespaced && p.IsLoaded(assetName, p.Language))) { object value = content.Load(assetName, this.Language, useCache: true); values.Add(value); } return values; }); } /// Get the tilesheet ID order used by the unmodified version of a map asset. /// The asset path relative to the loader root directory, not including the .xnb extension. 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(); } /// Get the locale code which corresponds to a language enum (e.g. fr-FR given ). /// The language enum to search. public string GetLocaleCode(LocalizedContentManager.LanguageCode language) { if (language == LocalizedContentManager.LanguageCode.mod && LocalizedContentManager.CurrentModLanguage == null) return null; return this.MainContentManager.LanguageCodeString(language); } /// Dispose held resources. 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; this.ContentManagerLock.Dispose(); } /********* ** Private methods *********/ /// A callback invoked when a content manager is disposed. /// The content manager being disposed. private void OnDisposing(IContentManager contentManager) { if (this.IsDisposed) return; this.ContentManagerLock.InWriteLock(() => this.ContentManagers.Remove(contentManager) ); } /// Get a vanilla asset without interception. /// The type of asset to load. /// The asset path relative to the loader root directory, not including the .xnb extension. /// The loaded asset data. private bool TryLoadVanillaAsset(string assetName, out T asset) { try { asset = this.VanillaContentManager.Load(assetName); return true; } catch { asset = default; return false; } } /// Get the language enums (like ) indexed by locale code (like ja-JP). /// The custom languages to add to the lookup. private Dictionary GetLocaleCodes(IEnumerable customLanguages) { var map = new Dictionary(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; } /// Get the asset load and edit operations to apply to a given asset if it's (re)loaded now, ignoring the cache. /// The asset type. /// The asset info to load or edit. private IEnumerable GetAssetOperationsWithoutCache(IAssetInfo info) { // new content API foreach (AssetOperationGroup group in this.RequestAssetOperations(info)) yield return group; // legacy load operations foreach (ModLinked loader in this.Loaders) { // check if loader applies try { if (!loader.Data.CanLoad(info)) continue; } catch (Exception ex) { loader.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{info.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); continue; } // add operation yield return new AssetOperationGroup( mod: loader.Mod, loadOperations: new[] { new AssetLoadOperation(loader.Mod, assetInfo => loader.Data.Load(assetInfo)) }, editOperations: Array.Empty() ); } // legacy edit operations foreach (var editor in this.Editors) { // check if editor applies try { if (!editor.Data.CanEdit(info)) continue; } catch (Exception ex) { editor.Mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{info.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); continue; } // add operation yield return new AssetOperationGroup( mod: editor.Mod, loadOperations: Array.Empty(), editOperations: new[] { new AssetEditOperation(editor.Mod, assetData => editor.Data.Edit(assetData)) } ); } } } }