Skip to content

Commit

Permalink
Merge branch 'develop' into stable
Browse files Browse the repository at this point in the history
  • Loading branch information
Pathoschild committed May 9, 2022
2 parents e7e6327 + cbe8b59 commit 09f69d9
Show file tree
Hide file tree
Showing 31 changed files with 347 additions and 407 deletions.
2 changes: 1 addition & 1 deletion build/common.targets
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<!--set general build properties -->
<Version>3.14.1</Version>
<Version>3.14.2</Version>
<Product>SMAPI</Product>
<LangVersion>latest</LangVersion>
<AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths>
Expand Down
11 changes: 11 additions & 0 deletions docs/release-notes.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
[README](README.md)

# Release notes
## 3.14.2
Released 08 May 2022 for Stardew Valley 1.5.6 or later.

* For players:
* Enabled case-insensitive file paths by default for Android and Linux players.
_This was temporarily disabled in SMAPI 3.14.1, and will remain disabled by default on macOS and Windows since their filesystems are already case-insensitive._
* Various performance improvements.
* For mod authors:
* Dynamic content packs created via `helper.ContentPacks.CreateTemporary` or `CreateFake` are now listed in the log file.
* Fixed assets loaded through a fake content pack not working correctly since 3.14.0.

## 3.14.1
Released 06 May 2022 for Stardew Valley 1.5.6 or later.

Expand Down
4 changes: 2 additions & 2 deletions src/SMAPI.Mods.ConsoleCommands/manifest.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"Name": "Console Commands",
"Author": "SMAPI",
"Version": "3.14.1",
"Version": "3.14.2",
"Description": "Adds SMAPI console commands that let you manipulate the game.",
"UniqueID": "SMAPI.ConsoleCommands",
"EntryDll": "ConsoleCommands.dll",
"MinimumApiVersion": "3.14.1"
"MinimumApiVersion": "3.14.2"
}
4 changes: 2 additions & 2 deletions src/SMAPI.Mods.ErrorHandler/manifest.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"Name": "Error Handler",
"Author": "SMAPI",
"Version": "3.14.1",
"Version": "3.14.2",
"Description": "Handles some common vanilla errors to log more useful info or avoid breaking the game.",
"UniqueID": "SMAPI.ErrorHandler",
"EntryDll": "ErrorHandler.dll",
"MinimumApiVersion": "3.14.1"
"MinimumApiVersion": "3.14.2"
}
4 changes: 2 additions & 2 deletions src/SMAPI.Mods.SaveBackup/manifest.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"Name": "Save Backup",
"Author": "SMAPI",
"Version": "3.14.1",
"Version": "3.14.2",
"Description": "Automatically backs up all your saves once per day into its folder.",
"UniqueID": "SMAPI.SaveBackup",
"EntryDll": "SaveBackup.dll",
"MinimumApiVersion": "3.14.1"
"MinimumApiVersion": "3.14.2"
}
21 changes: 14 additions & 7 deletions src/SMAPI.Tests/Core/ModResolverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ public void ReadBasicManifest_CanReadFile()
[Test(Description = "Assert that validation doesn't fail if there are no mods installed.")]
public void ValidateManifests_NoMods_DoesNothing()
{
new ModResolver().ValidateManifests(Array.Empty<ModMetadata>(), apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFilePathLookup: _ => MinimalPathLookup.Instance, validateFilesExist: false);
new ModResolver().ValidateManifests(Array.Empty<ModMetadata>(), apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false);
}

[Test(Description = "Assert that validation skips manifests that have already failed without calling any other properties.")]
Expand All @@ -144,7 +144,7 @@ public void ValidateManifests_Skips_Failed()
mock.Setup(p => p.Status).Returns(ModMetadataStatus.Failed);

// act
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFilePathLookup: _ => MinimalPathLookup.Instance, validateFilesExist: false);
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false);

// assert
mock.VerifyGet(p => p.Status, Times.Once, "The validation did not check the manifest status.");
Expand All @@ -161,7 +161,7 @@ public void ValidateManifests_ModStatus_AssumeBroken_Fails()
});

// act
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFilePathLookup: _ => MinimalPathLookup.Instance, validateFilesExist: false);
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false);

// assert
mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "The validation did not fail the metadata.");
Expand All @@ -175,7 +175,7 @@ public void ValidateManifests_MinimumApiVersion_Fails()
mock.Setup(p => p.Manifest).Returns(this.GetManifest(minimumApiVersion: "1.1"));

// act
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFilePathLookup: _ => MinimalPathLookup.Instance, validateFilesExist: false);
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false);

// assert
mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "The validation did not fail the metadata.");
Expand All @@ -190,7 +190,7 @@ public void ValidateManifests_MissingEntryDLL_Fails()
Directory.CreateDirectory(directoryPath);

// act
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFilePathLookup: _ => MinimalPathLookup.Instance);
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup);

// assert
mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "The validation did not fail the metadata.");
Expand All @@ -207,7 +207,7 @@ public void ValidateManifests_DuplicateUniqueID_Fails()
Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest(id: "Mod A", name: "Mod B", version: "1.0"), allowStatusChange: true);

