using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Content; using StardewModdingAPI.AssemblyRewriters; using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.Reflection; using StardewValley; namespace StardewModdingAPI.Framework { /// SMAPI's implementation of the game's content manager which lets it raise content events. internal class SContentManager : LocalizedContentManager { /********* ** Properties *********/ /// The possible directory separator characters in an asset key. private static readonly char[] PossiblePathSeparators = new[] { '/', '\\', Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }.Distinct().ToArray(); /// The preferred directory separator chaeacter in an asset key. private static readonly string PreferredPathSeparator = Path.DirectorySeparatorChar.ToString(); /// Encapsulates monitoring and logging. private readonly IMonitor Monitor; /// The underlying content manager's asset cache. private readonly IDictionary Cache; /// Applies platform-specific asset key normalisation so it's consistent with the underlying cache. private readonly Func NormaliseAssetNameForPlatform; /// The private method which generates the locale portion of an asset name. private readonly IPrivateMethod GetKeyLocale; /********* ** Accessors *********/ /// Implementations which change assets after they're loaded. internal IDictionary> Editors { get; } = new Dictionary>(); /// The absolute path to the . public string FullRootDirectory => Path.Combine(Constants.ExecutionPath, this.RootDirectory); /********* ** Public methods *********/ /// Construct an instance. /// The service provider to use to locate services. /// The root directory to search for content. /// The current culture for which to localise content. /// The current language code for which to localise content. /// Encapsulates monitoring and logging. public SContentManager(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, string languageCodeOverride, IMonitor monitor) : base(serviceProvider, rootDirectory, currentCulture, languageCodeOverride) { // initialise IReflectionHelper reflection = new ReflectionHelper(); this.Monitor = monitor; // get underlying fields for interception this.Cache = reflection.GetPrivateField>(this, "loadedAssets").GetValue(); this.GetKeyLocale = reflection.GetPrivateMethod(this, "languageCode"); // get asset key normalisation logic if (Constants.TargetPlatform == Platform.Windows) { IPrivateMethod method = reflection.GetPrivateMethod(typeof(TitleContainer), "GetCleanPath"); this.NormaliseAssetNameForPlatform = path => method.Invoke(path); } else this.NormaliseAssetNameForPlatform = key => key.Replace('\\', '/'); // based on MonoGame's ContentManager.Load logic } /// Normalise path separators in a file path. For asset keys, see instead. /// The file path to normalise. public string NormalisePathSeparators(string path) { string[] parts = path.Split(SContentManager.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries); string normalised = string.Join(SContentManager.PreferredPathSeparator, parts); if (path.StartsWith(SContentManager.PreferredPathSeparator)) normalised = SContentManager.PreferredPathSeparator + normalised; // keep root slash return normalised; } /// Normalise an asset name so it's consistent with the underlying cache. /// The asset key. public string NormaliseAssetName(string assetName) { assetName = this.NormalisePathSeparators(assetName); if (assetName.EndsWith(".xnb", StringComparison.InvariantCultureIgnoreCase)) return assetName.Substring(0, assetName.Length - 4); return this.NormaliseAssetNameForPlatform(assetName); } /// 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.NormaliseAssetName(assetName); return this.IsNormalisedKeyLoaded(assetName); } /// 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) { assetName = this.NormaliseAssetName(assetName); // skip if already loaded if (this.IsNormalisedKeyLoaded(assetName)) return base.Load(assetName); // load asset T asset = this.GetAssetWithInterceptors(this.GetLocale(), assetName, () => base.Load(assetName)); this.Cache[assetName] = asset; return asset; } /// 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.NormaliseAssetName(assetName); this.Cache[assetName] = value; } /// Get the current content locale. public string GetLocale() { return this.GetKeyLocale.Invoke(); } /********* ** Private methods *********/ /// Get whether an asset has already been loaded. /// The normalised asset name. private bool IsNormalisedKeyLoaded(string normalisedAssetName) { return this.Cache.ContainsKey(normalisedAssetName) || this.Cache.ContainsKey($"{normalisedAssetName}.{this.GetKeyLocale.Invoke()}"); // translated asset } /// Read an asset with support for asset interceptors. /// The asset type. /// The current content locale. /// The normalised asset path relative to the loader root directory, not including the .xnb extension. /// Get the asset from the underlying content manager. private T GetAssetWithInterceptors(string locale, string normalisedKey, Func getData) { // get metadata IAssetInfo info = new AssetInfo(locale, normalisedKey, typeof(T), this.NormaliseAssetName); // load asset T asset = getData(); // edit asset IAssetData data = new AssetDataForObject(info.Locale, info.AssetName, asset, this.NormaliseAssetName); foreach (var modEditors in this.Editors) { IModMetadata mod = modEditors.Key; foreach (IAssetEditor editor in modEditors.Value) { if (!editor.CanEdit(info)) continue; this.Monitor.Log($"{mod.DisplayName} intercepted {info.AssetName}.", LogLevel.Trace); editor.Edit(data); if (data.Data == null) throw new InvalidOperationException($"{mod.DisplayName} incorrectly set asset '{normalisedKey}' to a null value."); if (!(data.Data is T)) throw new InvalidOperationException($"{mod.DisplayName} incorrectly set asset '{normalisedKey}' to incompatible type '{data.Data.GetType()}', expected '{typeof(T)}'."); } } // return result return (T)data.Data; } } }