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 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; /// 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; /********* ** 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.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, 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, this.Monitor, reflection, aggressiveMemoryOptimizations); this.LocaleCodes = new Lazy>(() => this.GetLocaleCodes(includeCustomLanguages: false)); } /// 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, 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) this.LocaleCodes = new Lazy>(() => this.GetLocaleCodes(includeCustomLanguages: true)); _ = 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, key, type) => 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 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, 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 removedAssets = new Dictionary(); this.ContentManagerLock.InReadLock(() => { // cached assets foreach (IContentManager contentManager in this.ContentManagers) { foreach (var entry in contentManager.InvalidateCache((key, type) => predicate(contentManager, key, type), dispose)) { AssetName assetName = this.ParseAssetName(entry.Key); if (!removedAssets.ContainsKey(assetName)) removedAssets[assetName] = 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 AssetName mapPath = this.ParseAssetName(this.MainContentManager.AssertAndNormalizeAssetName(location.mapPath.Value)); if (!removedAssets.ContainsKey(mapPath) && predicate(this.MainContentManager, mapPath.Name, 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(); { IAssetName[] invalidatedKeys = removedAssets.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 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, 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 Game1.content.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). /// Whether to read custom languages from Data/AdditionalLanguages. private Dictionary GetLocaleCodes(bool includeCustomLanguages) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); // custom languages if (includeCustomLanguages) { foreach (ModLanguage language in Game1.content.Load>("Data/AdditionalLanguages")) { 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; } } }