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 7, 2022
2 parents c8ad50d + b45f50b commit e7e6327
Show file tree
Hide file tree
Showing 28 changed files with 339 additions and 209 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.0</Version>
<Version>3.14.1</Version>
<Product>SMAPI</Product>
<LangVersion>latest</LangVersion>
<AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths>
Expand Down
13 changes: 13 additions & 0 deletions docs/release-notes.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
[README](README.md)

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

* For players:
* Improved performance for mods still using the previous content API.
* Disabled case-insensitive file paths (introduced in 3.14.0) by default.
_You can enable them by editing `smapi-internal/config.json` if needed. They'll be re-enabled in an upcoming version after they're reworked a bit._
* Removed experimental 'aggressive memory optimizations' option.
_This was disabled by default and is no longer needed in most cases. Memory usage will be better reduced by reworked asset propagation in the upcoming SMAPI 4.0.0._
* Fixed 'content file was not found' error when the game tries to load unlocalized text from a localizable mod data asset in 3.14.0.
* Fixed error reading empty JSON files. These are now treated as if they didn't exist (matching pre-3.14.0 behavior).
* Updated compatibility list.

## 3.14.0
Released 01 May 2022 for Stardew Valley 1.5.6 or later. See [release highlights](https://www.patreon.com/posts/65265507).

Expand Down
4 changes: 2 additions & 2 deletions src/SMAPI.Installer/InteractiveInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -435,8 +435,8 @@ public void Run(string[] args)
{
this.PrintDebug("Adding bundled mods...");

ModFolder[] targetMods = toolkit.GetModFolders(paths.ModsPath).ToArray();
foreach (ModFolder sourceMod in toolkit.GetModFolders(bundledModsDir.FullName))
ModFolder[] targetMods = toolkit.GetModFolders(paths.ModsPath, useCaseInsensitiveFilePaths: true).ToArray();
foreach (ModFolder sourceMod in toolkit.GetModFolders(bundledModsDir.FullName, useCaseInsensitiveFilePaths: true))
{
// validate source mod
if (sourceMod.Manifest == null)
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.0",
"Version": "3.14.1",
"Description": "Adds SMAPI console commands that let you manipulate the game.",
"UniqueID": "SMAPI.ConsoleCommands",
"EntryDll": "ConsoleCommands.dll",
"MinimumApiVersion": "3.14.0"
"MinimumApiVersion": "3.14.1"
}
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.0",
"Version": "3.14.1",
"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.0"
"MinimumApiVersion": "3.14.1"
}
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.0",
"Version": "3.14.1",
"Description": "Automatically backs up all your saves once per day into its folder.",
"UniqueID": "SMAPI.SaveBackup",
"EntryDll": "SaveBackup.dll",
"MinimumApiVersion": "3.14.0"
"MinimumApiVersion": "3.14.1"
}
21 changes: 11 additions & 10 deletions src/SMAPI.Tests/Core/ModResolverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using StardewModdingAPI.Toolkit.Framework.ModData;
using StardewModdingAPI.Toolkit.Framework.UpdateData;
using StardewModdingAPI.Toolkit.Serialization.Models;
using StardewModdingAPI.Toolkit.Utilities.PathLookups;
using SemanticVersion = StardewModdingAPI.SemanticVersion;

namespace SMAPI.Tests.Core
Expand All @@ -34,7 +35,7 @@ public void ReadBasicManifest_NoMods_ReturnsEmptyList()
Directory.CreateDirectory(rootFolder);

// act
IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase()).ToArray();
IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase(), useCaseInsensitiveFilePaths: true).ToArray();

// assert
Assert.AreEqual(0, mods.Length, 0, $"Expected to find zero manifests, found {mods.Length} instead.");
Expand All @@ -52,7 +53,7 @@ public void ReadBasicManifest_EmptyModFolder_ReturnsFailedManifest()
Directory.CreateDirectory(modFolder);

// act
IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase()).ToArray();
IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase(), useCaseInsensitiveFilePaths: true).ToArray();
IModMetadata? mod = mods.FirstOrDefault();

// assert
Expand Down Expand Up @@ -94,7 +95,7 @@ public void ReadBasicManifest_CanReadFile()
File.WriteAllText(filename, JsonConvert.SerializeObject(original));

// act
IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase()).ToArray();
IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase(), useCaseInsensitiveFilePaths: true).ToArray();
IModMetadata? mod = mods.FirstOrDefault();

