diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/SMAPI/Framework/ContentCoordinator.cs | 22 | ||||
-rw-r--r-- | src/SMAPI/Framework/ContentManagers/ModContentManager.cs | 14 | ||||
-rw-r--r-- | src/SMAPI/Framework/ContentPack.cs | 38 | ||||
-rw-r--r-- | src/SMAPI/Framework/ModHelpers/ModContentHelper.cs | 16 | ||||
-rw-r--r-- | src/SMAPI/Framework/SCore.cs | 15 | ||||
-rw-r--r-- | src/SMAPI/Utilities/CaseInsensitivePathCache.cs | 124 |
6 files changed, 191 insertions, 38 deletions
diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs index 8483d45d..cd1de4a8 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -15,7 +15,7 @@ using StardewModdingAPI.Framework.Utilities; using StardewModdingAPI.Internal; using StardewModdingAPI.Metadata; using StardewModdingAPI.Toolkit.Serialization; -using StardewModdingAPI.Toolkit.Utilities; +using StardewModdingAPI.Utilities; using StardewValley; using StardewValley.GameData; using xTile; @@ -80,6 +80,9 @@ namespace StardewModdingAPI.Framework /// <summary>The cached asset load/edit operations to apply, indexed by asset name.</summary> private readonly TickCacheDictionary<IAssetName, AssetOperationGroup[]> AssetOperationsByKey = new(); + /// <summary>The previously created case-insensitive path caches by root path.</summary> + private readonly Dictionary<string, CaseInsensitivePathCache> CaseInsensitivePathCaches = new(StringComparer.OrdinalIgnoreCase); + /********* ** Accessors @@ -91,9 +94,11 @@ namespace StardewModdingAPI.Framework public LocalizedContentManager.LanguageCode Language => this.MainContentManager.Language; /// <summary>Interceptors which provide the initial versions of matching assets.</summary> + [Obsolete] public IList<ModLinked<IAssetLoader>> Loaders { get; } = new List<ModLinked<IAssetLoader>>(); /// <summary>Interceptors which edit matching assets after they're loaded.</summary> + [Obsolete] public IList<ModLinked<IAssetEditor>> Editors { get; } = new List<ModLinked<IAssetEditor>>(); /// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary> @@ -205,7 +210,8 @@ namespace StardewModdingAPI.Framework reflection: this.Reflection, jsonHelper: this.JsonHelper, onDisposing: this.OnDisposing, - aggressiveMemoryOptimizations: this.AggressiveMemoryOptimizations + aggressiveMemoryOptimizations: this.AggressiveMemoryOptimizations, + relativePathCache: this.GetCaseInsensitivePathCache(rootDirectory) ); this.ContentManagers.Add(manager); return manager; @@ -477,6 +483,18 @@ namespace StardewModdingAPI.Framework }); } + /// <summary>Get a dictionary of relative paths within a root path, for case-insensitive file lookups.</summary> + /// <param name="rootPath">The root path to scan.</param> + public CaseInsensitivePathCache GetCaseInsensitivePathCache(string rootPath) + { + rootPath = PathUtilities.NormalizePath(rootPath); + + if (!this.CaseInsensitivePathCaches.TryGetValue(rootPath, out CaseInsensitivePathCache cache)) + this.CaseInsensitivePathCaches[rootPath] = cache = new CaseInsensitivePathCache(rootPath); + + return cache; + } + /// <summary>Get the tilesheet ID order used by the unmodified version of a map asset.</summary> /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> public TilesheetReference[] GetVanillaTilesheetIds(string assetName) diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index 9ed989da..a451fd7c 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -9,7 +9,7 @@ using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Toolkit.Serialization; -using StardewModdingAPI.Toolkit.Utilities; +using StardewModdingAPI.Utilities; using StardewValley; using xTile; using xTile.Format; @@ -32,6 +32,9 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>The game content manager used for map tilesheets not provided by the mod.</summary> private readonly IContentManager GameContentManager; + /// <summary>A case-insensitive lookup of relative paths within the <see cref="ContentManager.RootDirectory"/>.</summary> + private readonly CaseInsensitivePathCache RelativePathCache; + /// <summary>If a map tilesheet's image source has no file extensions, the file extensions to check for in the local mod folder.</summary> private readonly string[] LocalTilesheetExtensions = { ".png", ".xnb" }; @@ -52,10 +55,12 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <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> /// <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) + /// <param name="relativePathCache">A case-insensitive lookup of relative paths within the <paramref name="rootDirectory"/>.</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, CaseInsensitivePathCache relativePathCache) : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: true, aggressiveMemoryOptimizations: aggressiveMemoryOptimizations) { this.GameContentManager = gameContentManager; + this.RelativePathCache = relativePathCache; this.JsonHelper = jsonHelper; this.ModName = modName; @@ -198,7 +203,7 @@ namespace StardewModdingAPI.Framework.ContentManagers public IAssetName GetInternalAssetKey(string key) { FileInfo file = this.GetModFile(key); - string relativePath = PathUtilities.GetRelativePath(this.RootDirectory, file.FullName); + string relativePath = Path.GetRelativePath(this.RootDirectory, file.FullName); string internalKey = Path.Combine(this.Name, relativePath); return this.Coordinator.ParseAssetName(internalKey, allowLocales: false); @@ -212,6 +217,9 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="path">The asset path relative to the content folder.</param> private FileInfo GetModFile(string path) { + // map to case-insensitive path if needed + path = this.RelativePathCache.GetFilePath(path); + // try exact match FileInfo file = new(Path.Combine(this.FullRootDirectory, path)); diff --git a/src/SMAPI/Framework/ContentPack.cs b/src/SMAPI/Framework/ContentPack.cs index 3920354e..e02ef88b 100644 --- a/src/SMAPI/Framework/ContentPack.cs +++ b/src/SMAPI/Framework/ContentPack.cs @@ -1,9 +1,8 @@ using System; -using System.Collections.Generic; using System.IO; using StardewModdingAPI.Framework.ModHelpers; using StardewModdingAPI.Toolkit.Serialization; -using StardewModdingAPI.Toolkit.Utilities; +using StardewModdingAPI.Utilities; namespace StardewModdingAPI.Framework { @@ -16,8 +15,8 @@ namespace StardewModdingAPI.Framework /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> private readonly JsonHelper JsonHelper; - /// <summary>A cache of case-insensitive => exact relative paths within the content pack, for case-insensitive file lookups on Linux/macOS.</summary> - private readonly IDictionary<string, string> RelativePaths = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + /// <summary>A case-insensitive lookup of relative paths within the <see cref="DirectoryPath"/>.</summary> + private readonly CaseInsensitivePathCache RelativePathCache; /********* @@ -48,19 +47,15 @@ namespace StardewModdingAPI.Framework /// <param name="content">Provides an API for loading content assets from the content pack's folder.</param> /// <param name="translation">Provides translations stored in the content pack's <c>i18n</c> folder.</param> /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> - public ContentPack(string directoryPath, IManifest manifest, IModContentHelper content, TranslationHelper translation, JsonHelper jsonHelper) + /// <param name="relativePathCache">A case-insensitive lookup of relative paths within the <paramref name="directoryPath"/>.</param> + public ContentPack(string directoryPath, IManifest manifest, IModContentHelper content, TranslationHelper translation, JsonHelper jsonHelper, CaseInsensitivePathCache relativePathCache) { this.DirectoryPath = directoryPath; this.Manifest = manifest; this.ModContent = content; this.TranslationImpl = translation; this.JsonHelper = jsonHelper; - - foreach (string path in Directory.EnumerateFiles(this.DirectoryPath, "*", SearchOption.AllDirectories)) - { - string relativePath = path.Substring(this.DirectoryPath.Length + 1); - this.RelativePaths[relativePath] = relativePath; - } + this.RelativePathCache = relativePathCache; } /// <inheritdoc /> @@ -90,8 +85,7 @@ namespace StardewModdingAPI.Framework FileInfo file = this.GetFile(path, out path); this.JsonHelper.WriteJsonFile(file.FullName, data); - if (!this.RelativePaths.ContainsKey(path)) - this.RelativePaths[path] = path; + this.RelativePathCache.Add(path); } /// <inheritdoc /> @@ -112,18 +106,6 @@ namespace StardewModdingAPI.Framework /********* ** Private methods *********/ - /// <summary>Get the real relative path from a case-insensitive path.</summary> - /// <param name="relativePath">The normalized relative path.</param> - private string GetCaseInsensitiveRelativePath(string relativePath) - { - if (!PathUtilities.IsSafeRelativePath(relativePath)) - throw new InvalidOperationException($"You must call {nameof(IContentPack)} methods with a relative path."); - - return !string.IsNullOrWhiteSpace(relativePath) && this.RelativePaths.TryGetValue(relativePath, out string caseInsensitivePath) - ? caseInsensitivePath - : relativePath; - } - /// <summary>Get the underlying file info.</summary> /// <param name="relativePath">The normalized file path relative to the content pack directory.</param> private FileInfo GetFile(string relativePath) @@ -136,7 +118,11 @@ namespace StardewModdingAPI.Framework /// <param name="actualRelativePath">The relative path after case-insensitive matching.</param> private FileInfo GetFile(string relativePath, out string actualRelativePath) { - actualRelativePath = this.GetCaseInsensitiveRelativePath(relativePath); + if (!PathUtilities.IsSafeRelativePath(relativePath)) + throw new InvalidOperationException($"You must call {nameof(IContentPack)} methods with a relative path."); + + actualRelativePath = this.RelativePathCache.GetFilePath(relativePath); + return new FileInfo(Path.Combine(this.DirectoryPath, actualRelativePath)); } } diff --git a/src/SMAPI/Framework/ModHelpers/ModContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ModContentHelper.cs index 2379583c..7468cda1 100644 --- a/src/SMAPI/Framework/ModHelpers/ModContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModContentHelper.cs @@ -1,7 +1,9 @@ using System; +using Microsoft.Xna.Framework.Content; using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Exceptions; +using StardewModdingAPI.Utilities; namespace StardewModdingAPI.Framework.ModHelpers { @@ -20,6 +22,9 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <summary>The friendly mod name for use in errors.</summary> private readonly string ModName; + /// <summary>A case-insensitive lookup of relative paths within the <see cref="ContentManager.RootDirectory"/>.</summary> + private readonly CaseInsensitivePathCache RelativePathCache; + /********* ** Public methods @@ -30,7 +35,8 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <param name="modID">The unique ID of the relevant mod.</param> /// <param name="modName">The friendly mod name for use in errors.</param> /// <param name="gameContentManager">The game content manager used for map tilesheets not provided by the mod.</param> - public ModContentHelper(ContentCoordinator contentCore, string modFolderPath, string modID, string modName, IContentManager gameContentManager) + /// <param name="relativePathCache">A case-insensitive lookup of relative paths within the <paramref name="relativePathCache"/>.</param> + public ModContentHelper(ContentCoordinator contentCore, string modFolderPath, string modID, string modName, IContentManager gameContentManager, CaseInsensitivePathCache relativePathCache) : base(modID) { string managedAssetPrefix = contentCore.GetManagedAssetPrefix(modID); @@ -38,11 +44,14 @@ namespace StardewModdingAPI.Framework.ModHelpers this.ContentCore = contentCore; this.ModContentManager = contentCore.CreateModContentManager(managedAssetPrefix, modName, modFolderPath, gameContentManager); this.ModName = modName; + this.RelativePathCache = relativePathCache; } /// <inheritdoc /> public T Load<T>(string relativePath) { + relativePath = this.RelativePathCache.GetAssetName(relativePath); + IAssetName assetName = this.ContentCore.ParseAssetName(relativePath, allowLocales: false); try @@ -58,6 +67,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <inheritdoc /> public IAssetName GetInternalAssetName(string relativePath) { + relativePath = this.RelativePathCache.GetAssetName(relativePath); return this.ModContentManager.GetInternalAssetKey(relativePath); } @@ -67,7 +77,9 @@ namespace StardewModdingAPI.Framework.ModHelpers if (data == null) throw new ArgumentNullException(nameof(data), "Can't get a patch helper for a null value."); - relativePath ??= $"temp/{Guid.NewGuid():N}"; + relativePath = relativePath != null + ? this.RelativePathCache.GetAssetName(relativePath) + : $"temp/{Guid.NewGuid():N}"; return new AssetDataForObject(this.ContentCore.GetLocale(), this.ContentCore.ParseAssetName(relativePath, allowLocales: false), data, key => this.ContentCore.ParseAssetName(key, allowLocales: false).Name); } diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index b4aa3595..7fd5bcd3 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -1775,10 +1775,11 @@ namespace StardewModdingAPI.Framework { IManifest manifest = mod.Manifest; IMonitor monitor = this.LogManager.GetMonitor(mod.DisplayName); + CaseInsensitivePathCache relativePathCache = this.ContentCore.GetCaseInsensitivePathCache(mod.DirectoryPath); GameContentHelper gameContentHelper = new(this.ContentCore, manifest.UniqueID, mod.DisplayName, monitor); - IModContentHelper modContentHelper = new ModContentHelper(this.ContentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, gameContentHelper.GetUnderlyingContentManager()); + IModContentHelper modContentHelper = new ModContentHelper(this.ContentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, gameContentHelper.GetUnderlyingContentManager(), relativePathCache); TranslationHelper translationHelper = new(manifest.UniqueID, contentCore.GetLocale(), contentCore.Language); - IContentPack contentPack = new ContentPack(mod.DirectoryPath, manifest, modContentHelper, translationHelper, jsonHelper); + IContentPack contentPack = new ContentPack(mod.DirectoryPath, manifest, modContentHelper, translationHelper, jsonHelper, relativePathCache); mod.SetMod(contentPack, monitor, translationHelper); this.ModRegistry.Add(mod); @@ -1856,11 +1857,14 @@ namespace StardewModdingAPI.Framework IContentPack CreateFakeContentPack(string packDirPath, IManifest packManifest) { IMonitor packMonitor = this.LogManager.GetMonitor(packManifest.Name); + + CaseInsensitivePathCache relativePathCache = this.ContentCore.GetCaseInsensitivePathCache(packDirPath); + GameContentHelper gameContentHelper = new(contentCore, packManifest.UniqueID, packManifest.Name, packMonitor); - IModContentHelper packContentHelper = new ModContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, gameContentHelper.GetUnderlyingContentManager()); + IModContentHelper packContentHelper = new ModContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, gameContentHelper.GetUnderlyingContentManager(), relativePathCache); TranslationHelper packTranslationHelper = new(packManifest.UniqueID, contentCore.GetLocale(), contentCore.Language); - ContentPack contentPack = new(packDirPath, packManifest, packContentHelper, packTranslationHelper, this.Toolkit.JsonHelper); + ContentPack contentPack = new(packDirPath, packManifest, packContentHelper, packTranslationHelper, this.Toolkit.JsonHelper, relativePathCache); this.ReloadTranslationsForTemporaryContentPack(mod, contentPack); mod.FakeContentPacks.Add(new WeakReference<ContentPack>(contentPack)); return contentPack; @@ -1868,9 +1872,10 @@ namespace StardewModdingAPI.Framework IModEvents events = new ModEvents(mod, this.EventManager); ICommandHelper commandHelper = new CommandHelper(mod, this.CommandManager); + CaseInsensitivePathCache relativePathCache = this.ContentCore.GetCaseInsensitivePathCache(mod.DirectoryPath); IContentHelper contentHelper = new ContentHelper(contentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, monitor); GameContentHelper gameContentHelper = new(contentCore, manifest.UniqueID, mod.DisplayName, monitor); - IModContentHelper modContentHelper = new ModContentHelper(contentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, gameContentHelper.GetUnderlyingContentManager()); + IModContentHelper modContentHelper = new ModContentHelper(contentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, gameContentHelper.GetUnderlyingContentManager(), relativePathCache); IContentPackHelper contentPackHelper = new ContentPackHelper(manifest.UniqueID, new Lazy<IContentPack[]>(GetContentPacks), CreateFakeContentPack); IDataHelper dataHelper = new DataHelper(manifest.UniqueID, mod.DirectoryPath, jsonHelper); IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, mod.DisplayName, this.Reflection); diff --git a/src/SMAPI/Utilities/CaseInsensitivePathCache.cs b/src/SMAPI/Utilities/CaseInsensitivePathCache.cs new file mode 100644 index 00000000..1d947b53 --- /dev/null +++ b/src/SMAPI/Utilities/CaseInsensitivePathCache.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace StardewModdingAPI.Utilities +{ + /// <summary>Provides an API for case-insensitive relative path lookups within a root directory.</summary> + internal class CaseInsensitivePathCache + { + /********* + ** Fields + *********/ + /// <summary>The root directory path for relative paths.</summary> + private readonly string RootPath; + + /// <summary>A case-insensitive lookup of file paths within the <see cref="RootPath"/>. Each path is listed in both file path and asset name format, so it's usable in both contexts without needing to re-parse paths.</summary> + private readonly Lazy<Dictionary<string, string>> RelativePathCache; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="rootPath">The root directory path for relative paths.</param> + public CaseInsensitivePathCache(string rootPath) + { + this.RootPath = rootPath; + this.RelativePathCache = new(this.GetRelativePathCache); + } + + /// <summary>Get the exact capitalization for a given relative file path.</summary> + /// <param name="relativePath">The relative path.</param> + /// <remarks>Returns the resolved path in file path format, else the normalized <paramref name="relativePath"/>.</remarks> + public string GetFilePath(string relativePath) + { + return this.GetImpl(PathUtilities.NormalizePath(relativePath)); + } + + /// <summary>Get the exact capitalization for a given asset name.</summary> + /// <param name="relativePath">The relative path.</param> + /// <remarks>Returns the resolved path in asset name format, else the normalized <paramref name="relativePath"/>.</remarks> + public string GetAssetName(string relativePath) + { + return this.GetImpl(PathUtilities.NormalizeAssetName(relativePath)); + } + + /// <summary>Add a relative path that was just created by a SMAPI API.</summary> + /// <param name="relativePath">The relative path. This must already be normalized in asset name or file path format.</param> + public void Add(string relativePath) + { + // skip if cache isn't created yet (no need to add files manually in that case) + if (!this.RelativePathCache.IsValueCreated) + return; + + // skip if already cached + if (this.RelativePathCache.Value.ContainsKey(relativePath)) + return; + + // make sure path exists + relativePath = PathUtilities.NormalizePath(relativePath); + if (!File.Exists(Path.Combine(this.RootPath, relativePath))) + throw new InvalidOperationException($"Can't add relative path '{relativePath}' to the case-insensitive cache for '{this.RootPath}' because that file doesn't exist."); + + // cache path + this.CacheRawPath(this.RelativePathCache.Value, relativePath); + } + + + /********* + ** Private methods + *********/ + /// <summary>Get the exact capitalization for a given relative path.</summary> + /// <param name="relativePath">The relative path. This must already be normalized into asset name or file path format (i.e. using <see cref="PathUtilities.NormalizeAssetName"/> or <see cref="PathUtilities.NormalizePath"/> respectively).</param> + /// <remarks>Returns the resolved path in the same format if found, else returns the path as-is.</remarks> + private string GetImpl(string relativePath) + { + // invalid path + if (string.IsNullOrWhiteSpace(relativePath)) + return relativePath; + + // already cached + if (this.RelativePathCache.Value.TryGetValue(relativePath, out string resolved)) + return resolved; + + // file exists but isn't cached for some reason + // cache it now so any later references to it are case-insensitive + if (File.Exists(Path.Combine(this.RootPath, relativePath))) + { + this.CacheRawPath(this.RelativePathCache.Value, relativePath); + return relativePath; + } + + // no such file, keep capitalization as-is + return relativePath; + } + + /// <summary>Get a case-insensitive lookup of file paths (see <see cref="RelativePathCache"/>).</summary> + private Dictionary<string, string> GetRelativePathCache() + { + Dictionary<string, string> cache = new(StringComparer.OrdinalIgnoreCase); + + foreach (string path in Directory.EnumerateFiles(this.RootPath, "*", SearchOption.AllDirectories)) + { + string relativePath = path.Substring(this.RootPath.Length + 1); + + this.CacheRawPath(cache, relativePath); + } + + return cache; + } + + /// <summary>Add a raw relative path to the cache.</summary> + /// <param name="cache">The cache to update.</param> + /// <param name="relativePath">The relative path to cache, with its exact filesystem capitalization.</param> + private void CacheRawPath(IDictionary<string, string> cache, string relativePath) + { + string filePath = PathUtilities.NormalizePath(relativePath); + string assetName = PathUtilities.NormalizeAssetName(relativePath); + + cache[filePath] = filePath; + cache[assetName] = assetName; + } + } +} |