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.Framework.Content; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Metadata; using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; 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; /// The loaded content managers (including the ). private readonly IList ContentManagers = new List(); /// 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 ReaderWriterLockSlim(); /// A cache of ordered tilesheet IDs used by vanilla maps. private readonly IDictionary VanillaTilesheets = new Dictionary(StringComparer.OrdinalIgnoreCase); /// An unmodified content manager which doesn't intercept assets, used to compare asset data. private readonly LocalizedContentManager VanillaContentManager; /********* ** 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. /// Whether to enable more aggressive memory optimizations. public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, bool aggressiveMemoryOptimizations) { 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.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, 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, aggressiveMemoryOptimizations: aggressiveMemoryOptimizations ); this.ContentManagers.Add(contentManagerForAssetPropagation); this.VanillaContentManager = new LocalizedContentManager(serviceProvider, rootDirectory); this.CoreAssets = new CoreAssetPropagator(this.MainContentManager, contentManagerForAssetPropagation, reflection, aggressiveMemoryOptimizations); } /// 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 GameContentManager( 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, 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 ModContentManager( 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 cleanup needed when the locale changes. public void OnLocaleChanged() { 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, key, type) => contentManager is GameContentManager); } /// Get whether this asset is mapped to a mod folder. /// The asset key. public bool IsManagedAssetKey(string 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 relative path within the mod folder. /// Returns whether the asset was parsed successfully. public bool TryParseManagedAssetKey(string key, out string contentManagerID, out string 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 = 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 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 internal SMAPI asset key. public T LoadManagedAsset(string contentManagerID, string 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((contentManager, assetName, type) => { 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 removedAssets = new Dictionary(StringComparer.OrdinalIgnoreCase); this.ContentManagerLock.InReadLock(() => { // cached assets foreach (IContentManager contentManager in this.ContentManagers) { foreach (var entry in contentManager.InvalidateCache((key, type) => predicate(contentManager, key, type), dispose)) { if (!removedAssets.ContainsKey(entry.Key)) removedAssets[entry.Key] = entry.Value.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 string mapPath = this.MainContentManager.AssertAndNormalizeAssetName(location.mapPath.Value); if (!removedAssets.ContainsKey(mapPath) && predicate(this.MainContentManager, mapPath, typeof(Map))) removedAssets[mapPath] = typeof(Map); } } }); // reload core game assets if (removedAssets.Any()) { // propagate changes to the game this.CoreAssets.Propagate( assets: removedAssets.ToDictionary(p => p.Key, p => p.Value), ignoreWorld: Context.IsWorldFullyUnloaded, out IDictionary propagated, out bool updatedNpcWarps ); // log summary StringBuilder report = new StringBuilder(); { string[] invalidatedKeys = removedAssets.Keys.ToArray(); string[] propagatedKeys = propagated.Where(p => p.Value).Select(p => p.Key).ToArray(); string FormatKeyList(IEnumerable keys) => string.Join(", ", keys.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 removedAssets.Keys; } /// 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(string 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)).ToArray() : null; this.VanillaTilesheets[assetName] = tilesheets; this.VanillaContentManager.Unload(); } return tilesheets ?? new TilesheetReference[0]; } /// 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; } } } }