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 { /********* ** Properties *********/ /// 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; /// Whether the content coordinator has been disposed. private bool IsDisposed; /// The language enum values indexed by locale code. private readonly IDictionary LanguageCodes; /// A callback to invoke when the content manager is being disposed. private readonly Action OnDisposing; /********* ** Accessors *********/ /// A name for the mod manager. Not guaranteed to be unique. public string Name { get; } /// The current language as a constant. public LanguageCode Language => this.GetCurrentLanguage(); /// The absolute path to the . public string FullRootDirectory => Path.Combine(Constants.ExecutionPath, this.RootDirectory); /// Whether this content manager is for a mod folder. public bool IsModContentManager { 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 localise 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 is for a mod folder. protected BaseContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action onDisposing, bool isModFolder) : base(serviceProvider, rootDirectory, currentCulture) { // init this.Name = name; this.Coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator)); this.Cache = new ContentCache(this, reflection); this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); this.OnDisposing = onDisposing; this.IsModContentManager = isModFolder; // get asset data this.LanguageCodes = this.GetKeyLocales().ToDictionary(p => p.Value, p => p.Key, StringComparer.InvariantCultureIgnoreCase); } /// Load an asset that has been processed by the content pipeline. /// The type of asset to load. /// The asset path relative to the loader root directory, not including the .xnb extension. public override T Load(string assetName) { return this.Load(assetName, LocalizedContentManager.CurrentLanguageCode); } /// Load the base asset without localisation. /// The type of asset to load. /// The asset path relative to the loader root directory, not including the .xnb extension. public override T LoadBase(string assetName) { return this.Load(assetName, LanguageCode.en); } /// Inject an asset into 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. public void Inject(string assetName, T value) { assetName = this.AssertAndNormaliseAssetName(assetName); this.Cache[assetName] = value; } /// Get a copy of the given asset if supported. /// The asset type. /// The asset to clone. public T CloneIfPossible(T asset) { 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 source: return (T)(object)new Dictionary(source); case Dictionary source: return (T)(object)new Dictionary(source); default: return asset; } } /// Normalise path separators in a file path. For asset keys, see instead. /// The file path to normalise. [Pure] public string NormalisePathSeparators(string path) { return this.Cache.NormalisePathSeparators(path); } /// Assert that the given key has a valid format and return a normalised form consistent with the underlying cache. /// The asset key to check. /// The asset key is empty or contains invalid characters. [SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")] public string AssertAndNormaliseAssetName(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("The asset key or local path is empty."); if (assetName.Intersect(Path.GetInvalidPathChars()).Any()) throw new SContentLoadException("The asset key or local path contains invalid characters."); return this.Cache.NormaliseKey(assetName); } /**** ** Content loading ****/ /// Get the current content locale. public string GetLocale() { return this.GetLocale(this.GetCurrentLanguage()); } /// The locale for a language. /// The language. public string GetLocale(LanguageCode language) { return this.LanguageCodeString(language); } /// Get whether the content manager has already loaded and cached the given asset. /// The asset path relative to the loader root directory, not including the .xnb extension. public bool IsLoaded(string assetName) { assetName = this.Cache.NormaliseKey(assetName); return this.IsNormalisedKeyLoaded(assetName); } /// Get the cached asset keys. public IEnumerable GetAssetKeys() { return this.Cache.Keys .Select(this.GetAssetName) .Distinct(); } /**** ** Cache invalidation ****/ /// 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 number of invalidated assets. public IEnumerable InvalidateCache(Func predicate, bool dispose = false) { HashSet removeAssetNames = new HashSet(StringComparer.InvariantCultureIgnoreCase); this.Cache.Remove((key, type) => { this.ParseCacheKey(key, out string assetName, out _); if (removeAssetNames.Contains(assetName) || predicate(assetName, type)) { removeAssetNames.Add(assetName); return true; } return false; }); return removeAssetNames; } /// Dispose held resources. /// Whether the content manager is being disposed (rather than finalized). protected override void Dispose(bool isDisposing) { if (this.IsDisposed) return; this.IsDisposed = true; 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 *********/ /// Get the locale codes (like ja-JP) used in asset keys. private IDictionary GetKeyLocales() { // create locale => code map IDictionary map = new Dictionary(); foreach (LanguageCode code in Enum.GetValues(typeof(LanguageCode))) map[code] = this.GetLocale(code); return map; } /// Get the asset name from a cache key. /// The input cache key. private string GetAssetName(string cacheKey) { this.ParseCacheKey(cacheKey, out string assetName, out string _); return assetName; } /// Parse a cache key into its component parts. /// The input cache key. /// The original asset name. /// The asset locale code (or null if not localised). protected void ParseCacheKey(string cacheKey, out string assetName, out string localeCode) { // handle localised key if (!string.IsNullOrWhiteSpace(cacheKey)) { int lastSepIndex = cacheKey.LastIndexOf(".", StringComparison.InvariantCulture); if (lastSepIndex >= 0) { string suffix = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1); if (this.LanguageCodes.ContainsKey(suffix)) { assetName = cacheKey.Substring(0, lastSepIndex); localeCode = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1); return; } } } // handle simple key assetName = cacheKey; localeCode = null; } /// Get whether an asset has already been loaded. /// The normalised asset name. protected abstract bool IsNormalisedKeyLoaded(string normalisedAssetName); } }