// assert
Expand Down Expand Up @@ -132,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, validateFilesExist: false);
new ModResolver().ValidateManifests(Array.Empty<ModMetadata>(), apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFilePathLookup: _ => MinimalPathLookup.Instance, validateFilesExist: false);
}

[Test(Description = "Assert that validation skips manifests that have already failed without calling any other properties.")]
Expand All @@ -143,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, validateFilesExist: false);
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFilePathLookup: _ => MinimalPathLookup.Instance, validateFilesExist: false);

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

// act
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, validateFilesExist: false);
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFilePathLookup: _ => MinimalPathLookup.Instance, 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 @@ -174,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, validateFilesExist: false);
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFilePathLookup: _ => MinimalPathLookup.Instance, 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 @@ -189,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);
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFilePathLookup: _ => MinimalPathLookup.Instance);

// 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 @@ -206,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, validateFilesExist: false);
new ModResolver().ValidateManifests(new[] { modA.Object, modB.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFilePathLookup: _ => MinimalPathLookup.Instance, 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 @@ -232,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);
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFilePathLookup: _ => MinimalPathLookup.Instance);

// assert
// if Moq doesn't throw a method-not-setup exception, the validation didn't override the status.
Expand Down
18 changes: 10 additions & 8 deletions src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
using System.Text.RegularExpressions;
using StardewModdingAPI.Toolkit.Serialization;
using StardewModdingAPI.Toolkit.Serialization.Models;
using StardewModdingAPI.Toolkit.Utilities;
using StardewModdingAPI.Toolkit.Utilities.PathLookups;

