diff options
Diffstat (limited to 'src/SMAPI/Framework/ContentManagers')
5 files changed, 138 insertions, 87 deletions
diff --git a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs index 92264f8c..1a64dab8 100644 --- a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs @@ -5,12 +5,12 @@ using System.Diagnostics.Contracts; using System.Globalization; using System.IO; using System.Linq; -using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Reflection; using StardewValley; +using xTile; namespace StardewModdingAPI.Framework.ContentManagers { @@ -29,6 +29,9 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>Encapsulates monitoring and logging.</summary> protected readonly IMonitor Monitor; + /// <summary>Whether to enable more aggressive memory optimizations.</summary> + protected readonly bool AggressiveMemoryOptimizations; + /// <summary>Whether the content coordinator has been disposed.</summary> private bool IsDisposed; @@ -49,16 +52,16 @@ namespace StardewModdingAPI.Framework.ContentManagers /********* ** Accessors *********/ - /// <summary>A name for the mod manager. Not guaranteed to be unique.</summary> + /// <inheritdoc /> public string Name { get; } - /// <summary>The current language as a constant.</summary> + /// <inheritdoc /> public LanguageCode Language => this.GetCurrentLanguage(); - /// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary> + /// <inheritdoc /> public string FullRootDirectory => Path.Combine(Constants.ExecutionPath, this.RootDirectory); - /// <summary>Whether this content manager can be targeted by managed asset keys (e.g. to load assets from a mod folder).</summary> + /// <inheritdoc /> public bool IsNamespaced { get; } @@ -75,7 +78,8 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="reflection">Simplifies access to private code.</param> /// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param> /// <param name="isNamespaced">Whether this content manager handles managed asset keys (e.g. to load assets from a mod folder).</param> - protected BaseContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing, bool isNamespaced) + /// <param name="aggressiveMemoryOptimizations">Whether to enable more aggressive memory optimizations.</param> + protected BaseContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing, bool isNamespaced, bool aggressiveMemoryOptimizations) : base(serviceProvider, rootDirectory, currentCulture) { // init @@ -85,59 +89,49 @@ namespace StardewModdingAPI.Framework.ContentManagers this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); this.OnDisposing = onDisposing; this.IsNamespaced = isNamespaced; + this.AggressiveMemoryOptimizations = aggressiveMemoryOptimizations; // get asset data this.LanguageCodes = this.GetKeyLocales().ToDictionary(p => p.Value, p => p.Key, StringComparer.OrdinalIgnoreCase); this.BaseDisposableReferences = reflection.GetField<List<IDisposable>>(this, "disposableAssets").GetValue(); } - /// <summary>Load an asset that has been processed by the content pipeline.</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> + /// <inheritdoc /> public override T Load<T>(string assetName) { return this.Load<T>(assetName, this.Language, useCache: true); } - /// <summary>Load an asset that has been processed by the content pipeline.</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="language">The language code for which to load content.</param> + /// <inheritdoc /> public override T Load<T>(string assetName, LanguageCode language) { return this.Load<T>(assetName, language, useCache: true); } - /// <summary>Load an asset that has been processed by the content pipeline.</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="language">The language code for which to load content.</param> - /// <param name="useCache">Whether to read/write the loaded asset to the asset cache.</param> + /// <inheritdoc /> public abstract T Load<T>(string assetName, LocalizedContentManager.LanguageCode language, bool useCache); - /// <summary>Load the base asset without localization.</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> + /// <inheritdoc /> [Obsolete("This method is implemented for the base game and should not be used directly. To load an asset from the underlying content manager directly, use " + nameof(BaseContentManager.RawLoad) + " instead.")] public override T LoadBase<T>(string assetName) { return this.Load<T>(assetName, LanguageCode.en, useCache: true); } - /// <summary>Perform any cleanup needed when the locale changes.</summary> + /// <inheritdoc /> public virtual void OnLocaleChanged() { } - /// <summary>Normalize path separators in a file path. For asset keys, see <see cref="AssertAndNormalizeAssetName"/> instead.</summary> - /// <param name="path">The file path to normalize.</param> + /// <inheritdoc /> + public virtual void OnReturningToTitleScreen() { } + + /// <inheritdoc /> [Pure] public string NormalizePathSeparators(string path) { return this.Cache.NormalizePathSeparators(path); } - /// <summary>Assert that the given key has a valid format and return a normalized form consistent with the underlying cache.</summary> - /// <param name="assetName">The asset key to check.</param> - /// <exception cref="SContentLoadException">The asset key is empty or contains invalid characters.</exception> + /// <inheritdoc /> [SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")] public string AssertAndNormalizeAssetName(string assetName) { @@ -154,29 +148,26 @@ namespace StardewModdingAPI.Framework.ContentManagers /**** ** Content loading ****/ - /// <summary>Get the current content locale.</summary> + /// <inheritdoc /> public string GetLocale() { return this.GetLocale(this.GetCurrentLanguage()); } - /// <summary>The locale for a language.</summary> - /// <param name="language">The language.</param> + /// <inheritdoc /> public string GetLocale(LanguageCode language) { return this.LanguageCodeString(language); } - /// <summary>Get whether the content manager has already loaded and cached the given asset.</summary> - /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> - /// <param name="language">The language.</param> + /// <inheritdoc /> public bool IsLoaded(string assetName, LanguageCode language) { assetName = this.Cache.NormalizeKey(assetName); return this.IsNormalizedKeyLoaded(assetName, language); } - /// <summary>Get the cached asset keys.</summary> + /// <inheritdoc /> public IEnumerable<string> GetAssetKeys() { return this.Cache.Keys @@ -187,10 +178,7 @@ namespace StardewModdingAPI.Framework.ContentManagers /**** ** Cache invalidation ****/ - /// <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 and instances.</returns> + /// <inheritdoc /> public IDictionary<string, object> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false) { IDictionary<string, object> removeAssets = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase); @@ -198,21 +186,28 @@ namespace StardewModdingAPI.Framework.ContentManagers { this.ParseCacheKey(key, out string assetName, out _); - if (removeAssets.ContainsKey(assetName)) - return true; - if (predicate(assetName, asset.GetType())) + // check if asset should be removed + bool remove = removeAssets.ContainsKey(assetName); + if (!remove && predicate(assetName, asset.GetType())) { removeAssets[assetName] = asset; - return true; + remove = true; + } + + // dispose if safe + if (remove && this.AggressiveMemoryOptimizations) + { + if (asset is Map map) + map.DisposeTileSheets(Game1.mapDisplayDevice); } - return false; + + return remove; }, dispose); return removeAssets; } - /// <summary>Dispose held resources.</summary> - /// <param name="isDisposing">Whether the content manager is being disposed (rather than finalized).</param> + /// <inheritdoc /> protected override void Dispose(bool isDisposing) { // ignore if disposed diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs index 665c019b..8e78faba 100644 --- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs @@ -52,17 +52,14 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="reflection">Simplifies access to private code.</param> /// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param> /// <param name="onLoadingFirstAsset">A callback to invoke the first time *any* game content manager loads an asset.</param> - public GameContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing, Action onLoadingFirstAsset) - : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: false) + /// <param name="aggressiveMemoryOptimizations">Whether to enable more aggressive memory optimizations.</param> + public GameContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing, Action onLoadingFirstAsset, bool aggressiveMemoryOptimizations) + : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: false, aggressiveMemoryOptimizations: aggressiveMemoryOptimizations) { this.OnLoadingFirstAsset = onLoadingFirstAsset; } - /// <summary>Load an asset that has been processed by the content pipeline.</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="language">The language code for which to load content.</param> - /// <param name="useCache">Whether to read/write the loaded asset to the asset cache.</param> + /// <inheritdoc /> public override T Load<T>(string assetName, LocalizedContentManager.LanguageCode language, bool useCache) { // raise first-load callback @@ -94,7 +91,7 @@ namespace StardewModdingAPI.Framework.ContentManagers if (this.AssetsBeingLoaded.Contains(assetName)) { this.Monitor.Log($"Broke loop while loading asset '{assetName}'.", LogLevel.Warn); - this.Monitor.Log($"Bypassing mod loaders for this asset. Stack trace:\n{Environment.StackTrace}", LogLevel.Trace); + this.Monitor.Log($"Bypassing mod loaders for this asset. Stack trace:\n{Environment.StackTrace}"); data = this.RawLoad<T>(assetName, language, useCache); } else @@ -116,7 +113,7 @@ namespace StardewModdingAPI.Framework.ContentManagers return data; } - /// <summary>Perform any cleanup needed when the locale changes.</summary> + /// <inheritdoc /> public override void OnLocaleChanged() { base.OnLocaleChanged(); @@ -136,10 +133,35 @@ namespace StardewModdingAPI.Framework.ContentManagers .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) .ToArray(); if (invalidated.Any()) - this.Monitor.Log($"Invalidated {invalidated.Length} asset names: {string.Join(", ", invalidated)} for locale change.", LogLevel.Trace); + this.Monitor.Log($"Invalidated {invalidated.Length} asset names: {string.Join(", ", invalidated)} for locale change."); + } + + /// <inheritdoc /> + public override 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. + if (LocalizedContentManager.CurrentLanguageCode != LocalizedContentManager.LanguageCode.en) + this.InvalidateCache((_, _) => true); } - /// <summary>Create a new content manager for temporary use.</summary> + /// <inheritdoc /> public override LocalizedContentManager CreateTemporary() { return this.Coordinator.CreateGameContentManager("(temporary)"); @@ -149,9 +171,7 @@ namespace StardewModdingAPI.Framework.ContentManagers /********* ** Private methods *********/ - /// <summary>Get whether an asset has already been loaded.</summary> - /// <param name="normalizedAssetName">The normalized asset name.</param> - /// <param name="language">The language to check.</param> + /// <inheritdoc /> protected override bool IsNormalizedKeyLoaded(string normalizedAssetName, LanguageCode language) { string cachedKey = null; @@ -165,12 +185,7 @@ namespace StardewModdingAPI.Framework.ContentManagers : this.Cache.ContainsKey(normalizedAssetName); } - /// <summary>Add tracking data to an asset and add it to the cache.</summary> - /// <typeparam name="T">The type of asset to inject.</typeparam> - /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> - /// <param name="value">The asset value.</param> - /// <param name="language">The language code for which to inject the asset.</param> - /// <param name="useCache">Whether to save the asset to the asset cache.</param> + /// <inheritdoc /> protected override void TrackAsset<T>(string assetName, T value, LanguageCode language, bool useCache) { // handle explicit language in asset name @@ -358,7 +373,7 @@ namespace StardewModdingAPI.Framework.ContentManagers try { editor.Edit<T>(asset); - this.Monitor.Log($"{mod.DisplayName} edited {info.AssetName}.", LogLevel.Trace); + this.Monitor.Log($"{mod.DisplayName} edited {info.AssetName}."); } catch (Exception ex) { diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManagerForAssetPropagation.cs b/src/SMAPI/Framework/ContentManagers/GameContentManagerForAssetPropagation.cs new file mode 100644 index 00000000..61683ce6 --- /dev/null +++ b/src/SMAPI/Framework/ContentManagers/GameContentManagerForAssetPropagation.cs @@ -0,0 +1,47 @@ +using System; +using System.Globalization; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework.Reflection; +using StardewValley; + +namespace StardewModdingAPI.Framework.ContentManagers +{ + /// <summary>An extension of <see cref="GameContentManager"/> specifically optimized for asset propagation.</summary> + /// <remarks>This avoids sharing an asset cache with <see cref="Game1.content"/> or mods, so that assets can be safely disposed when the vanilla game no longer references them.</remarks> + internal class GameContentManagerForAssetPropagation : GameContentManager + { + /********* + ** Fields + *********/ + /// <summary>A unique value used in <see cref="Texture2D"/> to identify assets loaded through this instance.</summary> + private readonly string Tag = $"Pathoschild.SMAPI/LoadedBy:{nameof(GameContentManagerForAssetPropagation)}"; + + + /********* + ** Public methods + *********/ + /// <inheritdoc /> + public GameContentManagerForAssetPropagation(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing, Action onLoadingFirstAsset, bool aggressiveMemoryOptimizations) + : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, onLoadingFirstAsset, aggressiveMemoryOptimizations) { } + + /// <inheritdoc /> + public override T Load<T>(string assetName, LanguageCode language, bool useCache) + { + T data = base.Load<T>(assetName, language, useCache); + + if (data is Texture2D texture) + texture.Tag = this.Tag; + + return data; + } + + /// <summary>Get whether a texture was loaded by this content manager.</summary> + /// <param name="texture">The texture to check.</param> + public bool IsResponsibleFor(Texture2D texture) + { + return + texture?.Tag is string tag + && tag.Contains(this.Tag); + } + } +} diff --git a/src/SMAPI/Framework/ContentManagers/IContentManager.cs b/src/SMAPI/Framework/ContentManagers/IContentManager.cs index 0e7edd8f..1e222472 100644 --- a/src/SMAPI/Framework/ContentManagers/IContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/IContentManager.cs @@ -36,9 +36,6 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="useCache">Whether to read/write the loaded asset to the asset cache.</param> T Load<T>(string assetName, LocalizedContentManager.LanguageCode language, bool useCache); - /// <summary>Perform any cleanup needed when the locale changes.</summary> - void OnLocaleChanged(); - /// <summary>Normalize path separators in a file path. For asset keys, see <see cref="AssertAndNormalizeAssetName"/> instead.</summary> /// <param name="path">The file path to normalize.</param> [Pure] @@ -69,5 +66,12 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <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 and instances.</returns> IDictionary<string, object> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false); + + /// <summary>Perform any cleanup needed when the locale changes.</summary> + void OnLocaleChanged(); + + /// <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> + void OnReturningToTitleScreen(); } } diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index 1456d3c1..9af14cb5 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -50,36 +50,28 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="reflection">Simplifies access to private code.</param> /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> /// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param> - public ModContentManager(string name, IContentManager gameContentManager, IServiceProvider serviceProvider, string modName, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action<BaseContentManager> onDisposing) - : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: true) + /// <param name="aggressiveMemoryOptimizations">Whether to enable more aggressive memory optimizations.</param> + public ModContentManager(string name, IContentManager gameContentManager, IServiceProvider serviceProvider, string modName, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action<BaseContentManager> onDisposing, bool aggressiveMemoryOptimizations) + : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: true, aggressiveMemoryOptimizations: aggressiveMemoryOptimizations) { this.GameContentManager = gameContentManager; this.JsonHelper = jsonHelper; this.ModName = modName; } - /// <summary>Load an asset that has been processed by the content pipeline.</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> + /// <inheritdoc /> public override T Load<T>(string assetName) { return this.Load<T>(assetName, this.DefaultLanguage, useCache: false); } - /// <summary>Load an asset that has been processed by the content pipeline.</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="language">The language code for which to load content.</param> + /// <inheritdoc /> public override T Load<T>(string assetName, LanguageCode language) { return this.Load<T>(assetName, language, useCache: false); } - /// <summary>Load an asset that has been processed by the content pipeline.</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="language">The language code for which to load content.</param> - /// <param name="useCache">Whether to read/write the loaded asset to the asset cache.</param> + /// <inheritdoc /> public override T Load<T>(string assetName, LanguageCode language, bool useCache) { assetName = this.AssertAndNormalizeAssetName(assetName); @@ -189,7 +181,7 @@ namespace StardewModdingAPI.Framework.ContentManagers return asset; } - /// <summary>Create a new content manager for temporary use.</summary> + /// <inheritdoc /> public override LocalizedContentManager CreateTemporary() { throw new NotSupportedException("Can't create a temporary mod content manager."); @@ -209,9 +201,7 @@ namespace StardewModdingAPI.Framework.ContentManagers /********* ** Private methods *********/ - /// <summary>Get whether an asset has already been loaded.</summary> - /// <param name="normalizedAssetName">The normalized asset name.</param> - /// <param name="language">The language to check.</param> + /// <inheritdoc /> protected override bool IsNormalizedKeyLoaded(string normalizedAssetName, LanguageCode language) { return this.Cache.ContainsKey(normalizedAssetName); |