// act
new ModResolver().ValidateManifests(new[] { modA.Object, modB.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFilePathLookup: _ => MinimalPathLookup.Instance, validateFilesExist: false);
new ModResolver().ValidateManifests(new[] { modA.Object, modB.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false);

// assert
modA.Verify(p => p.SetStatus(ModMetadataStatus.Failed, ModFailReason.Duplicate, It.IsAny<string>(), It.IsAny<string>()), Times.AtLeastOnce, "The validation did not fail the first mod with a unique ID.");
Expand All @@ -233,7 +233,7 @@ public void ValidateManifests_Valid_Passes()
mock.Setup(p => p.DirectoryPath).Returns(modFolder);

// act
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFilePathLookup: _ => MinimalPathLookup.Instance);
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup);

// assert
// if Moq doesn't throw a method-not-setup exception, the validation didn't override the status.
Expand Down Expand Up @@ -483,6 +483,13 @@ private string GetTempFolderPath()
return Path.Combine(Path.GetTempPath(), "smapi-unit-tests", Guid.NewGuid().ToString("N"));
}

/// <summary>Get a file lookup for a given directory.</summary>
/// <param name="rootDirectory">The full path to the directory.</param>
private IFileLookup GetFileLookup(string rootDirectory)
{
return MinimalFileLookup.GetCachedFor(rootDirectory);
}

