using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; 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; namespace StardewModdingAPI.Framework.ContentManagers { /// A content manager which handles reading files from a SMAPI mod folder with support for unpacked files. internal abstract class BaseContentManager : LocalizedContentManager, IContentManager { /********* ** Fields *********/ /// The central coordinator which manages content managers. protected readonly ContentCoordinator Coordinator; /// The underlying asset cache. protected readonly ContentCache Cache; /// Encapsulates monitoring and logging. protected readonly IMonitor Monitor; /// Simplifies access to private code. protected readonly Reflector Reflection; /// Whether to automatically try resolving keys to a localized form if available. protected bool TryLocalizeKeys = true; /// Whether the content coordinator has been disposed. private bool IsDisposed; /// A callback to invoke when the content manager is being disposed. private readonly Action OnDisposing; /// A list of disposable assets. private readonly List> Disposables = new(); /// The disposable assets tracked by the base content manager. /// This should be kept empty to avoid keeping disposable assets referenced forever, which prevents garbage collection when they're unused. Disposable assets are tracked by instead, which avoids a hard reference. private readonly List BaseDisposableReferences; /********* ** Accessors *********/ /// public string Name { get; } /// public LanguageCode Language => this.GetCurrentLanguage(); /// public string FullRootDirectory => Path.Combine(Constants.GamePath, this.RootDirectory); /// public bool IsNamespaced { get; } /********* ** Public methods *********/ /// Construct an instance. /// A name for the mod manager. Not guaranteed to be unique. /// The service provider to use to locate services. /// The root directory to search for content. /// The current culture for which to localize content. /// The central coordinator which manages content managers. /// Encapsulates monitoring and logging. /// Simplifies access to private code. /// A callback to invoke when the content manager is being disposed. /// Whether this content manager handles managed asset keys (e.g. to load assets from a mod folder). protected BaseContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action onDisposing, bool isNamespaced) : base(serviceProvider, rootDirectory, currentCulture) { // init this.Name = name; this.Coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator)); // ReSharper disable once VirtualMemberCallInConstructor -- LoadedAssets isn't overridden by SMAPI or Stardew Valley this.Cache = new ContentCache(this.LoadedAssets); this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); this.Reflection = reflection; this.OnDisposing = onDisposing; this.IsNamespaced = isNamespaced; // get asset data this.BaseDisposableReferences = reflection.GetField?>(this, "disposableAssets").GetValue() ?? throw new InvalidOperationException("Can't initialize content manager: the required 'disposableAssets' field wasn't found."); } /// public virtual bool DoesAssetExist(IAssetName assetName) where T : notnull { return this.Cache.ContainsKey(assetName.Name); } /// [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 sealed override T LoadBase(string assetName) { return this.Load(assetName, LanguageCode.en); } /// [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalse", Justification = "Copied as-is from game code")] public sealed override string LoadBaseString(string path) { try { // copied as-is from LocalizedContentManager.LoadBaseString // This is only changed to call this.Load instead of base.Load, to support mod assets this.ParseStringPath(path, out string assetName, out string key); Dictionary strings = this.Load>(assetName, LanguageCode.en); return strings != null && strings.ContainsKey(key) ? this.GetString(strings, key) : path; } catch (Exception ex) { throw new InvalidOperationException($"Failed loading string path '{path}' from '{this.Name}'.", ex); } } /// public sealed override T Load(string assetName) { return this.Load(assetName, this.Language); } /// public sealed override T Load(string assetName, LanguageCode language) { assetName = this.PrenormalizeRawAssetName(assetName); IAssetName parsedName = this.Coordinator.ParseAssetName(assetName, allowLocales: this.TryLocalizeKeys); return this.LoadLocalized(parsedName, language, useCache: true); } /// public T LoadLocalized(IAssetName assetName, LanguageCode language, bool useCache) where T : notnull { // ignore locale in English (or if disabled) if (!this.TryLocalizeKeys || language == LocalizedContentManager.LanguageCode.en) return this.LoadExact(assetName, useCache: useCache); // check for localized asset // ReSharper disable once LocalVariableHidesMember -- this is deliberate Dictionary localizedAssetNames = this.Coordinator.LocalizedAssetNames.Value; if (!localizedAssetNames.TryGetValue(assetName.Name, out _)) { string localeCode = this.LanguageCodeString(language); IAssetName localizedName = new AssetName(baseName: assetName.BaseName, localeCode: localeCode, languageCode: language); try { T data = this.LoadExact(localizedName, useCache: useCache); localizedAssetNames[assetName.Name] = localizedName.Name; return data; } catch (ContentLoadException) { localizedName = new AssetName(assetName.BaseName + "_international", null, null); try { T data = this.LoadExact(localizedName, useCache: useCache); localizedAssetNames[assetName.Name] = localizedName.Name; return data; } catch (ContentLoadException) { localizedAssetNames[assetName.Name] = assetName.Name; } } } // use cached key string rawName = localizedAssetNames[assetName.Name]; if (assetName.Name != rawName) assetName = this.Coordinator.ParseAssetName(rawName, allowLocales: this.TryLocalizeKeys); return this.LoadExact(assetName, useCache: useCache); } /// public abstract T LoadExact(IAssetName assetName, bool useCache) where T : notnull; /// [SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")] public string AssertAndNormalizeAssetName(string? assetName) { // NOTE: the game checks for ContentLoadException to handle invalid keys, so avoid // throwing other types like ArgumentException here. if (string.IsNullOrWhiteSpace(assetName)) throw new SContentLoadException(ContentLoadErrorType.InvalidName, "The asset key or local path is empty."); if (assetName.Intersect(Path.GetInvalidPathChars()).Any()) throw new SContentLoadException(ContentLoadErrorType.InvalidName, "The asset key or local path contains invalid characters."); return this.Cache.NormalizeKey(assetName); } /**** ** Content loading ****/ /// public string GetLocale() { return this.GetLocale(this.GetCurrentLanguage()); } /// public string GetLocale(LanguageCode language) { return this.LanguageCodeString(language); } /// public bool IsLoaded(IAssetName assetName) { return this.Cache.ContainsKey(assetName.Name); } /**** ** Cache invalidation ****/ /// public IEnumerable> GetCachedAssets() { foreach (string key in this.Cache.Keys) yield return new(key, this.Cache[key]); } /// public bool InvalidateCache(IAssetName assetName, bool dispose = false) { if (!this.Cache.ContainsKey(assetName.Name)) return false; // remove from cache this.Cache.Remove(assetName.Name, dispose); return true; } /// protected override void Dispose(bool isDisposing) { // ignore if disposed if (this.IsDisposed) return; this.IsDisposed = true; // dispose uncached assets foreach (WeakReference 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); } /// public override void Unload() { if (this.IsDisposed) return; // base logic doesn't allow unloading twice, which happens due to SMAPI and the game both unloading base.Unload(); } /********* ** Private methods *********/ /// Apply initial normalization to a raw asset name before it's parsed. /// The asset name to normalize. [return: NotNullIfNotNull("assetName")] private string? PrenormalizeRawAssetName(string? assetName) { // trim assetName = assetName?.Trim(); // For legacy reasons, mods can pass .xnb file extensions to the content pipeline which // are then stripped. This will be re-added as needed when reading from raw files. if (assetName?.EndsWith(".xnb") == true) assetName = assetName[..^".xnb".Length]; return assetName; } /// Normalize path separators in a file path. For asset keys, see instead. /// The file path to normalize. [Pure] [return: NotNullIfNotNull("path")] protected string? NormalizePathSeparators(string? path) { return this.Cache.NormalizePathSeparators(path); } /// Load an asset file directly from the underlying content manager. /// The type of asset to load. /// The normalized asset key. /// Whether to read/write the loaded asset to the asset cache. protected virtual T RawLoad(IAssetName assetName, bool useCache) { return useCache ? base.LoadBase(assetName.Name) : this.ReadAsset(assetName.Name, disposable => this.Disposables.Add(new WeakReference(disposable))); } /// Add tracking data to an asset and add it to the cache. /// The type of asset to inject. /// The asset path relative to the loader root directory, not including the .xnb extension. /// The asset value. /// Whether to save the asset to the asset cache. protected virtual void TrackAsset(IAssetName assetName, T value, bool useCache) where T : notnull { // track asset key if (value is Texture2D texture) texture.Name = assetName.Name; // 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 because a mod editor may have changed the asset in a // way that doesn't change the instance stored in the cache, e.g. using // `asset.ReplaceWith`. if (useCache) this.Cache[assetName.Name] = value; // avoid hard disposable references; see remarks on the field this.BaseDisposableReferences.Clear(); } /**** ** Private methods copied from the game code ****/ #pragma warning disable CS1574 // can't be resolved: the reference is valid but private /// Parse a string path like assetName:key. /// The string path. /// The extracted asset name. /// The extracted entry key. /// The string path is not in a valid format. /// This is copied as-is from . private void ParseStringPath(string path, out string assetName, out string key) { int length = path.IndexOf(':'); assetName = length != -1 ? path.Substring(0, length) : throw new ContentLoadException("Unable to parse string path: " + path); key = path.Substring(length + 1, path.Length - length - 1); } /// Get a string value from a dictionary asset. /// The asset to read. /// The string key to find. /// This is copied as-is from . private string GetString(Dictionary strings, string key) { return strings.TryGetValue(key + ".desktop", out string? str) ? str : strings[key]; } #pragma warning restore CS1574 } }