diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
deleted file mode 100644
index c51d164bb..000000000
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ /dev/null
@@ -1,30 +0,0 @@
----
-name: Bug report
-about: Report a problem with SMAPI.
-
----
-
-
-
-**Describe the bug**
-A clear and concise description of what the bug is. Provide any other details you think might be relevant here.
-
-**To Reproduce**
-Exact steps which reproduce the bug, if possible. For example:
-1. Load save '...'.
-2. Walk to '....'.
-3. Click '....'.
-4. Error occurs.
-
-**Log file**
-Upload your SMAPI log to https://smapi.io/log and post a link here.
-
-**Screenshots**
-If applicable, add screenshots to help explain your problem.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 000000000..63d7bb67f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Troubleshooting guide for players
+ url: https://smapi.io/troubleshoot
+ about: See if your question is already answered first!
+ - name: Get help or discuss
+ url: https://smapi.io/help
+ about: Ask for help from the community, or join the Stardew Valley Discord to ask questions, report issues, or discuss with the SMAPI developer, players, and mod authors. The SMAPI developer is @Pathoschild#0001 on Discord.
diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md
new file mode 100644
index 000000000..11306e7f5
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/custom.md
@@ -0,0 +1,37 @@
+---
+name: Create a development task
+about: DON'T DO THIS BEFORE READING. This is for specific changes to the code or technical bug reports. See below if something isn't working, you have questions or ideas, or you want to discuss something.
+
+---
+
+
+
+**Describe the bug**
+A clear and concise description of what the bug is. Provide any other details you think might be relevant here.
+
+**To Reproduce**
+Exact steps which reproduce the bug, if possible. For example:
+1. Load save '...'.
+2. Walk to '....'.
+3. Click '....'.
+4. Error occurs.
+
+**Log file**
+Upload your SMAPI log to https://smapi.io/log and post a link here.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
deleted file mode 100644
index 8d935dc84..000000000
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-name: Feature request
-about: Suggest an idea for SMAPI.
-
----
-
-
diff --git a/.github/ISSUE_TEMPLATE/general.md b/.github/ISSUE_TEMPLATE/general.md
deleted file mode 100644
index f02d3c9a4..000000000
--- a/.github/ISSUE_TEMPLATE/general.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-name: General
-about: Create a ticket about something else.
-
----
-
-
diff --git a/build/0Harmony.dll b/build/0Harmony.dll
index 492255be2..72ca2b610 100644
Binary files a/build/0Harmony.dll and b/build/0Harmony.dll differ
diff --git a/build/0Harmony.xml b/build/0Harmony.xml
index 8499d20b9..f1b9b4cff 100644
--- a/build/0Harmony.xml
+++ b/build/0Harmony.xml
@@ -845,7 +845,7 @@
- The beginning of an except filter block
+ The beginning of an except filter block (currently not supported to use in a patch)
@@ -2660,6 +2660,18 @@
The optional operand
The optional name
+
+
+ Creates a code match that calls a method
+ The lambda expression using the method
+ The optional name
+
+
+
+ Creates a code match that calls a method
+ The lambda expression using the method
+ The optional name
+
Creates a code match
@@ -3216,6 +3228,13 @@
The enum
True if the instruction loads the constant
+
+
+ Tests if the code instruction loads a string constant
+ The
+ The string
+ True if the instruction loads the constant
+
Tests if the code instruction loads a field
@@ -3346,7 +3365,11 @@
A file log for debugging
-
+
+ Set this to make Harmony write its log content to this stream
+
+
+ Full pathname of the log file, defaults to a file called harmony.log.txt on your Desktop
diff --git a/build/common.targets b/build/common.targets
index 7fe66fabc..590e04155 100644
--- a/build/common.targets
+++ b/build/common.targets
@@ -7,7 +7,7 @@ repo. It imports the other MSBuild files as needed.
- 3.16.2
+ 3.18.2SMAPIlatest$(AssemblySearchPaths);{GAC}
diff --git a/build/windows/prepare-install-package.ps1 b/build/windows/prepare-install-package.ps1
index 87a4fe01f..71de11547 100644
--- a/build/windows/prepare-install-package.ps1
+++ b/build/windows/prepare-install-package.ps1
@@ -158,6 +158,10 @@ foreach ($folder in $folders) {
cp "$smapiBin/$name" "$bundlePath/smapi-internal"
}
+ if ($folder -eq "windows") {
+ cp "$smapiBin/VdfConverter.dll" "$bundlePath/smapi-internal"
+ }
+
cp "$smapiBin/SMAPI.config.json" "$bundlePath/smapi-internal/config.json"
cp "$smapiBin/SMAPI.metadata.json" "$bundlePath/smapi-internal/metadata.json"
if ($folder -eq "linux" -or $folder -eq "macOS") {
diff --git a/docs/release-notes.md b/docs/release-notes.md
index ea459bcb0..fe1306089 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -9,7 +9,86 @@
## Upcoming release
* For players:
- * Fixed update alert shown for a prerelease version on GitHub if it's not marked as prerelease.
+ * Fixed `findstr` error in installer for some players.
+ * Updated to [FluentHttpClient](https://github.com/Pathoschild/FluentHttpClient#readme) 4.3.0 (see [changes](https://github.com/Pathoschild/FluentHttpClient/blob/develop/RELEASE-NOTES.md#430)).
+ * Removed `LargeAddressAware` flag on SMAPI (no longer needed since it's 64-bit now).
+
+* For mod authors:
+ * Added support for [custom update manifests](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Update_checks#Custom_update_manifest) (thanks to Jamie Taylor!).
+ * Added `IsActiveForScreen()` method to `PerScreen`.
+
+## 3.18.2
+Released 09 January 2023 for Stardew Valley 1.5.6 or later.
+
+* For players:
+ * Fixed empty save backups for some macOS players.
+ * Fixed `player_add` console command not handling custom slingshots correctly (thanks too DaLion!).
+
+* For mod authors:
+ * Added `DelegatingModHooks` utility for mods which need to override SMAPI's mod hooks directly.
+ * Updated to Newtonsoft.Json 13.0.2 (see [changes](https://github.com/JamesNK/Newtonsoft.Json/releases/tag/13.0.2)) and Pintail 2.2.2 (see [changes](https://github.com/Nanoray-pl/Pintail/blob/master/docs/release-notes.md#222)).
+
+## 3.18.1
+Released 01 December 2022 for Stardew Valley 1.5.6 or later.
+
+* For players:
+ * Fixed mod texture edits sometimes cut off (thanks to atravita!).
+
+* For the web UI:
+ * The log parser no longer warns about missing Error Handler on Android, where it doesn't exist yet (thanks to AnotherPillow!).
+
+## 3.18.0
+Released 12 November 2022 for Stardew Valley 1.5.6 or later. See [release highlights](https://www.patreon.com/posts/74565278).
+
+* For players:
+ * You can now override the mod load order in `smapi-internal/config.json` (thanks to Shockah!).
+ * You can now disable console input in `smapi-internal/config.json`, which may reduce CPU usage on some Linux systems.
+ * Fixed map edits not always applied for farmhands in multiplayer (thanks to SinZ163!).
+ * Internal changes to prepare for the upcoming Stardew Valley 1.6 and SMAPI 4.0.
+
+* For mod authors:
+ * Optimized asset name comparisons (thanks to atravita!).
+ * Raised all deprecation messages to the 'pending removal' level.
+ * **This is the last major update before SMAPI 4.0.0, which will drop all deprecated APIs.** If you haven't [fixed deprecation warnings in your mod code](https://stardewvalleywiki.com/Modding:Migrate_to_SMAPI_4.0) (if any), you should do it soon. SMAPI 4.0.0 will release alongside the upcoming Stardew Valley 1.6.
+
+* For the web UI:
+ * The log parser now detects split-screen mode and shows which screen logged each message.
+
+## 3.17.2
+Released 21 October 2022 for Stardew Valley 1.5.6 or later.
+
+* For players:
+ * Fixed installer crash if Steam's library data is invalid or in an old format; it'll now be ignored instead.
+* For mod authors:
+ * Fixed image patches sometimes applied one pixel higher than expected after 3.17.0 (thanks to atravita!).
+
+## 3.17.1
+Released 10 October 2022 for Stardew Valley 1.5.6 or later.
+
+* For players:
+ * Fixed installer error on Windows if the Steam library folder exists but doesn't contain Steam's `.vdf` library data file.
+
+## 3.17.0
+Released 09 October 2022 for Stardew Valley 1.5.6 or later. See [release highlights](https://www.patreon.com/posts/73090322).
+
+* For players:
+ * You can now download SMAPI 'strict mode' from [Nexus files](https://www.nexusmods.com/stardewvalley/mods/2400/?tab=files), which removes all deprecated APIs. This may significantly improve performance, but mods which still show deprecation warnings won't work.
+ * The SMAPI installer now also detects game folders in Steam's `.vdf` library data on Windows (thanks to pizzaoverhead!).
+ * SMAPI now prevents mods from enabling Harmony debug mode, which impacts performance and creates a file on your desktop.
+ _You can allow debug mode by editing `smapi-internal/config.json` in your game folder._
+ * Optimized performance and memory usage (thanks to atravita!).
+ * Other internal optimizations.
+ * Added more file extensions to ignore when searching for mod folders: `.7z`, `.tar`, `.tar.gz`, and `.xcf` (thanks to atravita!).
+ * Removed transitional `UseRawImageLoading` option added in 3.15.0. This is now always enabled, except when PyTK is installed.
+ * Fixed update alerts incorrectly shown for prerelease versions on GitHub that aren't marked as prerelease.
+
+* For mod authors:
+ * When [providing a mod API in a C# mod](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Integrations), you can now get the mod requesting it as an optional parameter (thanks to KhloeLeclair!).
+ * SMAPI now treats square brackets in the manifest `Name` field as round ones to avoid breaking tools which parse log files.
+ * Made deprecation message wording stronger for the upcoming SMAPI 4.0.0 release.
+ * The `Texture2D.Name` field is now set earlier to support mods like SpriteMaster.
+ * Updated dependencies: [Harmony](https://harmony.pardeike.net) 2.2.2 (see [changes](https://github.com/pardeike/Harmony/releases/tag/v2.2.2.0)) and [FluentHttpClient](https://github.com/Pathoschild/FluentHttpClient#readme) 4.2.0 (see [changes](https://github.com/Pathoschild/FluentHttpClient/blob/develop/RELEASE-NOTES.md#420)).
+ * Fixed `LocationListChanged` event not raised & memory leak occurring when a generated mine/volcano is removed (thanks to tylergibbs2!).
## 3.16.2
Released 31 August 2022 for Stardew Valley 1.5.6 or later.
diff --git a/docs/technical/mod-package.md b/docs/technical/mod-package.md
index ca78be553..ab8a2c95e 100644
--- a/docs/technical/mod-package.md
+++ b/docs/technical/mod-package.md
@@ -99,6 +99,10 @@ There are two places you can put them:
2. Open the `.csproj` file in a text editor (Notepad is fine).
3. Add the properties between the first `` and `` tags you find.
+**Note:** you can't use a property before it's defined. That mainly means that when setting
+`GameModsPath`, you'll need to either specify `GamePath` manually or put the full path in
+`GameModsPath`.
+
### Available properties
These are the options you can set:
@@ -412,8 +416,18 @@ The NuGet package is generated automatically in `StardewModdingAPI.ModBuildConfi
when you compile it.
## Release notes
-### Upcoming release
+### 4.1.0
+Released 08 January 2023.
+
+* Added `manifest.json` format validation on build (thanks to tylergibbs2!).
+* Fixed game DLLs not excluded from the release zip when they're referenced explicitly but `BundleExtraAssemblies` isn't set.
+
+### 4.0.2
+Released 09 October 2022.
+
* Switched to the newer crossplatform `portable` debug symbols (thanks to lanturnalis!).
+* Fixed `BundleExtraAssemblies` option being partly case-sensitive.
+* Fixed `BundleExtraAssemblies` not applying `All` value to game assemblies.
### 4.0.1
Released 14 April 2022.
diff --git a/docs/technical/smapi.md b/docs/technical/smapi.md
index b8a1683b0..d115aefa2 100644
--- a/docs/technical/smapi.md
+++ b/docs/technical/smapi.md
@@ -78,8 +78,8 @@ the `SMAPI` project with debugging from Visual Studio or Rider should launch SMA
debugger attached, so you can intercept errors and step through the code being executed.
### Custom Harmony build
-SMAPI uses [a custom build of Harmony](https://github.com/Pathoschild/Harmony#readme), which is
-included in the `build` folder. To use a different build, just replace `0Harmony.dll` in that
+SMAPI uses [a custom build of Harmony 2.2.2](https://github.com/Pathoschild/Harmony#readme), which
+is included in the `build` folder. To use a different build, just replace `0Harmony.dll` in that
folder before compiling.
## Prepare a release
diff --git a/src/SMAPI.Installer/assets/install on Windows.bat b/src/SMAPI.Installer/assets/install on Windows.bat
index b0d9ae815..c61a801e1 100644
--- a/src/SMAPI.Installer/assets/install on Windows.bat
+++ b/src/SMAPI.Installer/assets/install on Windows.bat
@@ -4,7 +4,9 @@ setlocal enabledelayedexpansion
SET installerDir="%~dp0"
REM make sure we're not running within a zip folder
-echo %installerDir% | findstr /C:"%TEMP%" 1>nul
+REM The error level is usually 0 (install dir contains temp path), 1 (it doesn't), or 9009 (findstr doesn't exist due to a Windows issue).
+REM If the command doesn't exist, just skip this check.
+echo %installerDir% | findstr /C:"%TEMP%" 1>nul 2>null
if %ERRORLEVEL% EQU 0 (
echo Oops! It looks like you're running the installer from inside a zip file. Make sure you unzip the download first.
echo.
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj b/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj
index 3be9c2255..1719d39b2 100644
--- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj
@@ -6,9 +6,9 @@
-
+
-
+
diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs
index 88412d920..3508a6db8 100644
--- a/src/SMAPI.ModBuildConfig/DeployModTask.cs
+++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs
@@ -7,7 +7,11 @@
using System.Text.RegularExpressions;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
+using Newtonsoft.Json;
using StardewModdingAPI.ModBuildConfig.Framework;
+using StardewModdingAPI.Toolkit.Framework;
+using StardewModdingAPI.Toolkit.Serialization;
+using StardewModdingAPI.Toolkit.Serialization.Models;
using StardewModdingAPI.Toolkit.Utilities;
namespace StardewModdingAPI.ModBuildConfig
@@ -75,9 +79,41 @@ public override bool Execute()
this.Log.LogMessage(MessageImportance.High, $"[mod build package] Handling build with options {string.Join(", ", properties)}");
}
+ // skip if nothing to do
+ // (This must be checked before the manifest validation, to allow cases like unit test projects.)
if (!this.EnableModDeploy && !this.EnableModZip)
- return true; // nothing to do
+ return true;
+
+ // validate the manifest file
+ IManifest manifest;
+ {
+ try
+ {
+ string manifestPath = Path.Combine(this.ProjectDir, "manifest.json");
+ if (!new JsonHelper().ReadJsonFileIfExists(manifestPath, out Manifest rawManifest))
+ {
+ this.Log.LogError("[mod build package] The mod's manifest.json file doesn't exist.");
+ return false;
+ }
+ manifest = rawManifest;
+ }
+ catch (JsonReaderException ex)
+ {
+ // log the inner exception, otherwise the message will be generic
+ Exception exToShow = ex.InnerException ?? ex;
+ this.Log.LogError($"[mod build package] The mod's manifest.json file isn't valid JSON: {exToShow.Message}");
+ return false;
+ }
+
+ // validate manifest fields
+ if (!ManifestValidator.TryValidateFields(manifest, out string error))
+ {
+ this.Log.LogError($"[mod build package] The mod's manifest.json file is invalid: {error}");
+ return false;
+ }
+ }
+ // deploy files
try
{
// parse extra DLLs to bundle
@@ -101,7 +137,7 @@ public override bool Execute()
// create release zip
if (this.EnableModZip)
{
- string zipName = this.EscapeInvalidFilenameCharacters($"{this.ModFolderName} {package.GetManifestVersion()}.zip");
+ string zipName = this.EscapeInvalidFilenameCharacters($"{this.ModFolderName} {manifest.Version}.zip");
string zipPath = Path.Combine(this.ModZipPath, zipName);
this.Log.LogMessage(MessageImportance.High, $"[mod build package] Generating the release zip at {zipPath}...");
diff --git a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs
index 80955f67b..d47e492ac 100644
--- a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs
+++ b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs
@@ -3,8 +3,6 @@
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
-using StardewModdingAPI.Toolkit.Serialization;
-using StardewModdingAPI.Toolkit.Serialization.Models;
using StardewModdingAPI.Toolkit.Utilities;
namespace StardewModdingAPI.ModBuildConfig.Framework
@@ -113,16 +111,6 @@ public IDictionary GetFiles()
return new Dictionary(this.Files, StringComparer.OrdinalIgnoreCase);
}
- /// Get a semantic version from the mod manifest.
- /// The manifest is missing or invalid.
- public string GetManifestVersion()
- {
- if (!this.Files.TryGetValue(this.ManifestFileName, out FileInfo manifestFile) || !new JsonHelper().ReadJsonFileIfExists(manifestFile.FullName, out Manifest manifest))
- throw new InvalidOperationException($"The mod does not have a {this.ManifestFileName} file."); // shouldn't happen since we validate in constructor
-
- return manifest.Version.ToString();
- }
-
/*********
** Private methods
@@ -227,13 +215,24 @@ private bool ShouldIgnore(FileInfo file, string relativePath, string[] ignoreFil
return true;
}
- // check for bundled assembly types
- // When bundleAssemblyTypes is set, *all* dependencies are copied into the build output but only those which match the given assembly types should be bundled.
- if (bundleAssemblyTypes != ExtraAssemblyTypes.None)
+ // ignore by assembly type
+ ExtraAssemblyTypes type = this.GetExtraAssemblyType(file, modDllName);
+ switch (bundleAssemblyTypes)
{
- var type = this.GetExtraAssemblyType(file, modDllName);
- if (type != ExtraAssemblyTypes.None && !bundleAssemblyTypes.HasFlag(type))
- return true;
+ // Only explicitly-referenced assemblies are in the build output. These should be added to the zip,
+ // since it's possible the game won't load them (except game assemblies which will always be loaded
+ // separately). If they're already loaded, SMAPI will just ignore them.
+ case ExtraAssemblyTypes.None:
+ if (type is ExtraAssemblyTypes.Game)
+ return true;
+ break;
+
+ // All assemblies are in the build output (due to how .NET builds references), but only those which
+ // match the bundled type should be in the zip.
+ default:
+ if (type != ExtraAssemblyTypes.None && !bundleAssemblyTypes.HasFlag(type))
+ return true;
+ break;
}
return false;
diff --git a/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj b/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj
index e25da168c..badabfc7d 100644
--- a/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj
+++ b/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj
@@ -10,7 +10,7 @@
Pathoschild.Stardew.ModBuildConfigBuild package for SMAPI mods
- 4.0.1
+ 4.1.0PathoschildAutomates the build configuration for crossplatform Stardew Valley SMAPI mods. For SMAPI 3.13.0 or later.MIT
@@ -24,7 +24,7 @@
-
+
+ <_BundleExtraAssembliesForGame>$([System.Text.RegularExpressions.Regex]::IsMatch('$(BundleExtraAssemblies)', '\bGame|All\b', RegexOptions.IgnoreCase))
+ <_BundleExtraAssembliesForAny>$([System.Text.RegularExpressions.Regex]::IsMatch('$(BundleExtraAssemblies)', '\bGame|System|ThirdParty|All\b', RegexOptions.IgnoreCase))
+
- true
+ true
@@ -44,17 +48,17 @@
**********************************************-->
-
-
-
-
+
+
+
+
-
-
+
+
-
+
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs
index 12a51bc9e..ea9f1d829 100644
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs
@@ -63,7 +63,7 @@ public override void Handle(IMonitor monitor, string command, ArgumentParser arg
/// The color to set.
private bool TryParseColor(string input, out Color color)
{
- string[] colorHexes = input.Split(new[] { ',' }, 3);
+ string[] colorHexes = input.Split(',', 3);
if (int.TryParse(colorHexes[0], out int r) && int.TryParse(colorHexes[1], out int g) && int.TryParse(colorHexes[2], out int b))
{
color = new Color(r, g, b);
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs
index 88ddfe6b1..c46195773 100644
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs
@@ -104,12 +104,18 @@ public IEnumerable GetAll(ItemType[]? itemTypes = null, bool inc
// weapons
if (ShouldGet(ItemType.Weapon))
{
- foreach (int id in this.TryLoad("Data\\weapons").Keys)
+ Dictionary weaponsData = this.TryLoad("Data\\weapons");
+ foreach (KeyValuePair pair in weaponsData)
{
- yield return this.TryCreate(ItemType.Weapon, id, p => p.ID is >= 32 and <= 34
- ? new Slingshot(p.ID)
- : new MeleeWeapon(p.ID)
- );
+ string rawFields = pair.Value;
+ yield return this.TryCreate(ItemType.Weapon, pair.Key, p =>
+ {
+ string[] fields = rawFields.Split('/');
+ bool isSlingshot = fields.Length > 8 && fields[8] == "4";
+ return isSlingshot
+ ? new Slingshot(p.ID)
+ : new MeleeWeapon(p.ID);
+ });
}
}
diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json
index abe8b3349..6ababef00 100644
--- a/src/SMAPI.Mods.ConsoleCommands/manifest.json
+++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json
@@ -1,9 +1,9 @@
{
"Name": "Console Commands",
"Author": "SMAPI",
- "Version": "3.16.2",
+ "Version": "3.18.2",
"Description": "Adds SMAPI console commands that let you manipulate the game.",
"UniqueID": "SMAPI.ConsoleCommands",
"EntryDll": "ConsoleCommands.dll",
- "MinimumApiVersion": "3.16.2"
+ "MinimumApiVersion": "3.18.2"
}
diff --git a/src/SMAPI.Mods.ErrorHandler/manifest.json b/src/SMAPI.Mods.ErrorHandler/manifest.json
index b6764bc00..826304798 100644
--- a/src/SMAPI.Mods.ErrorHandler/manifest.json
+++ b/src/SMAPI.Mods.ErrorHandler/manifest.json
@@ -1,9 +1,9 @@
{
"Name": "Error Handler",
"Author": "SMAPI",
- "Version": "3.16.2",
+ "Version": "3.18.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.16.2"
+ "MinimumApiVersion": "3.18.2"
}
diff --git a/src/SMAPI.Mods.SaveBackup/ModEntry.cs b/src/SMAPI.Mods.SaveBackup/ModEntry.cs
index a79c092fa..8a22a5f38 100644
--- a/src/SMAPI.Mods.SaveBackup/ModEntry.cs
+++ b/src/SMAPI.Mods.SaveBackup/ModEntry.cs
@@ -1,10 +1,8 @@
using System;
-using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.IO.Compression;
using System.Linq;
-using System.Reflection;
using System.Threading.Tasks;
using StardewValley;
@@ -81,7 +79,7 @@ private void CreateBackup(DirectoryInfo backupFolder)
}
// compress backup if possible
- if (!this.TryCompress(fallbackDir.FullName, targetFile, out Exception? compressError))
+ if (!this.TryCompressDir(fallbackDir.FullName, targetFile, out Exception? compressError))
{
this.Monitor.Log(Constants.TargetPlatform != GamePlatform.Android
? $"Backed up to {fallbackDir.FullName}." // expected to fail on Android
@@ -136,19 +134,16 @@ private void PruneBackups(DirectoryInfo backupFolder, int backupsToKeep)
}
}
- /// Create a zip using the best available method.
- /// The file or directory path to zip.
+ /// Try to create a compressed zip file for a directory.
+ /// The directory path to zip.
/// The destination file to create.
/// The error which occurred trying to compress, if applicable. This is if compression isn't supported on this platform.
/// Returns whether compression succeeded.
- private bool TryCompress(string sourcePath, FileInfo destination, [NotNullWhen(false)] out Exception? error)
+ private bool TryCompressDir(string sourcePath, FileInfo destination, [NotNullWhen(false)] out Exception? error)
{
try
{
- if (Constants.TargetPlatform == GamePlatform.Mac)
- this.CompressUsingMacProcess(sourcePath, destination); // due to limitations with the bundled Mono on macOS, we can't reference System.IO.Compression
- else
- this.CompressUsingNetFramework(sourcePath, destination);
+ ZipFile.CreateFromDirectory(sourcePath, destination.FullName, CompressionLevel.Fastest, false);
error = null;
return true;
@@ -160,48 +155,6 @@ private bool TryCompress(string sourcePath, FileInfo destination, [NotNullWhen(f
}
}
- /// Create a zip using the .NET compression library.
- /// The file or directory path to zip.
- /// The destination file to create.
- /// The compression libraries aren't available on this system.
- private void CompressUsingNetFramework(string sourcePath, FileInfo destination)
- {
- // get compress method
- MethodInfo createFromDirectory;
- try
- {
- // create compressed backup
- Assembly coreAssembly = Assembly.Load("System.IO.Compression, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089");
- Assembly fsAssembly = Assembly.Load("System.IO.Compression.FileSystem, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089");
- Type compressionLevelType = coreAssembly.GetType("System.IO.Compression.CompressionLevel") ?? throw new InvalidOperationException("Can't load CompressionLevel type.");
- Type zipFileType = fsAssembly.GetType("System.IO.Compression.ZipFile") ?? throw new InvalidOperationException("Can't load ZipFile type.");
- createFromDirectory = zipFileType.GetMethod("CreateFromDirectory", new[] { typeof(string), typeof(string), compressionLevelType, typeof(bool) }) ?? throw new InvalidOperationException("Can't load ZipFile.CreateFromDirectory method.");
- }
- catch (Exception ex)
- {
- throw new NotSupportedException("Couldn't load the .NET compression libraries on this system.", ex);
- }
-
- // compress file
- createFromDirectory.Invoke(null, new object[] { sourcePath, destination.FullName, CompressionLevel.Fastest, false });
- }
-
- /// Create a zip using a process command on macOS.
- /// The file or directory path to zip.
- /// The destination file to create.
- private void CompressUsingMacProcess(string sourcePath, FileInfo destination)
- {
- DirectoryInfo saveFolder = new(sourcePath);
- ProcessStartInfo startInfo = new()
- {
- FileName = "zip",
- Arguments = $"-rq \"{destination.FullName}\" \"{saveFolder.Name}\" -x \"*.DS_Store\" -x \"__MACOSX\"",
- WorkingDirectory = $"{saveFolder.FullName}/../",
- CreateNoWindow = true
- };
- new Process { StartInfo = startInfo }.Start();
- }
-
/// Recursively copy a directory or file.
/// The file or folder to copy.
/// The folder to copy into.
diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json
index a2657168c..e29a3ed32 100644
--- a/src/SMAPI.Mods.SaveBackup/manifest.json
+++ b/src/SMAPI.Mods.SaveBackup/manifest.json
@@ -1,9 +1,9 @@
{
"Name": "Save Backup",
"Author": "SMAPI",
- "Version": "3.16.2",
+ "Version": "3.18.2",
"Description": "Automatically backs up all your saves once per day into its folder.",
"UniqueID": "SMAPI.SaveBackup",
"EntryDll": "SaveBackup.dll",
- "MinimumApiVersion": "3.16.2"
+ "MinimumApiVersion": "3.18.2"
}
diff --git a/src/SMAPI.Tests/Core/AssetNameTests.cs b/src/SMAPI.Tests/Core/AssetNameTests.cs
index 655e9bae2..2d546ec77 100644
--- a/src/SMAPI.Tests/Core/AssetNameTests.cs
+++ b/src/SMAPI.Tests/Core/AssetNameTests.cs
@@ -151,6 +151,12 @@ public bool IsEquivalentTo_BaseName(string mainAssetName, string otherAssetName)
// with locale codes
[TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = true)]
+
+ // prefix ends with path separator
+ [TestCase("Data/Events/Boop", "Data/Events/", ExpectedResult = true)]
+ [TestCase("Data/Events/Boop", "Data/Events\\", ExpectedResult = true)]
+ [TestCase("Data/Events", "Data/Events/", ExpectedResult = false)]
+ [TestCase("Data/Events", "Data/Events\\", ExpectedResult = false)]
public bool StartsWith_SimpleCases(string mainAssetName, string prefix)
{
// arrange
@@ -243,6 +249,41 @@ public bool StartsWith_Subfolder(string mainAssetName, string otherAssetName, bo
return result;
}
+ [TestCase("Mods/SomeMod/SomeSubdirectory", "Mods/Some", true, ExpectedResult = true)]
+ [TestCase("Mods/SomeMod/SomeSubdirectory", "Mods/Some", false, ExpectedResult = false)]
+ [TestCase("Mods/Jasper/Data", "Mods/Jas/Image", true, ExpectedResult = false)]
+ [TestCase("Mods/Jasper/Data", "Mods/Jas/Image", true, ExpectedResult = false)]
+ public bool StartsWith_PartialMatchInPathSegment(string mainAssetName, string otherAssetName, bool allowSubfolder)
+ {
+ // arrange
+ mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName);
+
+ // act
+ AssetName name = AssetName.Parse(mainAssetName, _ => null);
+
+ // assert value
+ return name.StartsWith(otherAssetName, allowPartialWord: true, allowSubfolder: allowSubfolder);
+ }
+
+ // The enumerator strips the trailing path separator, so each of these cases has to be handled on each branch.
+ [TestCase("Mods/SomeMod", "Mods/", false, ExpectedResult = true)]
+ [TestCase("Mods/SomeMod", "Mods", false, ExpectedResult = false)]
+ [TestCase("Mods/Jasper/Data", "Mods/Jas/", false, ExpectedResult = false)]
+ [TestCase("Mods/Jasper/Data", "Mods/Jas", false, ExpectedResult = false)]
+ [TestCase("Mods/Jas", "Mods/Jas/", false, ExpectedResult = false)]
+ [TestCase("Mods/Jas", "Mods/Jas", false, ExpectedResult = true)]
+ public bool StartsWith_PrefixHasSeparator(string mainAssetName, string otherAssetName, bool allowSubfolder)
+ {
+ // arrange
+ mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName);
+
+ // act
+ AssetName name = AssetName.Parse(mainAssetName, _ => null);
+
+ // assert value
+ return name.StartsWith(otherAssetName, allowPartialWord: true, allowSubfolder: allowSubfolder);
+ }
+
/****
** GetHashCode
diff --git a/src/SMAPI.Tests/Core/AssumptionTests.cs b/src/SMAPI.Tests/Core/AssumptionTests.cs
new file mode 100644
index 000000000..efc9da3f7
--- /dev/null
+++ b/src/SMAPI.Tests/Core/AssumptionTests.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using FluentAssertions;
+using FluentAssertions.Execution;
+using NUnit.Framework;
+using StardewModdingAPI.Framework.Models;
+
+namespace SMAPI.Tests.Core
+{
+ /// Unit tests which validate assumptions about .NET used in the SMAPI implementation.
+ [TestFixture]
+ internal class AssumptionTests
+ {
+ /*********
+ ** Unit tests
+ *********/
+ /****
+ ** Constructor
+ ****/
+ [Test(Description = $"Assert that {nameof(HashSet)} maintains insertion order when no elements are removed. If this fails, we'll need to change the implementation for the {nameof(SConfig.ModsToLoadEarly)} and {nameof(SConfig.ModsToLoadLate)} options.")]
+ [TestCase("construct from array")]
+ [TestCase("add incrementally")]
+ public void HashSet_MaintainsInsertionOrderWhenNoElementsAreRemoved(string populateMethod)
+ {
+ // arrange
+ string[] inserted = Enumerable.Range(0, 1000)
+ .Select(_ => Guid.NewGuid().ToString("N"))
+ .ToArray();
+
+ // act
+ HashSet set;
+ switch (populateMethod)
+ {
+ case "construct from array":
+ set = new(inserted, StringComparer.OrdinalIgnoreCase);
+ break;
+
+ case "add incrementally":
+ set = new(StringComparer.OrdinalIgnoreCase);
+ foreach (string value in inserted)
+ set.Add(value);
+ break;
+
+ default:
+ throw new AssertionFailedException($"Unknown populate method '{populateMethod}'.");
+ }
+
+ // assert
+ string[] actualOrder = set.ToArray();
+ actualOrder.Should().HaveCount(inserted.Length);
+ for (int i = 0; i < inserted.Length; i++)
+ {
+ string expected = inserted[i];
+ string actual = actualOrder[i];
+
+ if (actual != expected)
+ throw new AssertionFailedException($"The hash set differed at index {i}: expected {expected}, but found {actual} instead.");
+ }
+ }
+ }
+}
diff --git a/src/SMAPI.Tests/SMAPI.Tests.csproj b/src/SMAPI.Tests/SMAPI.Tests.csproj
index 2c32a9325..0b1fb638c 100644
--- a/src/SMAPI.Tests/SMAPI.Tests.csproj
+++ b/src/SMAPI.Tests/SMAPI.Tests.csproj
@@ -14,11 +14,12 @@
-
-
-
-
+
+
+
+
+
diff --git a/src/SMAPI.Tests/Utilities/KeybindListTests.cs b/src/SMAPI.Tests/Utilities/KeybindListTests.cs
index c4c086de9..c5fd5daf3 100644
--- a/src/SMAPI.Tests/Utilities/KeybindListTests.cs
+++ b/src/SMAPI.Tests/Utilities/KeybindListTests.cs
@@ -136,11 +136,11 @@ private SButtonState GetStateFromMap(SButton button, string stateMap)
foreach (string rawPair in stateMap.Split(','))
{
// parse values
- string[] parts = rawPair.Split(new[] { ':' }, 2);
+ string[] parts = rawPair.Split(':', 2, StringSplitOptions.TrimEntries);
if (!Enum.TryParse(parts[0], ignoreCase: true, out SButton curButton))
- Assert.Fail($"The state map is invalid: unknown button value '{parts[0].Trim()}'");
+ Assert.Fail($"The state map is invalid: unknown button value '{parts[0]}'");
if (!Enum.TryParse(parts[1], ignoreCase: true, out SButtonState state))
- Assert.Fail($"The state map is invalid: unknown state value '{parts[1].Trim()}'");
+ Assert.Fail($"The state map is invalid: unknown state value '{parts[1]}'");
// get state
if (curButton == button)
diff --git a/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs b/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs
index fedadba64..77c0da5f6 100644
--- a/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs
+++ b/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs
@@ -382,11 +382,11 @@ public void Serializable(string versionStr)
{
// act
string json = JsonConvert.SerializeObject(new SemanticVersion(versionStr));
- SemanticVersion after = JsonConvert.DeserializeObject(json);
+ SemanticVersion? after = JsonConvert.DeserializeObject(json);
// assert
Assert.IsNotNull(after, "The semantic version after deserialization is unexpectedly null.");
- Assert.AreEqual(versionStr, after.ToString(), "The semantic version after deserialization doesn't match the input version.");
+ Assert.AreEqual(versionStr, after!.ToString(), "The semantic version after deserialization doesn't match the input version.");
}
diff --git a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs
index 8e1538a53..881428052 100644
--- a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs
+++ b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs
@@ -9,6 +9,7 @@
using System.Reflection;
#if SMAPI_FOR_WINDOWS
using Microsoft.Win32;
+using VdfParser;
#endif
namespace StardewModdingAPI.Toolkit.Framework.GameScanning
@@ -23,6 +24,9 @@ public class GameScanner
/// The current OS.
private readonly Platform Platform;
+ /// The Steam app ID for Stardew Valley.
+ private const string SteamAppId = "413150";
+
/*********
** Public methods
@@ -145,7 +149,7 @@ private IEnumerable GetDefaultInstallPaths()
#if SMAPI_FOR_WINDOWS
IDictionary registryKeys = new Dictionary
{
- [@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Steam App 413150"] = "InstallLocation", // Steam
+ [@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Steam App " + GameScanner.SteamAppId] = "InstallLocation", // Steam
[@"SOFTWARE\WOW6432Node\GOG.com\Games\1453375253"] = "PATH", // GOG on 64-bit Windows
};
foreach (var pair in registryKeys)
@@ -158,7 +162,15 @@ private IEnumerable GetDefaultInstallPaths()
// via Steam library path
string? steamPath = this.GetCurrentUserRegistryValue(@"Software\Valve\Steam", "SteamPath");
if (steamPath != null)
+ {
+ // conventional path
yield return Path.Combine(steamPath.Replace('/', '\\'), @"steamapps\common\Stardew Valley");
+
+ // from Steam's .vdf file
+ string? path = this.GetPathFromSteamLibrary(steamPath);
+ if (!string.IsNullOrWhiteSpace(path))
+ yield return path;
+ }
#endif
// default GOG/Steam paths
@@ -243,6 +255,57 @@ private IEnumerable GetCustomInstallPaths()
using (openKey)
return (string?)openKey.GetValue(name);
}
+
+ /// Get the game directory path from alternative Steam library locations.
+ /// The full path to the directory containing steam.exe.
+ /// The game directory, if found.
+ private string? GetPathFromSteamLibrary(string? steamPath)
+ {
+ try
+ {
+ if (steamPath == null)
+ return null;
+
+ // get .vdf file path
+ string libraryFoldersPath = Path.Combine(steamPath.Replace('/', '\\'), "steamapps\\libraryfolders.vdf");
+ if (!File.Exists(libraryFoldersPath))
+ return null;
+
+ // read data
+ using FileStream fileStream = File.OpenRead(libraryFoldersPath);
+ VdfDeserializer deserializer = new();
+ dynamic libraries = deserializer.Deserialize(fileStream);
+ if (libraries?.libraryfolders is null)
+ return null;
+
+ // get path from Stardew Valley app (if any)
+ foreach (dynamic pair in libraries.libraryfolders)
+ {
+ dynamic library = pair.Value;
+
+ foreach (dynamic app in library.apps)
+ {
+ string key = app.Key;
+ if (key == GameScanner.SteamAppId)
+ {
+ string path = library.path;
+
+ return Path.Combine(path.Replace("\\\\", "\\"), "steamapps", "common", "Stardew Valley");
+ }
+ }
+ }
+
+ return null;
+ }
+ catch
+ {
+ // The file might not be parseable in some cases (e.g. some players have an older Steam version using
+ // a different format). Ideally we'd log an error to know when it's actually an issue, but the SMAPI
+ // installer doesn't have a logging mechanism (and third-party code calling the toolkit may not either).
+ // So for now, just ignore the error and fallback to the other discovery mechanisms.
+ return null;
+ }
+ }
#endif
}
}
diff --git a/src/SMAPI.Toolkit/Framework/ManifestValidator.cs b/src/SMAPI.Toolkit/Framework/ManifestValidator.cs
new file mode 100644
index 000000000..461dc3255
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/ManifestValidator.cs
@@ -0,0 +1,106 @@
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Linq;
+using StardewModdingAPI.Toolkit.Utilities;
+
+namespace StardewModdingAPI.Toolkit.Framework
+{
+ /// Validates manifest fields.
+ public static class ManifestValidator
+ {
+ /// Validate a manifest's fields.
+ /// The manifest to validate.
+ /// The error message indicating why validation failed, if applicable.
+ /// Returns whether all manifest fields validated successfully.
+ [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract", Justification = "This is the method that ensures those annotations are respected.")]
+ public static bool TryValidateFields(IManifest manifest, out string error)
+ {
+ //
+ // Note: SMAPI assumes that it can grammatically append the returned sentence in the
+ // form "failed loading because its ". Any errors returned should be valid
+ // in that format, unless the SMAPI call is adjusted accordingly.
+ //
+
+ bool hasDll = !string.IsNullOrWhiteSpace(manifest.EntryDll);
+ bool isContentPack = manifest.ContentPackFor != null;
+
+ // validate use of EntryDll vs ContentPackFor fields
+ if (hasDll == isContentPack)
+ {
+ error = hasDll
+ ? $"manifest sets both {nameof(IManifest.EntryDll)} and {nameof(IManifest.ContentPackFor)}, which are mutually exclusive."
+ : $"manifest has no {nameof(IManifest.EntryDll)} or {nameof(IManifest.ContentPackFor)} field; must specify one.";
+ return false;
+ }
+
+ // validate EntryDll/ContentPackFor format
+ if (hasDll)
+ {
+ if (manifest.EntryDll!.Intersect(Path.GetInvalidFileNameChars()).Any())
+ {
+ error = $"manifest has invalid filename '{manifest.EntryDll}' for the {nameof(IManifest.EntryDll)} field.";
+ return false;
+ }
+ }
+ else
+ {
+ if (string.IsNullOrWhiteSpace(manifest.ContentPackFor!.UniqueID))
+ {
+ error = $"manifest declares {nameof(IManifest.ContentPackFor)} without its required {nameof(IManifestContentPackFor.UniqueID)} field.";
+ return false;
+ }
+ }
+
+ // validate required fields
+ {
+ List missingFields = new List(3);
+
+ if (string.IsNullOrWhiteSpace(manifest.Name))
+ missingFields.Add(nameof(IManifest.Name));
+ if (manifest.Version == null || manifest.Version.ToString() == "0.0.0")
+ missingFields.Add(nameof(IManifest.Version));
+ if (string.IsNullOrWhiteSpace(manifest.UniqueID))
+ missingFields.Add(nameof(IManifest.UniqueID));
+
+ if (missingFields.Any())
+ {
+ error = $"manifest is missing required fields ({string.Join(", ", missingFields)}).";
+ return false;
+ }
+ }
+
+ // validate ID format
+ if (!PathUtilities.IsSlug(manifest.UniqueID))
+ {
+ error = "manifest specifies an invalid ID (IDs must only contain letters, numbers, underscores, periods, or hyphens).";
+ return false;
+ }
+
+ // validate dependency format
+ foreach (IManifestDependency? dependency in manifest.Dependencies)
+ {
+ if (dependency == null)
+ {
+ error = $"manifest has a null entry under {nameof(IManifest.Dependencies)}.";
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(dependency.UniqueID))
+ {
+ error = $"manifest has a {nameof(IManifest.Dependencies)} entry with no {nameof(IManifestDependency.UniqueID)} field.";
+ return false;
+ }
+
+ if (!PathUtilities.IsSlug(dependency.UniqueID))
+ {
+ error = $"manifest has a {nameof(IManifest.Dependencies)} entry with an invalid {nameof(IManifestDependency.UniqueID)} field (IDs must only contain letters, numbers, underscores, periods, or hyphens).";
+ return false;
+ }
+ }
+
+ error = "";
+ return true;
+ }
+ }
+}
diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs
index a85ef1093..5e9e3c357 100644
--- a/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs
+++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs
@@ -45,10 +45,14 @@ public class ModScanner
".png",
".psd",
".tif",
+ ".xcf", // gimp files
// archives
".rar",
".zip",
+ ".7z",
+ ".tar",
+ ".tar.gz",
// backup files
".backup",
diff --git a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj
index 7b79105f7..2a9a8294b 100644
--- a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj
+++ b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj
@@ -9,11 +9,12 @@
-
-
-
+
+
+
+
diff --git a/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs b/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs
index da3ad608b..8a449f0a7 100644
--- a/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs
+++ b/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
+using System.Text;
using Newtonsoft.Json;
using StardewModdingAPI.Toolkit.Serialization.Converters;
@@ -90,13 +91,13 @@ public Manifest(string uniqueID, string name, string author, string description,
[JsonConstructor]
public Manifest(string uniqueId, string name, string author, string description, ISemanticVersion version, ISemanticVersion? minimumApiVersion, string? entryDll, IManifestContentPackFor? contentPackFor, IManifestDependency[]? dependencies, string[]? updateKeys)
{
- this.UniqueID = this.NormalizeWhitespace(uniqueId);
- this.Name = this.NormalizeWhitespace(name);
- this.Author = this.NormalizeWhitespace(author);
- this.Description = this.NormalizeWhitespace(description);
+ this.UniqueID = this.NormalizeField(uniqueId);
+ this.Name = this.NormalizeField(name, replaceSquareBrackets: true);
+ this.Author = this.NormalizeField(author);
+ this.Description = this.NormalizeField(description);
this.Version = version;
this.MinimumApiVersion = minimumApiVersion;
- this.EntryDll = this.NormalizeWhitespace(entryDll);
+ this.EntryDll = this.NormalizeField(entryDll);
this.ContentPackFor = contentPackFor;
this.Dependencies = dependencies ?? Array.Empty();
this.UpdateKeys = updateKeys ?? Array.Empty();
@@ -113,17 +114,47 @@ internal void OverrideUpdateKeys(params string[] updateKeys)
/*********
** Private methods
*********/
- /// Normalize whitespace in a raw string.
+ /// Normalize a manifest field to strip newlines, trim whitespace, and optionally strip square brackets.
/// The input to strip.
+ /// Whether to replace square brackets with round ones. This is used in the mod name to avoid breaking the log format.
#if NET5_0_OR_GREATER
[return: NotNullIfNotNull("input")]
#endif
- private string? NormalizeWhitespace(string? input)
+ private string? NormalizeField(string? input, bool replaceSquareBrackets = false)
{
- return input
- ?.Trim()
- .Replace("\r", "")
- .Replace("\n", "");
+ input = input?.Trim();
+
+ if (!string.IsNullOrEmpty(input))
+ {
+ StringBuilder? builder = null;
+
+ for (int i = 0; i < input.Length; i++)
+ {
+ switch (input[i])
+ {
+ case '\r':
+ case '\n':
+ builder ??= new StringBuilder(input);
+ builder[i] = ' ';
+ break;
+
+ case '[' when replaceSquareBrackets:
+ builder ??= new StringBuilder(input);
+ builder[i] = '(';
+ break;
+
+ case ']' when replaceSquareBrackets:
+ builder ??= new StringBuilder(input);
+ builder[i] = ')';
+ break;
+ }
+ }
+
+ if (builder != null)
+ input = builder.ToString();
+ }
+
+ return input;
}
}
}
diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs
index 23b25f959..46c3092ce 100644
--- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs
+++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs
@@ -121,10 +121,10 @@ public void Dispose()
HtmlNode? node = doc.DocumentNode.SelectSingleNode("//div[contains(@class, 'site-notice')][contains(@class, 'warning')]");
if (node != null)
{
- string[] errorParts = node.InnerText.Trim().Split(new[] { '\n' }, 2, System.StringSplitOptions.RemoveEmptyEntries);
+ string[] errorParts = node.InnerText.Trim().Split('\n', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
string errorCode = errorParts[0];
string? errorText = errorParts.Length > 1 ? errorParts[1] : null;
- switch (errorCode.Trim().ToLower())
+ switch (errorCode.ToLower())
{
case "not found":
return null;
diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestClient.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestClient.cs
index 1614beab0..270728977 100644
--- a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestClient.cs
+++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestClient.cs
@@ -1,4 +1,3 @@
-
using System;
using System.Diagnostics.CodeAnalysis;
using System.Net;
diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
index 18ba754c5..c39e612b8 100644
--- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
+++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
@@ -108,6 +108,10 @@ public ParsedLog Parse(string? logText)
}
}
+ // detect split-screen mode
+ if (message.ScreenId != 0)
+ log.IsSplitScreen = true;
+
// collect SMAPI metadata
if (message.Mod == "SMAPI")
{
@@ -199,8 +203,15 @@ public ParsedLog Parse(string? logText)
log.ApiVersion = match.Groups["apiVersion"].Value;
log.GameVersion = match.Groups["gameVersion"].Value;
log.OperatingSystem = match.Groups["os"].Value;
- smapiMod.OverrideVersion(log.ApiVersion);
+ const string strictModeSuffix = " (strict mode)";
+ if (log.ApiVersion.EndsWith(strictModeSuffix))
+ {
+ log.IsStrictMode = true;
+ log.ApiVersion = log.ApiVersion[..^strictModeSuffix.Length];
+ }
+
+ smapiMod.OverrideVersion(log.ApiVersion);
log.ApiVersionParsed = smapiMod.GetParsedVersion();
}
diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs
index 3f6491992..2a2e5f5c3 100644
--- a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs
+++ b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs
@@ -22,9 +22,15 @@ public class ParsedLog
/// The raw log text.
public string? RawText { get; set; }
+ /// Whether there are messages from multiple screens in the log.
+ public bool IsSplitScreen { get; set; }
+
/****
** Log data
****/
+ /// Whether SMAPI is running in strict mode, which disables all deprecated APIs.
+ public bool IsStrictMode { get; set; }
+
/// The SMAPI version.
public string? ApiVersion { get; set; }
diff --git a/src/SMAPI.Web/SMAPI.Web.csproj b/src/SMAPI.Web/SMAPI.Web.csproj
index d26cb6f81..1e5685720 100644
--- a/src/SMAPI.Web/SMAPI.Web.csproj
+++ b/src/SMAPI.Web/SMAPI.Web.csproj
@@ -2,7 +2,7 @@
SMAPI.WebStardewModdingAPI.Web
- net6.0
+ net7.0latest
@@ -15,17 +15,17 @@
-
-
+
+
-
+
-
-
-
+
+
+
-
+
diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml
index 24fe5fa2b..b989417ee 100644
--- a/src/SMAPI.Web/Views/LogParser/Index.cshtml
+++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml
@@ -40,14 +40,14 @@
}
-
+
-
+