namespace StardewModdingAPI.Toolkit.Framework.ModScanning
{
Expand Down Expand Up @@ -95,19 +95,20 @@ public ModScanner(JsonHelper jsonHelper)

/// <summary>Extract information about all mods in the given folder.</summary>
/// <param name="rootPath">The root folder containing mods.</param>
public IEnumerable<ModFolder> GetModFolders(string rootPath)
/// <param name="useCaseInsensitiveFilePaths">Whether to match file paths case-insensitively, even on Linux.</param>
public IEnumerable<ModFolder> GetModFolders(string rootPath, bool useCaseInsensitiveFilePaths)
{
DirectoryInfo root = new(rootPath);
return this.GetModFolders(root, root);
return this.GetModFolders(root, root, useCaseInsensitiveFilePaths);
}

/// <summary>Extract information about all mods in the given folder.</summary>
/// <param name="rootPath">The root folder containing mods. Only the <paramref name="modPath"/> will be searched, but this field allows it to be treated as a potential mod folder of its own.</param>
/// <param name="modPath">The mod path to search.</param>
// /// <param name="tryConsolidateMod">If the folder contains multiple XNB mods, treat them as subfolders of a single mod. This is useful when reading a single mod archive, as opposed to a mods folder.</param>
public IEnumerable<ModFolder> GetModFolders(string rootPath, string modPath)
/// <param name="useCaseInsensitiveFilePaths">Whether to match file paths case-insensitively, even on Linux.</param>
public IEnumerable<ModFolder> GetModFolders(string rootPath, string modPath, bool useCaseInsensitiveFilePaths)
{
return this.GetModFolders(root: new DirectoryInfo(rootPath), folder: new DirectoryInfo(modPath));
return this.GetModFolders(root: new DirectoryInfo(rootPath), folder: new DirectoryInfo(modPath), useCaseInsensitiveFilePaths: useCaseInsensitiveFilePaths);
}

/// <summary>Extract information from a mod folder.</summary>
Expand Down Expand Up @@ -195,7 +196,8 @@ public ModFolder ReadFolder(DirectoryInfo root, DirectoryInfo searchFolder)
/// <summary>Recursively extract information about all mods in the given folder.</summary>
/// <param name="root">The root mod folder.</param>
/// <param name="folder">The folder to search for mods.</param>
private IEnumerable<ModFolder> GetModFolders(DirectoryInfo root, DirectoryInfo folder)
/// <param name="useCaseInsensitiveFilePaths">Whether to match file paths case-insensitively, even on Linux.</param>
private IEnumerable<ModFolder> GetModFolders(DirectoryInfo root, DirectoryInfo folder, bool useCaseInsensitiveFilePaths)
{
bool isRoot = folder.FullName == root.FullName;

Expand All @@ -214,7 +216,7 @@ private IEnumerable<ModFolder> GetModFolders(DirectoryInfo root, DirectoryInfo f
// find mods in subfolders
if (this.IsModSearchFolder(root, folder))
{
IEnumerable<ModFolder> subfolders = folder.EnumerateDirectories().SelectMany(sub => this.GetModFolders(root, sub));
IEnumerable<ModFolder> subfolders = folder.EnumerateDirectories().SelectMany(sub => this.GetModFolders(root, sub, useCaseInsensitiveFilePaths));
if (!isRoot)
subfolders = this.TryConsolidate(root, folder, subfolders.ToArray());
foreach (ModFolder subfolder in subfolders)
Expand Down
10 changes: 6 additions & 4 deletions src/SMAPI.Toolkit/ModToolkit.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,17 +72,19 @@ public ModDatabase GetModDatabase(string metadataPath)

/// <summary>Extract information about all mods in the given folder.</summary>
/// <param name="rootPath">The root folder containing mods.</param>
public IEnumerable<ModFolder> GetModFolders(string rootPath)
/// <param name="useCaseInsensitiveFilePaths">Whether to match file paths case-insensitively, even on Linux.</param>
public IEnumerable<ModFolder> GetModFolders(string rootPath, bool useCaseInsensitiveFilePaths)
{
return new ModScanner(this.JsonHelper).GetModFolders(rootPath);
return new ModScanner(this.JsonHelper).GetModFolders(rootPath, useCaseInsensitiveFilePaths);
}

/// <summary>Extract information about all mods in the given folder.</summary>
/// <param name="rootPath">The root folder containing mods. Only the <paramref name="modPath"/> will be searched, but this field allows it to be treated as a potential mod folder of its own.</param>
/// <param name="modPath">The mod path to search.</param>
public IEnumerable<ModFolder> GetModFolders(string rootPath, string modPath)
/// <param name="useCaseInsensitiveFilePaths">Whether to match file paths case-insensitively, even on Linux.</param>
public IEnumerable<ModFolder> GetModFolders(string rootPath, string modPath, bool useCaseInsensitiveFilePaths)
{
return new ModScanner(this.JsonHelper).GetModFolders(rootPath, modPath);
return new ModScanner(this.JsonHelper).GetModFolders(rootPath, modPath, useCaseInsensitiveFilePaths);
}

/// <summary>Get an update URL for an update key (if valid).</summary>
Expand Down
5 changes: 2 additions & 3 deletions src/SMAPI.Toolkit/Serialization/JsonHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,11 @@ public void WriteJsonFile<TModel>(string fullPath, TModel model)
/// <summary>Deserialize JSON text if possible.</summary>
/// <typeparam name="TModel">The model type.</typeparam>
/// <param name="json">The raw JSON text.</param>
public TModel Deserialize<TModel>(string json)
public TModel? Deserialize<TModel>(string json)
{
try
{
return JsonConvert.DeserializeObject<TModel>(json, this.JsonSettings)
?? throw new InvalidOperationException($"Couldn't deserialize model type '{typeof(TModel)}' from empty or null JSON.");
return JsonConvert.DeserializeObject<TModel>(json, this.JsonSettings);
}
catch (JsonReaderException)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
using System.Collections.Generic;
using System.IO;

namespace StardewModdingAPI.Toolkit.Utilities
namespace StardewModdingAPI.Toolkit.Utilities.PathLookups
{
/// <summary>Provides an API for case-insensitive relative path lookups within a root directory.</summary>
internal class CaseInsensitivePathLookup
/// <summary>An API for case-insensitive relative path lookups within a root directory.</summary>
internal class CaseInsensitivePathLookup : IFilePathLookup
{
/*********
** Fields
Expand All @@ -32,24 +32,19 @@ public CaseInsensitivePathLookup(string rootPath, SearchOption searchOption = Se
this.RelativePathCache = new(() => this.GetRelativePathCache(searchOption));
}

/// <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>
/// <inheritdoc />
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>
/// <inheritdoc />
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>
/// <inheritdoc />
public void Add(string relativePath)
{
// skip if cache isn't created yet (no need to add files manually in that case)
Expand Down
20 changes: 20 additions & 0 deletions src/SMAPI.Toolkit/Utilities/PathLookups/IFilePathLookup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace StardewModdingAPI.Toolkit.Utilities.PathLookups
{
/// <summary>An API for relative path lookups within a root directory.</summary>
internal interface IFilePathLookup
{
/// <summary>Get the actual path 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>
string GetFilePath(string relativePath);

/// <summary>Get the actual path 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>
string GetAssetName(string 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>
void Add(string relativePath);
}
}
Loading

0 comments on commit e7e6327

Please sign in to comment.