using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using Microsoft.Xna.Framework.Content; using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Utilities; using StardewModdingAPI.Metadata; using StardewValley; using xTile; namespace StardewModdingAPI.Framework { /// The central logic for creating content managers, invalidating caches, and propagating asset changes. internal class ContentCoordinator : IDisposable { /********* ** Properties *********/ /// An asset key prefix for assets from SMAPI mod folders. private readonly string ManagedPrefix = "SMAPI"; /// 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; /// The loaded content managers (including the ). private readonly IList ContentManagers = new List(); /// Whether the content coordinator has been disposed. private bool IsDisposed; /********* ** 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 IDictionary> Loaders { get; } = new Dictionary>(); /// Interceptors which edit matching assets after they're loaded. public IDictionary> Editors { get; } = new Dictionary>(); /// 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 localise content. /// Encapsulates monitoring and logging. /// Simplifies access to private code. public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection) { this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); this.Reflection = reflection; this.FullRootDirectory = Path.Combine(Constants.ExecutionPath, rootDirectory); this.ContentManagers.Add( this.MainContentManager = new GameContentManager("Game1.content", serviceProvider, rootDirectory, currentCulture, this, monitor, reflection, this.OnDisposing) ); this.CoreAssets = new CoreAssetPropagator(this.MainContentManager.AssertAndNormaliseAssetName, reflection); } /// 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) { GameContentManager manager = new GameContentManager(name, this.MainContentManager.ServiceProvider, this.MainContentManager.RootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.OnDisposing); 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 root directory to search for content (or null for the default). public ModContentManager CreateModContentManager(string name, string rootDirectory) { ModContentManager manager = new ModContentManager(name, this.MainContentManager.ServiceProvider, rootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.OnDisposing); this.ContentManagers.Add(manager); return manager; } /// Get the current content locale. public string GetLocale() { return this.MainContentManager.GetLocale(LocalizedContentManager.CurrentLanguageCode); } /// 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 internal asset key. /// The unique name for the content manager which should load this asset. /// The internal SMAPI asset key. /// The language code for which to load content. public T LoadAndCloneManagedAsset(string internalKey, string contentManagerID, string relativePath, LocalizedContentManager.LanguageCode language) { // get content manager IContentManager contentManager = this.ContentManagers.FirstOrDefault(p => p.Name == contentManagerID); if (contentManager == null) throw new InvalidOperationException($"The '{contentManagerID}' prefix isn't handled by any mod."); // get cloned asset T data = contentManager.Load(internalKey, language); return contentManager.CloneIfPossible(data); } /// Purge assets from the cache that match one of the interceptors. /// The asset editors for which to purge matching assets. /// The asset loaders for which to purge matching assets. /// Returns the invalidated asset names. public IEnumerable InvalidateCacheFor(IAssetEditor[] editors, IAssetLoader[] loaders) { if (!editors.Any() && !loaders.Any()) return new string[0]; // get CanEdit/Load methods MethodInfo canEdit = typeof(IAssetEditor).GetMethod(nameof(IAssetEditor.CanEdit)); MethodInfo canLoad = typeof(IAssetLoader).GetMethod(nameof(IAssetLoader.CanLoad)); if (canEdit == null || canLoad == null) throw new InvalidOperationException("SMAPI could not access the interceptor methods."); // should never happen // invalidate matching keys return this.InvalidateCache(asset => { // check loaders MethodInfo canLoadGeneric = canLoad.MakeGenericMethod(asset.DataType); if (loaders.Any(loader => (bool)canLoadGeneric.Invoke(loader, new object[] { asset }))) return true; // check editors MethodInfo canEditGeneric = canEdit.MakeGenericMethod(asset.DataType); return editors.Any(editor => (bool)canEditGeneric.Invoke(editor, new object[] { asset })); }); } /// 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((assetName, type) => { IAssetInfo info = new AssetInfo(locale, assetName, type, this.MainContentManager.AssertAndNormaliseAssetName); return predicate(info); }); } /// 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 HashSet removedAssetNames = new HashSet(); foreach (IContentManager contentManager in this.ContentManagers) { foreach (string name in contentManager.InvalidateCache(predicate, dispose)) removedAssetNames.Add(name); } // reload core game assets int reloaded = 0; foreach (string key in removedAssetNames) { if (this.CoreAssets.Propagate(this.MainContentManager, key)) // use an intercepted content manager reloaded++; } // report result if (removedAssetNames.Any()) this.Monitor.Log($"Invalidated {removedAssetNames.Count} asset names: {string.Join(", ", removedAssetNames.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase))}. Reloaded {reloaded} core assets.", LogLevel.Trace); this.Monitor.Log("Invalidated 0 cache entries.", LogLevel.Trace); return removedAssetNames; } /// 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.", LogLevel.Trace); foreach (IContentManager contentManager in this.ContentManagers) contentManager.Dispose(); this.ContentManagers.Clear(); this.MainContentManager = null; } /********* ** 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.ContentManagers.Remove(contentManager); } } }