/// <summary>Get a randomized basic manifest.</summary>
/// <param name="id">The <see cref="IManifest.UniqueID"/> value, or <c>null</c> for a generated value.</param>
/// <param name="name">The <see cref="IManifest.Name"/> value, or <c>null</c> for a generated value.</param>
Expand Down
22 changes: 12 additions & 10 deletions src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,11 @@ public IEnumerable<ModFolder> GetModFolders(string rootPath, string modPath, boo
/// <summary>Extract information from a mod folder.</summary>
/// <param name="root">The root folder containing mods.</param>
/// <param name="searchFolder">The folder to search for a mod.</param>
public ModFolder ReadFolder(DirectoryInfo root, DirectoryInfo searchFolder)
/// <param name="useCaseInsensitiveFilePaths">Whether to match file paths case-insensitively, even on Linux.</param>
public ModFolder ReadFolder(DirectoryInfo root, DirectoryInfo searchFolder, bool useCaseInsensitiveFilePaths)
{
// find manifest.json
FileInfo? manifestFile = this.FindManifest(searchFolder);
FileInfo? manifestFile = this.FindManifest(searchFolder, useCaseInsensitiveFilePaths);

// set appropriate invalid-mod error
if (manifestFile == null)
Expand Down Expand Up @@ -225,7 +226,7 @@ private IEnumerable<ModFolder> GetModFolders(DirectoryInfo root, DirectoryInfo f

// treat as mod folder
else
yield return this.ReadFolder(root, folder);
yield return this.ReadFolder(root, folder, useCaseInsensitiveFilePaths);
}

/// <summary>Consolidate adjacent folders into one mod folder, if possible.</summary>
Expand All @@ -250,7 +251,8 @@ private IEnumerable<ModFolder> TryConsolidate(DirectoryInfo root, DirectoryInfo

/// <summary>Find the manifest for a mod folder.</summary>
/// <param name="folder">The folder to search.</param>
private FileInfo? FindManifest(DirectoryInfo folder)
/// <param name="useCaseInsensitiveFilePaths">Whether to match file paths case-insensitively, even on Linux.</param>
private FileInfo? FindManifest(DirectoryInfo folder, bool useCaseInsensitiveFilePaths)
{
// check for conventional manifest in current folder
const string defaultName = "manifest.json";
Expand All @@ -259,14 +261,14 @@ private IEnumerable<ModFolder> TryConsolidate(DirectoryInfo root, DirectoryInfo
return file;

// check for manifest with incorrect capitalization
if (useCaseInsensitiveFilePaths)
{
CaseInsensitivePathLookup pathLookup = new(folder.FullName, SearchOption.TopDirectoryOnly); // don't use GetCachedFor, since we only need it temporarily
string realName = pathLookup.GetFilePath(defaultName);
if (realName != defaultName)
file = new(Path.Combine(folder.FullName, realName));
CaseInsensitiveFileLookup fileLookup = new(folder.FullName, SearchOption.TopDirectoryOnly); // don't use GetCachedFor, since we only need it temporarily
file = fileLookup.GetFile(defaultName);
return file.Exists
? file
: null;
}
if (file.Exists)
return file;

// not found
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

namespace StardewModdingAPI.Toolkit.Utilities.PathLookups
{
/// <summary>An API for case-insensitive relative path lookups within a root directory.</summary>
internal class CaseInsensitivePathLookup : IFilePathLookup
/// <summary>An API for case-insensitive file lookups within a root directory.</summary>
internal class CaseInsensitiveFileLookup : IFileLookup
{
/*********
** Fields
Expand All @@ -16,8 +16,8 @@ internal class CaseInsensitivePathLookup : IFilePathLookup
/// <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;

/// <summary>The case-insensitive path caches by root path.</summary>
private static readonly Dictionary<string, CaseInsensitivePathLookup> CachedRoots = new(StringComparer.OrdinalIgnoreCase);
/// <summary>The case-insensitive file lookups by root path.</summary>
private static readonly Dictionary<string, CaseInsensitiveFileLookup> CachedRoots = new(StringComparer.OrdinalIgnoreCase);


/*********
Expand All @@ -26,22 +26,28 @@ internal class CaseInsensitivePathLookup : IFilePathLookup
/// <summary>Construct an instance.</summary>
/// <param name="rootPath">The root directory path for relative paths.</param>
/// <param name="searchOption">Which directories to scan from the root.</param>
public CaseInsensitivePathLookup(string rootPath, SearchOption searchOption = SearchOption.AllDirectories)
public CaseInsensitiveFileLookup(string rootPath, SearchOption searchOption = SearchOption.AllDirectories)
{
this.RootPath = rootPath;
this.RootPath = PathUtilities.NormalizePath(rootPath);
this.RelativePathCache = new(() => this.GetRelativePathCache(searchOption));
}

/// <inheritdoc />
public string GetFilePath(string relativePath)
public FileInfo GetFile(string relativePath)
{
return this.GetImpl(PathUtilities.NormalizePath(relativePath));
}
// invalid path
if (string.IsNullOrWhiteSpace(relativePath))
throw new InvalidOperationException("Can't get a file from an empty relative path.");

/// <inheritdoc />
public string GetAssetName(string relativePath)
{
return this.GetImpl(PathUtilities.NormalizeAssetName(relativePath));
// already cached
if (this.RelativePathCache.Value.TryGetValue(relativePath, out string? resolved))
return new(Path.Combine(this.RootPath, resolved));

// keep capitalization as-is
FileInfo file = new(Path.Combine(this.RootPath, relativePath));
if (file.Exists)
this.RelativePathCache.Value[relativePath] = relativePath;
return file;
}

/// <inheritdoc />
Expand All @@ -61,17 +67,17 @@ public void Add(string 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);
this.RelativePathCache.Value[relativePath] = relativePath;
}

/// <summary>Get a cached dictionary of relative paths within a root path, for case-insensitive file lookups.</summary>
/// <param name="rootPath">The root path to scan.</param>
public static CaseInsensitivePathLookup GetCachedFor(string rootPath)
public static CaseInsensitiveFileLookup GetCachedFor(string rootPath)
{
rootPath = PathUtilities.NormalizePath(rootPath);

if (!CaseInsensitivePathLookup.CachedRoots.TryGetValue(rootPath, out CaseInsensitivePathLookup? cache))
CaseInsensitivePathLookup.CachedRoots[rootPath] = cache = new CaseInsensitivePathLookup(rootPath);
if (!CaseInsensitiveFileLookup.CachedRoots.TryGetValue(rootPath, out CaseInsensitiveFileLookup? cache))
CaseInsensitiveFileLookup.CachedRoots[rootPath] = cache = new CaseInsensitiveFileLookup(rootPath);

return cache;
}
Expand All @@ -80,29 +86,6 @@ public static CaseInsensitivePathLookup GetCachedFor(string rootPath)
/*********
** 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;

// keep capitalization as-is
if (File.Exists(Path.Combine(this.RootPath, relativePath)))
{
// file exists but isn't cached for some reason
// cache it now so any later references to it are case-insensitive
this.CacheRawPath(this.RelativePathCache.Value, relativePath);
}
return relativePath;
}

/// <summary>Get a case-insensitive lookup of file paths (see <see cref="RelativePathCache"/>).</summary>
/// <param name="searchOption">Which directories to scan from the root.</param>
private Dictionary<string, string> GetRelativePathCache(SearchOption searchOption)
Expand All @@ -112,23 +95,10 @@ private Dictionary<string, string> GetRelativePathCache(SearchOption searchOptio
foreach (string path in Directory.EnumerateFiles(this.RootPath, "*", searchOption))
{
string relativePath = path.Substring(this.RootPath.Length + 1);

this.CacheRawPath(cache, relativePath);
cache[relativePath] = 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;
}
}
}
16 changes: 16 additions & 0 deletions src/SMAPI.Toolkit/Utilities/PathLookups/IFileLookup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System.IO;

namespace StardewModdingAPI.Toolkit.Utilities.PathLookups
{
/// <summary>An API for file lookups within a root directory.</summary>
internal interface IFileLookup
{
/// <summary>Get the file for a given relative file path, if it exists.</summary>
/// <param name="relativePath">The relative path.</param>
FileInfo GetFile(string relativePath);

/// <summary>Add a relative path that was just created by a SMAPI API.</summary>
/// <param name="relativePath">The relative path.</param>
void Add(string relativePath);
}
}
Loading

0 comments on commit 09f69d9

Please sign in to comment.