summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/release-notes.md15
-rw-r--r--src/SMAPI/Framework/ContentCoordinator.cs22
-rw-r--r--src/SMAPI/Framework/ContentManagers/ModContentManager.cs14
-rw-r--r--src/SMAPI/Framework/ContentPack.cs38
-rw-r--r--src/SMAPI/Framework/ModHelpers/ModContentHelper.cs16
-rw-r--r--src/SMAPI/Framework/SCore.cs15
-rw-r--r--src/SMAPI/Utilities/CaseInsensitivePathCache.cs124
7 files changed, 200 insertions, 44 deletions
diff --git a/docs/release-notes.md b/docs/release-notes.md
index 2e09240c..6d505d40 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -8,6 +8,7 @@
* Improved Linux/macOS [command-line arguments](technical/smapi.md#command-line-arguments):
* Added `--use-current-shell` to avoid opening a separate terminal window.
* Fixed `--no-terminal` still opening a terminal window, even if nothing is logged to it (thanks to Ryhon0!).
+ * SMAPI now fixes many case-sensitive mod file path issues automatically.
* Improved translations. Thanks to ChulkyBow (updated Ukrainian)!
* For the Console Commands mod:
@@ -15,12 +16,14 @@
* Fixed `set_farm_type` not updating warps.
* For mod authors:
- * **Added [content events](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Events#Content), which will replace `IAssetEditor` and `IAssetLoader` in SMAPI 4.0.0.**
- _These include new features not supported by the old API like load conflict resolution, edit priority, and content pack labels. They also support new cases like easily detecting when an asset has changed, and avoid data corruption issues in some edge cases._
- * **Added `helper.GameContent` and `helper.ModContent`, which will replace `helper.Content` in SMAPI 4.0.0.**
- * **Overhauled [mod-provided API](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Integrations#Mod-provided_APIs) proxying** (thanks to Shockah!).
- _This adds support for many previously unsupported cases: proxied interfaces in return values or input arguments, proxied enums if their values match, generic methods, and more. Existing mod APIs should work fine as-is._
- * **Deprecation warning:** The upcoming SMAPI 4.0 will remove deprecated APIs and break mods which haven't updated yet.
+ * **Major changes as part of the upcoming SMAPI 4.0.0:**
+ * Added [content events](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Events#Content), which will replace `IAssetEditor` and `IAssetLoader` in SMAPI 4.0.0.
+ _These include new features not supported by the old API like load conflict resolution, edit priority, and content pack labels. They also support new cases like easily detecting when an asset has changed, and avoid data corruption issues in some edge cases._
+ * Added `helper.GameContent` and `helper.ModContent`, which will replace `helper.Content` in SMAPI 4.0.0.
+ * Overhauled [mod-provided API](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Integrations#Mod-provided_APIs) proxying (thanks to Shockah!).
+ _This adds support for many previously unsupported cases: proxied interfaces in return values or input arguments, proxied enums if their values match, generic methods, and more. Existing mod APIs should work fine as-is._
+ * Mod files loaded through SMAPI APIs (including `helper.Content.Load`) are now case-insensitive, even on Linux.
+ * **Deprecation warning:** The upcoming SMAPI 4.0 will remove deprecated APIs and break mods which haven't updated yet.
_See [_Migrate to SMAPI 4.0_](https://stardewvalleywiki.com/Modding:Migrate_to_SMAPI_4.0) for help updating your mod code. You can update your mod code now, there's no need to wait for the 4.0.0 release (which will happen in at least three months, and possibly later if needed to update open-source mods)._
* Added `IContentPack.ModContent` property.
* Added `Constants.ContentPath`.
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;
+ }
+ }
+}