diff options
Diffstat (limited to 'src/SMAPI/Framework/ContentManagers')
4 files changed, 527 insertions, 185 deletions
diff --git a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs index 7821e454..5283340e 100644 --- a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs @@ -38,6 +38,9 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>The language enum values indexed by locale code.</summary> protected IDictionary<string, LanguageCode> LanguageCodes { get; } + /// <summary>A list of disposable assets.</summary> + private readonly List<WeakReference<IDisposable>> Disposables = new List<WeakReference<IDisposable>>(); + /********* ** Accessors @@ -51,8 +54,8 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary> public string FullRootDirectory => Path.Combine(Constants.ExecutionPath, this.RootDirectory); - /// <summary>Whether this content manager is for a mod folder.</summary> - public bool IsModContentManager { get; } + /// <summary>Whether this content manager can be targeted by managed asset keys (e.g. to load assets from a mod folder).</summary> + public bool IsNamespaced { get; } /********* @@ -62,13 +65,13 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param> /// <param name="serviceProvider">The service provider to use to locate services.</param> /// <param name="rootDirectory">The root directory to search for content.</param> - /// <param name="currentCulture">The current culture for which to localise content.</param> + /// <param name="currentCulture">The current culture for which to localize content.</param> /// <param name="coordinator">The central coordinator which manages content managers.</param> /// <param name="monitor">Encapsulates monitoring and logging.</param> /// <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="isModFolder">Whether this content manager is for a mod folder.</param> - protected BaseContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing, bool isModFolder) + /// <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) : base(serviceProvider, rootDirectory, currentCulture) { // init @@ -77,7 +80,7 @@ namespace StardewModdingAPI.Framework.ContentManagers this.Cache = new ContentCache(this, reflection); this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); this.OnDisposing = onDisposing; - this.IsModContentManager = isModFolder; + this.IsNamespaced = isNamespaced; // get asset data this.LanguageCodes = this.GetKeyLocales().ToDictionary(p => p.Value, p => p.Key, StringComparer.InvariantCultureIgnoreCase); @@ -88,69 +91,50 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> public override T Load<T>(string assetName) { - return this.Load<T>(assetName, LocalizedContentManager.CurrentLanguageCode); + return this.Load<T>(assetName, this.Language, useCache: true); } - /// <summary>Load the base asset without localisation.</summary> + /// <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> - public override T LoadBase<T>(string assetName) + /// <param name="language">The language code for which to load content.</param> + public override T Load<T>(string assetName, LanguageCode language) { - return this.Load<T>(assetName, LanguageCode.en); + return this.Load<T>(assetName, language, useCache: true); } - /// <summary>Inject an asset into the cache.</summary> - /// <typeparam name="T">The type of asset to inject.</typeparam> + /// <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="value">The asset value.</param> - public void Inject<T>(string assetName, T value) - { - assetName = this.AssertAndNormaliseAssetName(assetName); - this.Cache[assetName] = value; - - } + /// <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> + public abstract T Load<T>(string assetName, LocalizedContentManager.LanguageCode language, bool useCache); - /// <summary>Get a copy of the given asset if supported.</summary> - /// <typeparam name="T">The asset type.</typeparam> - /// <param name="asset">The asset to clone.</param> - public T CloneIfPossible<T>(T asset) + /// <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> + [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) { - switch (asset as object) - { - case Texture2D source: - { - int[] pixels = new int[source.Width * source.Height]; - source.GetData(pixels); - - Texture2D clone = new Texture2D(source.GraphicsDevice, source.Width, source.Height); - clone.SetData(pixels); - return (T)(object)clone; - } - - case Dictionary<string, string> source: - return (T)(object)new Dictionary<string, string>(source); - - case Dictionary<int, string> source: - return (T)(object)new Dictionary<int, string>(source); - - default: - return asset; - } + return this.Load<T>(assetName, LanguageCode.en, useCache: true); } - /// <summary>Normalise path separators in a file path. For asset keys, see <see cref="AssertAndNormaliseAssetName"/> instead.</summary> - /// <param name="path">The file path to normalise.</param> + /// <summary>Perform any cleanup needed when the locale changes.</summary> + 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> [Pure] - public string NormalisePathSeparators(string path) + public string NormalizePathSeparators(string path) { - return this.Cache.NormalisePathSeparators(path); + return this.Cache.NormalizePathSeparators(path); } - /// <summary>Assert that the given key has a valid format and return a normalised form consistent with the underlying cache.</summary> + /// <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> [SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")] - public string AssertAndNormaliseAssetName(string assetName) + public string AssertAndNormalizeAssetName(string assetName) { // NOTE: the game checks for ContentLoadException to handle invalid keys, so avoid // throwing other types like ArgumentException here. @@ -159,7 +143,7 @@ namespace StardewModdingAPI.Framework.ContentManagers if (assetName.Intersect(Path.GetInvalidPathChars()).Any()) throw new SContentLoadException("The asset key or local path contains invalid characters."); - return this.Cache.NormaliseKey(assetName); + return this.Cache.NormalizeKey(assetName); } /**** @@ -182,8 +166,8 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> public bool IsLoaded(string assetName) { - assetName = this.Cache.NormaliseKey(assetName); - return this.IsNormalisedKeyLoaded(assetName); + assetName = this.Cache.NormalizeKey(assetName); + return this.IsNormalizedKeyLoaded(assetName); } /// <summary>Get the cached asset keys.</summary> @@ -225,11 +209,28 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="isDisposing">Whether the content manager is being disposed (rather than finalized).</param> protected override void Dispose(bool isDisposing) { + // ignore if disposed if (this.IsDisposed) return; this.IsDisposed = true; + // dispose uncached assets + foreach (WeakReference<IDisposable> reference in this.Disposables) + { + if (reference.TryGetTarget(out IDisposable disposable)) + { + try + { + disposable.Dispose(); + } + catch { /* ignore dispose errors */ } + } + } + this.Disposables.Clear(); + + // raise event this.OnDisposing(this); + base.Dispose(isDisposing); } @@ -246,32 +247,40 @@ namespace StardewModdingAPI.Framework.ContentManagers /********* ** Private methods *********/ - /// <summary>Get the locale codes (like <c>ja-JP</c>) used in asset keys.</summary> - private IDictionary<LanguageCode, string> GetKeyLocales() + /// <summary>Load an asset file directly from the underlying content manager.</summary> + /// <typeparam name="T">The type of asset to load.</typeparam> + /// <param name="assetName">The normalized asset key.</param> + /// <param name="useCache">Whether to read/write the loaded asset to the asset cache.</param> + protected virtual T RawLoad<T>(string assetName, bool useCache) { - // create locale => code map - IDictionary<LanguageCode, string> map = new Dictionary<LanguageCode, string>(); - foreach (LanguageCode code in Enum.GetValues(typeof(LanguageCode))) - map[code] = this.GetLocale(code); - - return map; + return useCache + ? base.LoadBase<T>(assetName) + : base.ReadAsset<T>(assetName, disposable => this.Disposables.Add(new WeakReference<IDisposable>(disposable))); } - /// <summary>Get the asset name from a cache key.</summary> - /// <param name="cacheKey">The input cache key.</param> - private string GetAssetName(string cacheKey) + /// <summary>Inject an asset into 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> + protected virtual void Inject<T>(string assetName, T value, LanguageCode language) { - this.ParseCacheKey(cacheKey, out string assetName, out string _); - return assetName; + // track asset key + if (value is Texture2D texture) + texture.Name = assetName; + + // cache asset + assetName = this.AssertAndNormalizeAssetName(assetName); + this.Cache[assetName] = value; } /// <summary>Parse a cache key into its component parts.</summary> /// <param name="cacheKey">The input cache key.</param> /// <param name="assetName">The original asset name.</param> - /// <param name="localeCode">The asset locale code (or <c>null</c> if not localised).</param> + /// <param name="localeCode">The asset locale code (or <c>null</c> if not localized).</param> protected void ParseCacheKey(string cacheKey, out string assetName, out string localeCode) { - // handle localised key + // handle localized key if (!string.IsNullOrWhiteSpace(cacheKey)) { int lastSepIndex = cacheKey.LastIndexOf(".", StringComparison.InvariantCulture); @@ -293,7 +302,26 @@ namespace StardewModdingAPI.Framework.ContentManagers } /// <summary>Get whether an asset has already been loaded.</summary> - /// <param name="normalisedAssetName">The normalised asset name.</param> - protected abstract bool IsNormalisedKeyLoaded(string normalisedAssetName); + /// <param name="normalizedAssetName">The normalized asset name.</param> + protected abstract bool IsNormalizedKeyLoaded(string normalizedAssetName); + + /// <summary>Get the locale codes (like <c>ja-JP</c>) used in asset keys.</summary> + private IDictionary<LanguageCode, string> GetKeyLocales() + { + // create locale => code map + IDictionary<LanguageCode, string> map = new Dictionary<LanguageCode, string>(); + foreach (LanguageCode code in Enum.GetValues(typeof(LanguageCode))) + map[code] = this.GetLocale(code); + + return map; + } + + /// <summary>Get the asset name from a cache key.</summary> + /// <param name="cacheKey">The input cache key.</param> + private string GetAssetName(string cacheKey) + { + this.ParseCacheKey(cacheKey, out string assetName, out string _); + return assetName; + } } } diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs index ee940cc7..0b563555 100644 --- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using Microsoft.Xna.Framework.Content; using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Reflection; @@ -25,8 +26,14 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>Interceptors which edit matching assets after they're loaded.</summary> private IDictionary<IModMetadata, IList<IAssetEditor>> Editors => this.Coordinator.Editors; - /// <summary>A lookup which indicates whether the asset is localisable (i.e. the filename contains the locale), if previously loaded.</summary> - private readonly IDictionary<string, bool> IsLocalisableLookup; + /// <summary>A lookup which indicates whether the asset is localizable (i.e. the filename contains the locale), if previously loaded.</summary> + private readonly IDictionary<string, bool> IsLocalizableLookup; + + /// <summary>Whether the next load is the first for any game content manager.</summary> + private static bool IsFirstLoad = true; + + /// <summary>A callback to invoke the first time *any* game content manager loads an asset.</summary> + private readonly Action OnLoadingFirstAsset; /********* @@ -36,37 +43,48 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param> /// <param name="serviceProvider">The service provider to use to locate services.</param> /// <param name="rootDirectory">The root directory to search for content.</param> - /// <param name="currentCulture">The current culture for which to localise content.</param> + /// <param name="currentCulture">The current culture for which to localize content.</param> /// <param name="coordinator">The central coordinator which manages content managers.</param> /// <param name="monitor">Encapsulates monitoring and logging.</param> /// <param name="reflection">Simplifies access to private code.</param> /// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param> - public GameContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing) - : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isModFolder: false) + /// <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) { - this.IsLocalisableLookup = reflection.GetField<IDictionary<string, bool>>(this, "_localizedAsset").GetValue(); + this.IsLocalizableLookup = reflection.GetField<IDictionary<string, bool>>(this, "_localizedAsset").GetValue(); + 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> - public override T Load<T>(string assetName, LanguageCode language) + /// <param name="useCache">Whether to read/write the loaded asset to the asset cache.</param> + public override T Load<T>(string assetName, LocalizedContentManager.LanguageCode language, bool useCache) { - // normalise asset name - assetName = this.AssertAndNormaliseAssetName(assetName); + // raise first-load callback + if (GameContentManager.IsFirstLoad) + { + GameContentManager.IsFirstLoad = false; + this.OnLoadingFirstAsset(); + } + + // normalize asset name + assetName = this.AssertAndNormalizeAssetName(assetName); if (this.TryParseExplicitLanguageAssetKey(assetName, out string newAssetName, out LanguageCode newLanguage)) - return this.Load<T>(newAssetName, newLanguage); + return this.Load<T>(newAssetName, newLanguage, useCache); // get from cache - if (this.IsLoaded(assetName)) - return base.Load<T>(assetName, language); + if (useCache && this.IsLoaded(assetName)) + return this.RawLoad<T>(assetName, language, useCache: true); // get managed asset if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath)) { - T managedAsset = this.Coordinator.LoadAndCloneManagedAsset<T>(assetName, contentManagerID, relativePath, language); - this.Inject(assetName, managedAsset); + T managedAsset = this.Coordinator.LoadManagedAsset<T>(contentManagerID, relativePath); + if (useCache) + this.Inject(assetName, managedAsset, language); return managedAsset; } @@ -76,27 +94,50 @@ namespace StardewModdingAPI.Framework.ContentManagers { 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); - data = base.Load<T>(assetName, language); + data = this.RawLoad<T>(assetName, language, useCache); } else { data = this.AssetsBeingLoaded.Track(assetName, () => { string locale = this.GetLocale(language); - IAssetInfo info = new AssetInfo(locale, assetName, typeof(T), this.AssertAndNormaliseAssetName); + IAssetInfo info = new AssetInfo(locale, assetName, typeof(T), this.AssertAndNormalizeAssetName); IAssetData asset = this.ApplyLoader<T>(info) - ?? new AssetDataForObject(info, base.Load<T>(assetName, language), this.AssertAndNormaliseAssetName); + ?? new AssetDataForObject(info, this.RawLoad<T>(assetName, language, useCache), this.AssertAndNormalizeAssetName); asset = this.ApplyEditors<T>(info, asset); return (T)asset.Data; }); } // update cache & return data - this.Inject(assetName, data); + this.Inject(assetName, data, language); return data; } + /// <summary>Perform any cleanup needed when the locale changes.</summary> + public override void OnLocaleChanged() + { + base.OnLocaleChanged(); + + // find assets for which a translatable version was loaded + HashSet<string> removeAssetNames = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); + foreach (string key in this.IsLocalizableLookup.Where(p => p.Value).Select(p => p.Key)) + removeAssetNames.Add(this.TryParseExplicitLanguageAssetKey(key, out string assetName, out _) ? assetName : key); + + // invalidate translatable assets + string[] invalidated = this + .InvalidateCache((key, type) => + removeAssetNames.Contains(key) + || (this.TryParseExplicitLanguageAssetKey(key, out string assetName, out _) && removeAssetNames.Contains(assetName)) + ) + .Select(p => p.Item1) + .OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase) + .ToArray(); + if (invalidated.Any()) + this.Monitor.Log($"Invalidated {invalidated.Length} asset names: {string.Join(", ", invalidated)} for locale change.", LogLevel.Trace); + } + /// <summary>Create a new content manager for temporary use.</summary> public override LocalizedContentManager CreateTemporary() { @@ -108,30 +149,107 @@ namespace StardewModdingAPI.Framework.ContentManagers ** Private methods *********/ /// <summary>Get whether an asset has already been loaded.</summary> - /// <param name="normalisedAssetName">The normalised asset name.</param> - protected override bool IsNormalisedKeyLoaded(string normalisedAssetName) + /// <param name="normalizedAssetName">The normalized asset name.</param> + protected override bool IsNormalizedKeyLoaded(string normalizedAssetName) { // default English - if (this.Language == LocalizedContentManager.LanguageCode.en || this.Coordinator.IsManagedAssetKey(normalisedAssetName)) - return this.Cache.ContainsKey(normalisedAssetName); + if (this.Language == LocalizedContentManager.LanguageCode.en || this.Coordinator.IsManagedAssetKey(normalizedAssetName)) + return this.Cache.ContainsKey(normalizedAssetName); // translated - string localeKey = $"{normalisedAssetName}.{this.GetLocale(this.GetCurrentLanguage())}"; - if (this.IsLocalisableLookup.TryGetValue(localeKey, out bool localisable)) + string keyWithLocale = $"{normalizedAssetName}.{this.GetLocale(this.GetCurrentLanguage())}"; + if (this.IsLocalizableLookup.TryGetValue(keyWithLocale, out bool localizable)) { - return localisable - ? this.Cache.ContainsKey(localeKey) - : this.Cache.ContainsKey(normalisedAssetName); + return localizable + ? this.Cache.ContainsKey(keyWithLocale) + : this.Cache.ContainsKey(normalizedAssetName); } // not loaded yet return false; } + /// <summary>Inject an asset into 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> + protected override void Inject<T>(string assetName, T value, LanguageCode language) + { + // handle explicit language in asset name + { + if (this.TryParseExplicitLanguageAssetKey(assetName, out string newAssetName, out LanguageCode newLanguage)) + { + this.Inject(newAssetName, value, newLanguage); + return; + } + } + + // save to cache + // Note: even if the asset was loaded and cached right before this method was called, + // we need to fully re-inject it here for two reasons: + // 1. So we can look up an asset by its base or localized key (the game/XNA logic + // only caches by the most specific key). + // 2. Because a mod asset loader/editor may have changed the asset in a way that + // doesn't change the instance stored in the cache, e.g. using `asset.ReplaceWith`. + string keyWithLocale = $"{assetName}.{this.GetLocale(language)}"; + base.Inject(assetName, value, language); + if (this.Cache.ContainsKey(keyWithLocale)) + base.Inject(keyWithLocale, value, language); + + // track whether the injected asset is translatable for is-loaded lookups + if (this.Cache.ContainsKey(keyWithLocale)) + { + this.IsLocalizableLookup[assetName] = true; + this.IsLocalizableLookup[keyWithLocale] = true; + } + else if (this.Cache.ContainsKey(assetName)) + { + this.IsLocalizableLookup[assetName] = false; + this.IsLocalizableLookup[keyWithLocale] = false; + } + else + this.Monitor.Log($"Asset '{assetName}' could not be found in the cache immediately after injection.", LogLevel.Error); + } + + /// <summary>Load an asset file directly from the underlying content manager.</summary> + /// <typeparam name="T">The type of asset to load.</typeparam> + /// <param name="assetName">The normalized asset key.</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> + /// <remarks>Derived from <see cref="LocalizedContentManager.Load{T}(string, LocalizedContentManager.LanguageCode)"/>.</remarks> + private T RawLoad<T>(string assetName, LanguageCode language, bool useCache) + { + // try translated asset + if (language != LocalizedContentManager.LanguageCode.en) + { + string translatedKey = $"{assetName}.{this.GetLocale(language)}"; + if (!this.IsLocalizableLookup.TryGetValue(translatedKey, out bool isTranslatable) || isTranslatable) + { + try + { + T obj = base.RawLoad<T>(translatedKey, useCache); + this.IsLocalizableLookup[assetName] = true; + this.IsLocalizableLookup[translatedKey] = true; + return obj; + } + catch (ContentLoadException) + { + this.IsLocalizableLookup[assetName] = false; + this.IsLocalizableLookup[translatedKey] = false; + } + } + } + + // try base asset + return base.RawLoad<T>(assetName, useCache); + } + /// <summary>Parse an asset key that contains an explicit language into its asset name and language, if applicable.</summary> /// <param name="rawAsset">The asset key to parse.</param> /// <param name="assetName">The asset name without the language code.</param> /// <param name="language">The language code removed from the asset name.</param> + /// <returns>Returns whether the asset key contains an explicit language and was successfully parsed.</returns> private bool TryParseExplicitLanguageAssetKey(string rawAsset, out string assetName, out LanguageCode language) { if (string.IsNullOrWhiteSpace(rawAsset)) @@ -188,7 +306,7 @@ namespace StardewModdingAPI.Framework.ContentManagers T data; try { - data = this.CloneIfPossible(loader.Load<T>(info)); + data = loader.Load<T>(info); this.Monitor.Log($"{mod.DisplayName} loaded asset '{info.AssetName}'.", LogLevel.Trace); } catch (Exception ex) @@ -205,7 +323,7 @@ namespace StardewModdingAPI.Framework.ContentManagers } // return matched asset - return new AssetDataForObject(info, data, this.AssertAndNormaliseAssetName); + return new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName); } /// <summary>Apply any <see cref="Editors"/> to a loaded asset.</summary> @@ -214,7 +332,7 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="asset">The loaded asset.</param> private IAssetData ApplyEditors<T>(IAssetInfo info, IAssetData asset) { - IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.AssertAndNormaliseAssetName); + IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName); // edit asset foreach (var entry in this.GetInterceptors(this.Editors)) diff --git a/src/SMAPI/Framework/ContentManagers/IContentManager.cs b/src/SMAPI/Framework/ContentManagers/IContentManager.cs index 17618edd..12c01352 100644 --- a/src/SMAPI/Framework/ContentManagers/IContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/IContentManager.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Contracts; using Microsoft.Xna.Framework.Content; using StardewModdingAPI.Framework.Exceptions; @@ -23,8 +22,8 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary> string FullRootDirectory { get; } - /// <summary>Whether this content manager is for a mod folder.</summary> - bool IsModContentManager { get; } + /// <summary>Whether this content manager can be targeted by managed asset keys (e.g. to load assets from a mod folder).</summary> + bool IsNamespaced { get; } /********* @@ -33,35 +32,22 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <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> - T Load<T>(string assetName); - - /// <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> - T Load<T>(string assetName, LocalizedContentManager.LanguageCode language); - - /// <summary>Inject an asset into 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> - void Inject<T>(string assetName, T value); + /// <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>Get a copy of the given asset if supported.</summary> - /// <typeparam name="T">The asset type.</typeparam> - /// <param name="asset">The asset to clone.</param> - T CloneIfPossible<T>(T asset); + /// <summary>Perform any cleanup needed when the locale changes.</summary> + void OnLocaleChanged(); - /// <summary>Normalise path separators in a file path. For asset keys, see <see cref="AssertAndNormaliseAssetName"/> instead.</summary> - /// <param name="path">The file path to normalise.</param> + /// <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] - string NormalisePathSeparators(string path); + string NormalizePathSeparators(string path); - /// <summary>Assert that the given key has a valid format and return a normalised form consistent with the underlying cache.</summary> + /// <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> - [SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")] - string AssertAndNormaliseAssetName(string assetName); + string AssertAndNormalizeAssetName(string assetName); /// <summary>Get the current content locale.</summary> string GetLocale(); diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index 2c50ec04..90b86179 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -1,12 +1,19 @@ using System; using System.Globalization; using System.IO; +using System.Linq; using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Reflection; -using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialization; +using StardewModdingAPI.Toolkit.Utilities; using StardewValley; +using xTile; +using xTile.Format; +using xTile.ObjectModel; +using xTile.Tiles; namespace StardewModdingAPI.Framework.ContentManagers { @@ -19,84 +26,89 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> private readonly JsonHelper JsonHelper; + /// <summary>The game content manager used for map tilesheets not provided by the mod.</summary> + private readonly IContentManager GameContentManager; + + /// <summary>The language code for language-agnostic mod assets.</summary> + private readonly LanguageCode DefaultLanguage = Constants.DefaultLanguage; + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param> + /// <param name="gameContentManager">The game content manager used for map tilesheets not provided by the mod.</param> /// <param name="serviceProvider">The service provider to use to locate services.</param> /// <param name="rootDirectory">The root directory to search for content.</param> - /// <param name="currentCulture">The current culture for which to localise content.</param> + /// <param name="currentCulture">The current culture for which to localize content.</param> /// <param name="coordinator">The central coordinator which manages content managers.</param> /// <param name="monitor">Encapsulates monitoring and logging.</param> /// <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, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action<BaseContentManager> onDisposing) - : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isModFolder: true) + public ModContentManager(string name, IContentManager gameContentManager, IServiceProvider serviceProvider, 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) { + this.GameContentManager = gameContentManager; this.JsonHelper = jsonHelper; } /// <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> + 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> public override T Load<T>(string assetName, LanguageCode language) { - assetName = this.AssertAndNormaliseAssetName(assetName); + 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> + public override T Load<T>(string assetName, LanguageCode language, bool useCache) + { + assetName = this.AssertAndNormalizeAssetName(assetName); - // get from cache - if (this.IsLoaded(assetName)) - return base.Load<T>(assetName, language); + // disable caching + // This is necessary to avoid assets being shared between content managers, which can + // cause changes to an asset through one content manager affecting the same asset in + // others (or even fresh content managers). See https://www.patreon.com/posts/27247161 + // for more background info. + if (useCache) + throw new InvalidOperationException("Mod content managers don't support asset caching."); - // get managed asset - if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath)) + // disable language handling + // Mod files don't support automatic translation logic, so this should never happen. + if (language != this.DefaultLanguage) + throw new InvalidOperationException("Localized assets aren't supported by the mod content manager."); + + // resolve managed asset key { - if (contentManagerID != this.Name) + if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath)) { - T data = this.Coordinator.LoadAndCloneManagedAsset<T>(assetName, contentManagerID, relativePath, language); - this.Inject(assetName, data); - return data; + if (contentManagerID != this.Name) + throw new SContentLoadException($"Can't load managed asset key '{assetName}' through content manager '{this.Name}' for a different mod."); + assetName = relativePath; } - - return this.LoadManagedAsset<T>(assetName, contentManagerID, relativePath, language); } - throw new NotSupportedException("Can't load content folder asset from a mod content manager."); - } - - /// <summary>Create a new content manager for temporary use.</summary> - public override LocalizedContentManager CreateTemporary() - { - throw new NotSupportedException("Can't create a temporary mod content manager."); - } - - - /********* - ** Private methods - *********/ - /// <summary>Get whether an asset has already been loaded.</summary> - /// <param name="normalisedAssetName">The normalised asset name.</param> - protected override bool IsNormalisedKeyLoaded(string normalisedAssetName) - { - return this.Cache.ContainsKey(normalisedAssetName); - } - - /// <summary>Load a managed mod asset.</summary> - /// <typeparam name="T">The type of asset to load.</typeparam> - /// <param name="internalKey">The internal asset key.</param> - /// <param name="contentManagerID">The unique name for the content manager which should load this asset.</param> - /// <param name="relativePath">The relative path within the mod folder.</param> - /// <param name="language">The language code for which to load content.</param> - private T LoadManagedAsset<T>(string internalKey, string contentManagerID, string relativePath, LanguageCode language) - { - SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading asset '{relativePath}' from {contentManagerID}: {reasonPhrase}"); + // get local asset + SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}"); try { // get file - FileInfo file = this.GetModFile(relativePath); + FileInfo file = this.GetModFile(assetName); if (!file.Exists) throw GetContentError("the specified path doesn't exist."); @@ -105,35 +117,54 @@ namespace StardewModdingAPI.Framework.ContentManagers { // XNB file case ".xnb": - return base.Load<T>(relativePath, language); + { + T data = this.RawLoad<T>(assetName, useCache: false); + if (data is Map map) + { + this.NormalizeTilesheetPaths(map); + this.FixCustomTilesheetPaths(map, relativeMapPath: assetName); + } + return data; + } // unpacked data case ".json": { if (!this.JsonHelper.ReadJsonFileIfExists(file.FullName, out T data)) throw GetContentError("the JSON file is invalid."); // should never happen since we check for file existence above - return data; } // unpacked image case ".png": - // validate - if (typeof(T) != typeof(Texture2D)) - throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'."); - - // fetch & cache - using (FileStream stream = File.OpenRead(file.FullName)) { - Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream); - texture = this.PremultiplyTransparency(texture); - this.Inject(internalKey, texture); - return (T)(object)texture; + // validate + if (typeof(T) != typeof(Texture2D)) + throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'."); + + // fetch & cache + using (FileStream stream = File.OpenRead(file.FullName)) + { + Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream); + texture = this.PremultiplyTransparency(texture); + return (T)(object)texture; + } } // unpacked map case ".tbin": - throw GetContentError($"can't read unpacked map file directly from the underlying content manager. It must be loaded through the mod's {typeof(IModHelper)}.{nameof(IModHelper.Content)} helper."); + { + // validate + if (typeof(T) != typeof(Map)) + throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'."); + + // fetch & cache + FormatManager formatManager = FormatManager.Instance; + Map map = formatManager.LoadMap(file.FullName); + this.NormalizeTilesheetPaths(map); + this.FixCustomTilesheetPaths(map, relativeMapPath: assetName); + return (T)(object)map; + } default: throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.json', '.png', '.tbin', or '.xnb'."); @@ -143,10 +174,37 @@ namespace StardewModdingAPI.Framework.ContentManagers { if (ex.GetInnermostException() is DllNotFoundException dllEx && dllEx.Message == "libgdiplus.dylib") throw GetContentError("couldn't find libgdiplus, which is needed to load mod images. Make sure Mono is installed and you're running the game through the normal launcher."); - throw new SContentLoadException($"The content manager failed loading content asset '{relativePath}' from {contentManagerID}.", ex); + throw new SContentLoadException($"The content manager failed loading content asset '{assetName}' from {this.Name}.", ex); } } + /// <summary>Create a new content manager for temporary use.</summary> + public override LocalizedContentManager CreateTemporary() + { + throw new NotSupportedException("Can't create a temporary mod content manager."); + } + + /// <summary>Get the underlying key in the game's content cache for an asset. This does not validate whether the asset exists.</summary> + /// <param name="key">The local path to a content file relative to the mod folder.</param> + /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> + public string GetInternalAssetKey(string key) + { + FileInfo file = this.GetModFile(key); + string relativePath = PathUtilities.GetRelativePath(this.RootDirectory, file.FullName); + return Path.Combine(this.Name, relativePath); + } + + + /********* + ** Private methods + *********/ + /// <summary>Get whether an asset has already been loaded.</summary> + /// <param name="normalizedAssetName">The normalized asset name.</param> + protected override bool IsNormalizedKeyLoaded(string normalizedAssetName) + { + return this.Cache.ContainsKey(normalizedAssetName); + } + /// <summary>Get a file from the mod folder.</summary> /// <param name="path">The asset path relative to the content folder.</param> private FileInfo GetModFile(string path) @@ -182,9 +240,161 @@ namespace StardewModdingAPI.Framework.ContentManagers Color[] data = new Color[texture.Width * texture.Height]; texture.GetData(data); for (int i = 0; i < data.Length; i++) + { + if (data[i].A == 0) + continue; // no need to change fully transparent pixels + data[i] = Color.FromNonPremultiplied(data[i].ToVector4()); + } + texture.SetData(data); return texture; } + + /// <summary>Normalize map tilesheet paths for the current platform.</summary> + /// <param name="map">The map whose tilesheets to fix.</param> + private void NormalizeTilesheetPaths(Map map) + { + foreach (TileSheet tilesheet in map.TileSheets) + tilesheet.ImageSource = this.NormalizePathSeparators(tilesheet.ImageSource); + } + + /// <summary>Fix custom map tilesheet paths so they can be found by the content manager.</summary> + /// <param name="map">The map whose tilesheets to fix.</param> + /// <param name="relativeMapPath">The relative map path within the mod folder.</param> + /// <exception cref="ContentLoadException">A map tilesheet couldn't be resolved.</exception> + /// <remarks> + /// The game's logic for tilesheets in <see cref="Game1.setGraphicsForSeason"/> is a bit specialized. It boils + /// down to this: + /// * If the location is indoors or the desert, or the image source contains 'path' or 'object', it's loaded + /// as-is relative to the <c>Content</c> folder. + /// * Else it's loaded from <c>Content\Maps</c> with a seasonal prefix. + /// + /// That logic doesn't work well in our case, mainly because we have no location metadata at this point. + /// Instead we use a more heuristic approach: check relative to the map file first, then relative to + /// <c>Content\Maps</c>, then <c>Content</c>. If the image source filename contains a seasonal prefix, try for a + /// seasonal variation and then an exact match. + /// + /// While that doesn't exactly match the game logic, it's close enough that it's unlikely to make a difference. + /// </remarks> + private void FixCustomTilesheetPaths(Map map, string relativeMapPath) + { + // get map info + if (!map.TileSheets.Any()) + return; + relativeMapPath = this.AssertAndNormalizeAssetName(relativeMapPath); // Mono's Path.GetDirectoryName doesn't handle Windows dir separators + string relativeMapFolder = Path.GetDirectoryName(relativeMapPath) ?? ""; // folder path containing the map, relative to the mod folder + bool isOutdoors = map.Properties.TryGetValue("Outdoors", out PropertyValue outdoorsProperty) && outdoorsProperty != null; + + // fix tilesheets + foreach (TileSheet tilesheet in map.TileSheets) + { + string imageSource = tilesheet.ImageSource; + + // validate tilesheet path + if (Path.IsPathRooted(imageSource) || PathUtilities.GetSegments(imageSource).Contains("..")) + throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded. Tilesheet paths must be a relative path without directory climbing (../)."); + + // get seasonal name (if applicable) + string seasonalImageSource = null; + if (isOutdoors && Context.IsSaveLoaded && Game1.currentSeason != null) + { + string filename = Path.GetFileName(imageSource) ?? throw new InvalidOperationException($"The '{imageSource}' tilesheet couldn't be loaded: filename is unexpectedly null."); + bool hasSeasonalPrefix = + filename.StartsWith("spring_", StringComparison.CurrentCultureIgnoreCase) + || filename.StartsWith("summer_", StringComparison.CurrentCultureIgnoreCase) + || filename.StartsWith("fall_", StringComparison.CurrentCultureIgnoreCase) + || filename.StartsWith("winter_", StringComparison.CurrentCultureIgnoreCase); + if (hasSeasonalPrefix && !filename.StartsWith(Game1.currentSeason + "_")) + { + string dirPath = imageSource.Substring(0, imageSource.LastIndexOf(filename, StringComparison.CurrentCultureIgnoreCase)); + seasonalImageSource = $"{dirPath}{Game1.currentSeason}_{filename.Substring(filename.IndexOf("_", StringComparison.CurrentCultureIgnoreCase) + 1)}"; + } + } + + // load best match + try + { + string key = + this.GetTilesheetAssetName(relativeMapFolder, seasonalImageSource) + ?? this.GetTilesheetAssetName(relativeMapFolder, imageSource); + if (key != null) + { + tilesheet.ImageSource = key; + continue; + } + } + catch (Exception ex) + { + throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder.", ex); + } + + // none found + throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder."); + } + } + + /// <summary>Get the actual asset name for a tilesheet.</summary> + /// <param name="modRelativeMapFolder">The folder path containing the map, relative to the mod folder.</param> + /// <param name="imageSource">The tilesheet image source to load.</param> + /// <returns>Returns the asset name.</returns> + /// <remarks>See remarks on <see cref="FixCustomTilesheetPaths"/>.</remarks> + private string GetTilesheetAssetName(string modRelativeMapFolder, string imageSource) + { + if (imageSource == null) + return null; + + // check relative to map file + { + string localKey = Path.Combine(modRelativeMapFolder, imageSource); + FileInfo localFile = this.GetModFile(localKey); + if (localFile.Exists) + return this.GetInternalAssetKey(localKey); + } + + // check relative to content folder + { + foreach (string candidateKey in new[] { imageSource, Path.Combine("Maps", imageSource) }) + { + string contentKey = candidateKey.EndsWith(".png") + ? candidateKey.Substring(0, candidateKey.Length - 4) + : candidateKey; + + try + { + this.GameContentManager.Load<Texture2D>(contentKey, this.Language, useCache: true); // no need to bypass cache here, since we're not storing the asset + return contentKey; + } + catch + { + // ignore file-not-found errors + // TODO: while it's useful to suppress an asset-not-found error here to avoid + // confusion, this is a pretty naive approach. Even if the file doesn't exist, + // the file may have been loaded through an IAssetLoader which failed. So even + // if the content file doesn't exist, that doesn't mean the error here is a + // content-not-found error. Unfortunately XNA doesn't provide a good way to + // detect the error type. + if (this.GetContentFolderFileExists(contentKey)) + throw; + } + } + } + + // not found + return null; + } + + /// <summary>Get whether a file from the game's content folder exists.</summary> + /// <param name="key">The asset key.</param> + private bool GetContentFolderFileExists(string key) + { + // get file path + string path = Path.Combine(this.GameContentManager.FullRootDirectory, key); + if (!path.EndsWith(".xnb")) + path += ".xnb"; + + // get file + return new FileInfo(path).Exists; + } } } |