diff options
82 files changed, 1535 insertions, 597 deletions
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index c51d164b..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: Bug report -about: Report a problem with SMAPI. - ---- - -<!-- - -Only report a bug here if you're sure it's a SMAPI bug! -To request support instead, see https://smapi.io/community. - -Replace the instructions below with the bug details. - ---> - -**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 00000000..63d7bb67 --- /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 00000000..11306e7f --- /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. + +--- + +<!-- + + + + +STOP! + +Is this a specific development task? Don't create an issue if not! +See https://smapi.io/community if something isn't working, you have questions or ideas, or you want +to discuss something. + +If you're absolutely sure it's a specific development task (e.g. a specific bug, not just +'something went wrong on my computer'), edit the template below. + +--> + +**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 8d935dc8..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for SMAPI. - ---- - -<!-- - -GitHub issues are only used for development tasks. Please don't submit feature requests here! -Instead, see https://smapi.io/community to discuss SMAPI. - ---> diff --git a/.github/ISSUE_TEMPLATE/general.md b/.github/ISSUE_TEMPLATE/general.md deleted file mode 100644 index f02d3c9a..00000000 --- a/.github/ISSUE_TEMPLATE/general.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: General -about: Create a ticket about something else. - ---- - -<!-- - -GitHub issues are only used for development tasks. -For support and questions, see https://smapi.io/community instead. - ---> diff --git a/build/0Harmony.dll b/build/0Harmony.dll Binary files differindex 492255be..72ca2b61 100644 --- a/build/0Harmony.dll +++ b/build/0Harmony.dll diff --git a/build/0Harmony.xml b/build/0Harmony.xml index 8499d20b..f1b9b4cf 100644 --- a/build/0Harmony.xml +++ b/build/0Harmony.xml @@ -845,7 +845,7 @@ </member> <member name="F:HarmonyLib.ExceptionBlockType.BeginExceptFilterBlock"> - <summary>The beginning of an except filter block</summary> + <summary>The beginning of an except filter block (currently not supported to use in a patch)</summary> </member> <member name="F:HarmonyLib.ExceptionBlockType.BeginFaultBlock"> @@ -2661,6 +2661,18 @@ <param name="name">The optional name</param> </member> + <member name="M:HarmonyLib.CodeMatch.#ctor(System.Linq.Expressions.Expression{System.Action},System.String)"> + <summary>Creates a code match that calls a method</summary> + <param name="expression">The lambda expression using the method</param> + <param name="name">The optional name</param> + + </member> + <member name="M:HarmonyLib.CodeMatch.#ctor(System.Linq.Expressions.LambdaExpression,System.String)"> + <summary>Creates a code match that calls a method</summary> + <param name="expression">The lambda expression using the method</param> + <param name="name">The optional name</param> + + </member> <member name="M:HarmonyLib.CodeMatch.#ctor(HarmonyLib.CodeInstruction,System.String)"> <summary>Creates a code match</summary> <param name="instruction">The CodeInstruction</param> @@ -3217,6 +3229,13 @@ <returns>True if the instruction loads the constant</returns> </member> + <member name="M:HarmonyLib.CodeInstructionExtensions.LoadsConstant(HarmonyLib.CodeInstruction,System.String)"> + <summary>Tests if the code instruction loads a string constant</summary> + <param name="code">The <see cref="T:HarmonyLib.CodeInstruction"/></param> + <param name="str">The string</param> + <returns>True if the instruction loads the constant</returns> + + </member> <member name="M:HarmonyLib.CodeInstructionExtensions.LoadsField(HarmonyLib.CodeInstruction,System.Reflection.FieldInfo,System.Boolean)"> <summary>Tests if the code instruction loads a field</summary> <param name="code">The <see cref="T:HarmonyLib.CodeInstruction"/></param> @@ -3346,7 +3365,11 @@ <summary>A file log for debugging</summary> </member> - <member name="F:HarmonyLib.FileLog.logPath"> + <member name="P:HarmonyLib.FileLog.LogWriter"> + <summary>Set this to make Harmony write its log content to this stream</summary> + + </member> + <member name="P:HarmonyLib.FileLog.LogPath"> <summary>Full pathname of the log file, defaults to a file called <c>harmony.log.txt</c> on your Desktop</summary> </member> diff --git a/build/common.targets b/build/common.targets index 7fe66fab..1ead1508 100644 --- a/build/common.targets +++ b/build/common.targets @@ -7,7 +7,7 @@ repo. It imports the other MSBuild files as needed. <Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <PropertyGroup> <!--set general build properties --> - <Version>3.16.2</Version> + <Version>3.18.3</Version> <Product>SMAPI</Product> <LangVersion>latest</LangVersion> <AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths> diff --git a/build/unix/prepare-install-package.sh b/build/unix/prepare-install-package.sh index 1d805e00..304579b9 100755 --- a/build/unix/prepare-install-package.sh +++ b/build/unix/prepare-install-package.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # # diff --git a/build/unix/set-smapi-version.sh b/build/unix/set-smapi-version.sh index 0c0cbeb0..02b5e615 100755 --- a/build/unix/set-smapi-version.sh +++ b/build/unix/set-smapi-version.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # # diff --git a/build/windows/finalize-install-package.sh b/build/windows/finalize-install-package.sh index 0996e3ed..117e33e5 100755 --- a/build/windows/finalize-install-package.sh +++ b/build/windows/finalize-install-package.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash ########## ## Read config diff --git a/build/windows/prepare-install-package.ps1 b/build/windows/prepare-install-package.ps1 index 87a4fe01..71de1154 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 ea459bcb..e99218bd 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -7,9 +7,97 @@ _If needed, you can update to SMAPI 3.16.0 first and then install the latest version._ --> -## Upcoming release +## 3.18.3 +Released 09 April 2023 for Stardew Valley 1.5.6 or later. + +* For players: + * Fixed `findstr` installer error for some players. + * Fixed installer error for some Linux players due to a non-portable shebang (thanks to freyacoded!). + * Fixed error using load order overrides when there are broken mods installed (thanks to atravita!). + * Removed `LargeAddressAware` flag on SMAPI (no longer needed since it's 64-bit now). + * Improved translations. Thganks to stylemate (updated Korean)! + +* For mod authors: + * Added `IsActiveForScreen()` method to `PerScreen<T>`. + * 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)). + * Adjusted `ModContentManager.HandleUnknownFileType` to let mods patch it. + * Fixed `Context.IsWorldReady` being editable by mods. + +* For the web UI: + * Updated the JSON validator/schema for Content Patcher 1.29.0. + +## 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: - * Fixed update alert shown for a prerelease version on GitHub if it's not marked as prerelease. + * 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 ca78be55..ab8a2c95 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 `<PropertyGroup>` and `</PropertyGroup>` 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 b8a1683b..d115aefa 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 Linux.sh b/src/SMAPI.Installer/assets/install on Linux.sh index 3b7eae9c..70b21521 100644 --- a/src/SMAPI.Installer/assets/install on Linux.sh +++ b/src/SMAPI.Installer/assets/install on Linux.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash cd "`dirname "$0"`" internal/linux/SMAPI.Installer diff --git a/src/SMAPI.Installer/assets/install on Windows.bat b/src/SMAPI.Installer/assets/install on Windows.bat index b0d9ae81..c61a801e 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.Installer/assets/install on macOS.command b/src/SMAPI.Installer/assets/install on macOS.command index abd21dc8..e85230ed 100644 --- a/src/SMAPI.Installer/assets/install on macOS.command +++ b/src/SMAPI.Installer/assets/install on macOS.command @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash cd "`dirname "$0"`" 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 3be9c225..1719d39b 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 @@ <ItemGroup> <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="3.10.0" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> <PackageReference Include="NUnit" Version="3.13.3" /> - <PackageReference Include="NUnit3TestAdapter" Version="4.2.1" PrivateAssets="all" IncludeAssets="runtime; build; native; contentfiles; analyzers; buildtransitive" /> + <PackageReference Include="NUnit3TestAdapter" Version="4.3.1" PrivateAssets="all" IncludeAssets="runtime; build; native; contentfiles; analyzers; buildtransitive" /> </ItemGroup> <ItemGroup> diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs index 88412d92..3508a6db 100644 --- a/src/SMAPI.ModBuildConfig/DeployModTask.cs +++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs @@ -7,7 +7,11 @@ using System.Reflection; 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 @@ namespace StardewModdingAPI.ModBuildConfig 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 @@ namespace StardewModdingAPI.ModBuildConfig // 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 80955f67..d47e492a 100644 --- a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs +++ b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs @@ -3,8 +3,6 @@ using System.Collections.Generic; 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 @@ namespace StardewModdingAPI.ModBuildConfig.Framework return new Dictionary<string, FileInfo>(this.Files, StringComparer.OrdinalIgnoreCase); } - /// <summary>Get a semantic version from the mod manifest.</summary> - /// <exception cref="UserErrorException">The manifest is missing or invalid.</exception> - 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 @@ namespace StardewModdingAPI.ModBuildConfig.Framework 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 e25da168..badabfc7 100644 --- a/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj +++ b/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj @@ -10,7 +10,7 @@ <!--NuGet package--> <PackageId>Pathoschild.Stardew.ModBuildConfig</PackageId> <Title>Build package for SMAPI mods</Title> - <Version>4.0.1</Version> + <Version>4.1.0</Version> <Authors>Pathoschild</Authors> <Description>Automates the build configuration for crossplatform Stardew Valley SMAPI mods. For SMAPI 3.13.0 or later.</Description> <PackageLicenseExpression>MIT</PackageLicenseExpression> @@ -24,7 +24,7 @@ <ItemGroup> <PackageReference Include="Microsoft.Build.Utilities.Core" Version="16.10" /> - <PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> + <PackageReference Include="Newtonsoft.Json" Version="13.0.2" /> <!-- This is imported through Microsoft.Build.Utilities.Core. When installed by a mod, NuGet diff --git a/src/SMAPI.ModBuildConfig/build/smapi.targets b/src/SMAPI.ModBuildConfig/build/smapi.targets index 12619439..b4fd312e 100644 --- a/src/SMAPI.ModBuildConfig/build/smapi.targets +++ b/src/SMAPI.ModBuildConfig/build/smapi.targets @@ -27,8 +27,12 @@ <EnableGameDebugging Condition="'$(EnableGameDebugging)' == ''">true</EnableGameDebugging> <BundleExtraAssemblies Condition="'$(BundleExtraAssemblies)' == ''"></BundleExtraAssemblies> + <!-- simplify conditions --> + <_BundleExtraAssembliesForGame>$([System.Text.RegularExpressions.Regex]::IsMatch('$(BundleExtraAssemblies)', '\bGame|All\b', RegexOptions.IgnoreCase))</_BundleExtraAssembliesForGame> + <_BundleExtraAssembliesForAny>$([System.Text.RegularExpressions.Regex]::IsMatch('$(BundleExtraAssemblies)', '\bGame|System|ThirdParty|All\b', RegexOptions.IgnoreCase))</_BundleExtraAssembliesForAny> + <!-- coppy referenced DLLs into build output --> - <CopyLocalLockFileAssemblies Condition="$(BundleExtraAssemblies.Contains('ThirdParty')) OR $(BundleExtraAssemblies.Contains('Game')) OR $(BundleExtraAssemblies.Contains('System')) OR $(BundleExtraAssemblies.Contains('All'))">true</CopyLocalLockFileAssemblies> + <CopyLocalLockFileAssemblies Condition="$(_BundleExtraAssembliesForAny)">true</CopyLocalLockFileAssemblies> </PropertyGroup> <PropertyGroup Condition="'$(OS)' == 'Windows_NT' AND '$(EnableGameDebugging)' == 'true'"> @@ -44,17 +48,17 @@ **********************************************--> <ItemGroup> <!-- game --> - <Reference Include="Stardew Valley" HintPath="$(GamePath)\Stardew Valley.dll" Private="$(BundleExtraAssemblies.Contains('Game'))" /> - <Reference Include="StardewValley.GameData" HintPath="$(GamePath)\StardewValley.GameData.dll" Private="$(BundleExtraAssemblies.Contains('Game'))" /> - <Reference Include="MonoGame.Framework" HintPath="$(GamePath)\MonoGame.Framework.dll" Private="$(BundleExtraAssemblies.Contains('Game'))" /> - <Reference Include="xTile" HintPath="$(GamePath)\xTile.dll" Private="$(BundleExtraAssemblies.Contains('Game'))" /> + <Reference Include="Stardew Valley" HintPath="$(GamePath)\Stardew Valley.dll" Private="$(_BundleExtraAssembliesForGame)" /> + <Reference Include="StardewValley.GameData" HintPath="$(GamePath)\StardewValley.GameData.dll" Private="$(_BundleExtraAssembliesForGame)" /> + <Reference Include="MonoGame.Framework" HintPath="$(GamePath)\MonoGame.Framework.dll" Private="$(_BundleExtraAssembliesForGame)" /> + <Reference Include="xTile" HintPath="$(GamePath)\xTile.dll" Private="$(_BundleExtraAssembliesForGame)" /> <!-- SMAPI --> - <Reference Include="StardewModdingAPI" HintPath="$(GamePath)\StardewModdingAPI.dll" Private="$(BundleExtraAssemblies.Contains('Game'))" /> - <Reference Include="SMAPI.Toolkit.CoreInterfaces" HintPath="$(GamePath)\smapi-internal\SMAPI.Toolkit.CoreInterfaces.dll" Private="$(BundleExtraAssemblies.Contains('Game'))" /> + <Reference Include="StardewModdingAPI" HintPath="$(GamePath)\StardewModdingAPI.dll" Private="$(_BundleExtraAssembliesForGame)" /> + <Reference Include="SMAPI.Toolkit.CoreInterfaces" HintPath="$(GamePath)\smapi-internal\SMAPI.Toolkit.CoreInterfaces.dll" Private="$(_BundleExtraAssembliesForGame)" /> <!-- Harmony --> - <Reference Include="0Harmony" Condition="'$(EnableHarmony)' == 'true'" HintPath="$(GamePath)\smapi-internal\0Harmony.dll" Private="$(BundleExtraAssemblies.Contains('Game'))" /> + <Reference Include="0Harmony" Condition="'$(EnableHarmony)' == 'true'" HintPath="$(GamePath)\smapi-internal\0Harmony.dll" Private="$(_BundleExtraAssembliesForGame)" /> </ItemGroup> diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs index 12a51bc9..ea9f1d82 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 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player /// <param name="color">The color to set.</param> 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 88ddfe6b..c4619577 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs @@ -104,12 +104,18 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework // weapons if (ShouldGet(ItemType.Weapon)) { - foreach (int id in this.TryLoad<int, string>("Data\\weapons").Keys) + Dictionary<int, string> weaponsData = this.TryLoad<int, string>("Data\\weapons"); + foreach (KeyValuePair<int, string> 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 abe8b334..2447c5c3 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.3", "Description": "Adds SMAPI console commands that let you manipulate the game.", "UniqueID": "SMAPI.ConsoleCommands", "EntryDll": "ConsoleCommands.dll", - "MinimumApiVersion": "3.16.2" + "MinimumApiVersion": "3.18.3" } diff --git a/src/SMAPI.Mods.ErrorHandler/manifest.json b/src/SMAPI.Mods.ErrorHandler/manifest.json index b6764bc0..306c92fc 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.3", "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.3" } diff --git a/src/SMAPI.Mods.SaveBackup/ModEntry.cs b/src/SMAPI.Mods.SaveBackup/ModEntry.cs index a79c092f..8a22a5f3 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 @@ namespace StardewModdingAPI.Mods.SaveBackup } // 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 @@ namespace StardewModdingAPI.Mods.SaveBackup } } - /// <summary>Create a zip using the best available method.</summary> - /// <param name="sourcePath">The file or directory path to zip.</param> + /// <summary>Try to create a compressed zip file for a directory.</summary> + /// <param name="sourcePath">The directory path to zip.</param> /// <param name="destination">The destination file to create.</param> /// <param name="error">The error which occurred trying to compress, if applicable. This is <see cref="NotSupportedException"/> if compression isn't supported on this platform.</param> /// <returns>Returns whether compression succeeded.</returns> - 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 @@ namespace StardewModdingAPI.Mods.SaveBackup } } - /// <summary>Create a zip using the .NET compression library.</summary> - /// <param name="sourcePath">The file or directory path to zip.</param> - /// <param name="destination">The destination file to create.</param> - /// <exception cref="NotSupportedException">The compression libraries aren't available on this system.</exception> - 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 }); - } - - /// <summary>Create a zip using a process command on macOS.</summary> - /// <param name="sourcePath">The file or directory path to zip.</param> - /// <param name="destination">The destination file to create.</param> - 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(); - } - /// <summary>Recursively copy a directory or file.</summary> /// <param name="source">The file or folder to copy.</param> /// <param name="targetFolder">The folder to copy into.</param> diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json index a2657168..c5075c57 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.3", "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.3" } diff --git a/src/SMAPI.Tests/Core/AssetNameTests.cs b/src/SMAPI.Tests/Core/AssetNameTests.cs index 655e9bae..2d546ec7 100644 --- a/src/SMAPI.Tests/Core/AssetNameTests.cs +++ b/src/SMAPI.Tests/Core/AssetNameTests.cs @@ -151,6 +151,12 @@ namespace SMAPI.Tests.Core // 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 @@ namespace SMAPI.Tests.Core 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 00000000..efc9da3f --- /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 +{ + /// <summary>Unit tests which validate assumptions about .NET used in the SMAPI implementation.</summary> + [TestFixture] + internal class AssumptionTests + { + /********* + ** Unit tests + *********/ + /**** + ** Constructor + ****/ + [Test(Description = $"Assert that {nameof(HashSet<string>)} 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<string> 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 2c32a932..0b1fb638 100644 --- a/src/SMAPI.Tests/SMAPI.Tests.csproj +++ b/src/SMAPI.Tests/SMAPI.Tests.csproj @@ -14,11 +14,12 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="FluentAssertions" Version="6.7.0" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" /> - <PackageReference Include="Moq" Version="4.18.1" /> - <PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> + <PackageReference Include="FluentAssertions" Version="6.8.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> + <PackageReference Include="Moq" Version="4.18.4" /> + <PackageReference Include="Newtonsoft.Json" Version="13.0.2" /> <PackageReference Include="NUnit" Version="3.13.3" /> + <PackageReference Include="NUnit3TestAdapter" Version="4.3.1" PrivateAssets="all" IncludeAssets="runtime; build; native; contentfiles; analyzers; buildtransitive" /> </ItemGroup> <ItemGroup> diff --git a/src/SMAPI.Tests/Utilities/KeybindListTests.cs b/src/SMAPI.Tests/Utilities/KeybindListTests.cs index c4c086de..c5fd5daf 100644 --- a/src/SMAPI.Tests/Utilities/KeybindListTests.cs +++ b/src/SMAPI.Tests/Utilities/KeybindListTests.cs @@ -136,11 +136,11 @@ namespace SMAPI.Tests.Utilities 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 fedadba6..77c0da5f 100644 --- a/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs +++ b/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs @@ -382,11 +382,11 @@ namespace SMAPI.Tests.Utilities { // act string json = JsonConvert.SerializeObject(new SemanticVersion(versionStr)); - SemanticVersion after = JsonConvert.DeserializeObject<SemanticVersion>(json); + SemanticVersion? after = JsonConvert.DeserializeObject<SemanticVersion>(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 8e1538a5..88142805 100644 --- a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs +++ b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs @@ -9,6 +9,7 @@ using StardewModdingAPI.Toolkit.Utilities; using System.Reflection; #if SMAPI_FOR_WINDOWS using Microsoft.Win32; +using VdfParser; #endif namespace StardewModdingAPI.Toolkit.Framework.GameScanning @@ -23,6 +24,9 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning /// <summary>The current OS.</summary> private readonly Platform Platform; + /// <summary>The Steam app ID for Stardew Valley.</summary> + private const string SteamAppId = "413150"; + /********* ** Public methods @@ -145,7 +149,7 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning #if SMAPI_FOR_WINDOWS IDictionary<string, string> registryKeys = new Dictionary<string, string> { - [@"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 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning // 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 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning using (openKey) return (string?)openKey.GetValue(name); } + + /// <summary>Get the game directory path from alternative Steam library locations.</summary> + /// <param name="steamPath">The full path to the directory containing steam.exe.</param> + /// <returns>The game directory, if found.</returns> + 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 00000000..461dc325 --- /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 +{ + /// <summary>Validates manifest fields.</summary> + public static class ManifestValidator + { + /// <summary>Validate a manifest's fields.</summary> + /// <param name="manifest">The manifest to validate.</param> + /// <param name="error">The error message indicating why validation failed, if applicable.</param> + /// <returns>Returns whether all manifest fields validated successfully.</returns> + [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 <mod> because its <error>". 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<string> missingFields = new List<string>(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 a85ef109..5e9e3c35 100644 --- a/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs +++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs @@ -45,10 +45,14 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning ".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 7b79105f..2a9a8294 100644 --- a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj +++ b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj @@ -9,11 +9,12 @@ <Import Project="..\..\build\common.targets" /> <ItemGroup> - <PackageReference Include="HtmlAgilityPack" Version="1.11.43" /> - <PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> - <PackageReference Include="Pathoschild.Http.FluentClient" Version="4.1.1" /> + <PackageReference Include="HtmlAgilityPack" Version="1.11.46" /> + <PackageReference Include="Newtonsoft.Json" Version="13.0.2" /> + <PackageReference Include="Pathoschild.Http.FluentClient" Version="4.3.0" /> <PackageReference Include="System.Management" Version="5.0.0" Condition="'$(OS)' == 'Windows_NT'" /> <PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" Condition="'$(OS)' == 'Windows_NT'" /> + <PackageReference Include="VdfConverter" Version="1.0.3" Condition="'$(OS)' == 'Windows_NT'" Private="False" /> </ItemGroup> <ItemGroup> diff --git a/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs b/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs index da3ad608..8a449f0a 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 @@ namespace StardewModdingAPI.Toolkit.Serialization.Models [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<IManifestDependency>(); this.UpdateKeys = updateKeys ?? Array.Empty<string>(); @@ -113,17 +114,47 @@ namespace StardewModdingAPI.Toolkit.Serialization.Models /********* ** Private methods *********/ - /// <summary>Normalize whitespace in a raw string.</summary> + /// <summary>Normalize a manifest field to strip newlines, trim whitespace, and optionally strip square brackets.</summary> /// <param name="input">The input to strip.</param> + /// <param name="replaceSquareBrackets">Whether to replace square brackets with round ones. This is used in the mod name to avoid breaking the log format.</param> #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 23b25f95..46c3092c 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs @@ -121,10 +121,10 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus 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/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs index 18ba754c..c39e612b 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs @@ -108,6 +108,10 @@ namespace StardewModdingAPI.Web.Framework.LogParsing } } + // detect split-screen mode + if (message.ScreenId != 0) + log.IsSplitScreen = true; + // collect SMAPI metadata if (message.Mod == "SMAPI") { @@ -199,8 +203,15 @@ namespace StardewModdingAPI.Web.Framework.LogParsing 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 3f649199..2a2e5f5c 100644 --- a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs +++ b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs @@ -22,9 +22,15 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models /// <summary>The raw log text.</summary> public string? RawText { get; set; } + /// <summary>Whether there are messages from multiple screens in the log.</summary> + public bool IsSplitScreen { get; set; } + /**** ** Log data ****/ + /// <summary>Whether SMAPI is running in strict mode, which disables all deprecated APIs.</summary> + public bool IsStrictMode { get; set; } + /// <summary>The SMAPI version.</summary> public string? ApiVersion { get; set; } diff --git a/src/SMAPI.Web/SMAPI.Web.csproj b/src/SMAPI.Web/SMAPI.Web.csproj index d26cb6f8..1e568572 100644 --- a/src/SMAPI.Web/SMAPI.Web.csproj +++ b/src/SMAPI.Web/SMAPI.Web.csproj @@ -2,7 +2,7 @@ <PropertyGroup> <AssemblyName>SMAPI.Web</AssemblyName> <RootNamespace>StardewModdingAPI.Web</RootNamespace> - <TargetFramework>net6.0</TargetFramework> + <TargetFramework>net7.0</TargetFramework> <LangVersion>latest</LangVersion> </PropertyGroup> @@ -15,17 +15,17 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Azure.Storage.Blobs" Version="12.13.0" /> - <PackageReference Include="Hangfire.AspNetCore" Version="1.7.29" /> + <PackageReference Include="Azure.Storage.Blobs" Version="12.14.1" /> + <PackageReference Include="Hangfire.AspNetCore" Version="1.7.32" /> <PackageReference Include="Hangfire.MemoryStorage" Version="1.7.0" /> - <PackageReference Include="HtmlAgilityPack" Version="1.11.43" /> + <PackageReference Include="HtmlAgilityPack" Version="1.11.46" /> <PackageReference Include="Humanizer.Core" Version="2.14.1" /> - <PackageReference Include="JetBrains.Annotations" Version="2022.1.0" /> - <PackageReference Include="Markdig" Version="0.30.2" /> - <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.5" /> + <PackageReference Include="JetBrains.Annotations" Version="2022.3.1" /> + <PackageReference Include="Markdig" Version="0.30.4" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="7.0.1" /> <PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.14" /> <PackageReference Include="Pathoschild.FluentNexus" Version="1.0.5" /> - <PackageReference Include="Pathoschild.Http.FluentClient" Version="4.1.1" /> + <PackageReference Include="Pathoschild.Http.FluentClient" Version="4.3.0" /> </ItemGroup> <ItemGroup> <DotNetCliToolReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Tools" Version="1.0.1" /> diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index 24fe5fa2..b989417e 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -40,14 +40,14 @@ <meta name="robots" content="noindex" /> } <link rel="stylesheet" href="~/Content/css/file-upload.css" /> - <link rel="stylesheet" href="~/Content/css/log-parser.css" /> + <link rel="stylesheet" href="~/Content/css/log-parser.css?r=20221009" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tabbyjs@12.0.3/dist/css/tabby-ui-vertical.min.css" /> <script src="https://cdn.jsdelivr.net/npm/tabbyjs@12.0.3" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1" crossorigin="anonymous"></script> <script src="~/Content/js/file-upload.js"></script> - <script src="~/Content/js/log-parser.js?r=20220409"></script> + <script src="~/Content/js/log-parser.js"></script> <script id="serializedData" type="application/json"> @if (!Model.ShowRaw) @@ -86,7 +86,8 @@ showMods: @this.ForJson(log?.Mods.Where(p => p.Loaded && !p.IsContentPack).Select(p => Model.GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, _ => true)), showSections: @this.ForJson(Enum.GetNames(typeof(LogSection)).ToDictionary(section => section, _ => false)), showLevels: @this.ForJson(defaultFilters), - enableFilters: @this.ForJson(!Model.ShowRaw) + enableFilters: @this.ForJson(!Model.ShowRaw), + isSplitScreen: @this.ForJson(log?.IsSplitScreen ?? false) } ); @@ -243,11 +244,11 @@ else if (log?.IsValid == true) @if (log?.IsValid == true) { <div id="output"> - @if (outdatedMods.Any() || errorHandler is null || hasOlderErrorHandler || isPyTkCompatibilityMode) + @if (outdatedMods.Any() || errorHandler is null || hasOlderErrorHandler || isPyTkCompatibilityMode || log.IsStrictMode) { <h2>Suggested fixes</h2> <ul id="fix-list"> - @if (errorHandler is null) + @if (errorHandler is null && log.ApiVersionParsed?.IsNewerThan("3.8.4") is true) { <li class="important">You don't have the <strong>Error Handler</strong> mod installed. This automatically prevents many game or mod errors. You can <a href="https://stardewvalleywiki.com/Modding:Player_Guide#Install_SMAPI">reinstall SMAPI</a> to re-add it.</li> } @@ -257,7 +258,14 @@ else if (log?.IsValid == true) } @if (isPyTkCompatibilityMode) { - <li>PyTK 1.23.* or earlier isn't compatible with newer SMAPI performance optimizations. This may increase loading times or in-game lag.</li> + if (log.IsStrictMode) + { + <li>PyTK's image scaling isn't compatible with SMAPI strict mode.</li> + } + else + { + <li>PyTK 1.23.* or earlier isn't compatible with newer SMAPI performance optimizations. This may increase loading times or in-game lag.</li> + } } @if (outdatedMods.Any()) { @@ -307,6 +315,10 @@ else if (log?.IsValid == true) </table> </li> } + @if (log.IsStrictMode) + { + <li class="notice">SMAPI is running in 'strict mode', which removes all deprecated APIs. This can significantly improve performance, but some mods may not work. You can <a href="https://stardewvalleywiki.com/Modding:Player_Guide#Install_SMAPI">reinstall SMAPI</a> to disable it if you run into problems.</li> + } </ul> } @@ -329,7 +341,13 @@ else if (log?.IsValid == true) </tr> <tr> <th>SMAPI:</th> - <td v-pre>@log.ApiVersion</td> + <td v-pre> + @log.ApiVersion + @if (log.IsStrictMode) + { + <strong>(strict mode)</strong> + } + </td> </tr> <tr> <th>Folder:</th> @@ -361,7 +379,7 @@ else if (log?.IsValid == true) ContentPacks: contentPacks?.TryGetValue(mod.Name, out LogModInfo[]? contentPackList) == true ? contentPackList : Array.Empty<LogModInfo>() )) .ToList(); - if (contentPacks?.TryGetValue("", out LogModInfo[] invalidPacks) == true) + if (contentPacks?.TryGetValue("", out LogModInfo[]? invalidPacks) == true) { modsWithContentPacks.Add(( Mod: new LogModInfo(ModType.CodeMod, "<invalid content packs>", "", "", ""), @@ -477,7 +495,6 @@ else if (log?.IsValid == true) <log-line v-for="msg in visibleMessages" v-bind:key="msg.id" - v-bind:showScreenId="showScreenId" v-bind:message="msg" v-bind:highlight="shouldHighlight" /> diff --git a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css index f136a96f..995f5aa9 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css +++ b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css @@ -73,6 +73,11 @@ table caption { margin-bottom: 0.5em; } +#fix-list li.notice { + background: #EEFFEE; + border-color: #080; +} + #fix-list li.important { background: #FCC; border-color: #800; diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js index fccd00be..324218bb 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -424,10 +424,6 @@ smapi.logParser = function (state) { Vue.component("log-line", { functional: true, props: { - showScreenId: { - type: Boolean, - required: true - }, message: { type: Object, required: true @@ -456,7 +452,7 @@ smapi.logParser = function (state) { "td", { attrs: { - colspan: context.props.showScreenId ? 4 : 3 + colspan: state.isSplitScreen ? 4 : 3 } }, "" @@ -541,7 +537,7 @@ smapi.logParser = function (state) { }, [ createElement("td", message.Time), - context.props.showScreenId ? createElement("td", message.ScreenId) : null, + state.isSplitScreen ? createElement("td", { attrs: { title: (message.ScreenId == 0 ? "main screen" : "screen #" + (message.ScreenId + 1)) + " in split-screen mode" } }, `🖵${message.ScreenId + 1}`) : null, createElement("td", level.toUpperCase()), createElement( "td", @@ -588,9 +584,6 @@ smapi.logParser = function (state) { anyModsShown: function () { return stats.modsShown > 0; }, - showScreenId: function () { - return this.data.screenIds.length > 1; - }, // Maybe not strictly necessary, but the Vue template is being // weird about accessing data entries on the app rather than diff --git a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json index a00403c0..bd9e7427 100644 --- a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json +++ b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json @@ -14,9 +14,9 @@ "title": "Format version", "description": "The format version. You should always use the latest version to enable the latest features, avoid obsolete behavior, and reduce load times.", "type": "string", - "pattern": "^1\\.28\\.[0-9]+$", + "pattern": "^1\\.29\\.[0-9]+$", "@errorMessages": { - "pattern": "Incorrect value '@value'. You should always use the latest format version (currently 1.28.0) to enable the latest features, avoid obsolete behavior, and reduce load times." + "pattern": "Incorrect value '@value'. You should always use the latest format version (currently 1.29.0) to enable the latest features, avoid obsolete behavior, and reduce load times." } }, "ConfigSchema": { diff --git a/src/SMAPI.sln b/src/SMAPI.sln index 8bf86487..99b9dc83 100644 --- a/src/SMAPI.sln +++ b/src/SMAPI.sln @@ -19,9 +19,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{4B1C EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ISSUE_TEMPLATE", "ISSUE_TEMPLATE", "{F4453AB6-D7D6-447F-A973-956CC777968F}" ProjectSection(SolutionItems) = preProject - ..\.github\ISSUE_TEMPLATE\bug_report.md = ..\.github\ISSUE_TEMPLATE\bug_report.md - ..\.github\ISSUE_TEMPLATE\feature_request.md = ..\.github\ISSUE_TEMPLATE\feature_request.md - ..\.github\ISSUE_TEMPLATE\general.md = ..\.github\ISSUE_TEMPLATE\general.md + ..\.github\ISSUE_TEMPLATE\config.yml = ..\.github\ISSUE_TEMPLATE\config.yml + ..\.github\ISSUE_TEMPLATE\custom.md = ..\.github\ISSUE_TEMPLATE\custom.md EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{09CF91E5-5BAB-4650-A200-E5EA9A633046}" diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index 493aebd9..77a19964 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -52,7 +52,7 @@ namespace StardewModdingAPI internal static int? LogScreenId { get; set; } /// <summary>SMAPI's current raw semantic version.</summary> - internal static string RawApiVersion = "3.16.2"; + internal static string RawApiVersion = "3.18.3"; } /// <summary>Contains SMAPI's constants and assumptions.</summary> @@ -71,7 +71,7 @@ namespace StardewModdingAPI public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.5.6"); /// <summary>The maximum supported version of Stardew Valley, if any.</summary> - public static ISemanticVersion? MaximumGameVersion { get; } = null; + public static ISemanticVersion? MaximumGameVersion { get; } = new GameVersion("1.5.6"); /// <summary>The target game platform.</summary> public static GamePlatform TargetPlatform { get; } = EarlyConstants.Platform; @@ -90,7 +90,7 @@ namespace StardewModdingAPI source: null, nounPhrase: $"{nameof(Constants)}.{nameof(Constants.ExecutionPath)}", version: "3.14.0", - severity: DeprecationLevel.Info + severity: DeprecationLevel.PendingRemoval ); return Constants.GamePath; diff --git a/src/SMAPI/Context.cs b/src/SMAPI/Context.cs index c822908e..978459e8 100644 --- a/src/SMAPI/Context.cs +++ b/src/SMAPI/Context.cs @@ -60,7 +60,7 @@ namespace StardewModdingAPI public static bool IsWorldReady { get => Context.IsWorldReadyForScreen.Value; - set => Context.IsWorldReadyForScreen.Value = value; + internal set => Context.IsWorldReadyForScreen.Value = value; } /// <summary>Whether <see cref="IsWorldReady"/> is true and the player is free to act in the world (no menu is displayed, no cutscene is in progress, etc).</summary> diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs index 3393b22f..89ced1ba 100644 --- a/src/SMAPI/Framework/Content/AssetDataForImage.cs +++ b/src/SMAPI/Framework/Content/AssetDataForImage.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Diagnostics.CodeAnalysis; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; @@ -32,59 +33,70 @@ namespace StardewModdingAPI.Framework.Content /// <inheritdoc /> public void PatchImage(IRawTextureData source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace) { - this.GetPatchBounds(ref sourceArea, ref targetArea, source.Width, source.Height); - - // validate source data if (source == null) throw new ArgumentNullException(nameof(source), "Can't patch from null source data."); - // get the pixels for the source area - Color[] sourceData; + // get normalized bounds + this.GetPatchBounds(ref sourceArea, ref targetArea, source.Width, source.Height); + if (source.Data.Length < (sourceArea.Value.Bottom - 1) * source.Width + sourceArea.Value.Right) + throw new ArgumentException("Can't apply image patch because the source image is smaller than the source area.", nameof(source)); + int areaX = sourceArea.Value.X; + int areaY = sourceArea.Value.Y; + int areaWidth = sourceArea.Value.Width; + int areaHeight = sourceArea.Value.Height; + + // shortcut: if the area width matches the source image, we can apply the image as-is without needing + // to copy the pixels into a smaller subset. It's fine if the source is taller than the area, since we'll + // just ignore the extra data at the end of the pixel array. + if (areaWidth == source.Width) + { + this.PatchImageImpl(source.Data, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode, areaY); + return; + } + + // else copy the pixels within the smaller area & apply that + int pixelCount = areaWidth * areaHeight; + Color[] sourceData = ArrayPool<Color>.Shared.Rent(pixelCount); + try { - int areaX = sourceArea.Value.X; - int areaY = sourceArea.Value.Y; - int areaWidth = sourceArea.Value.Width; - int areaHeight = sourceArea.Value.Height; - - if (areaX == 0 && areaY == 0 && areaWidth == source.Width && areaHeight == source.Height) - sourceData = source.Data; - else + for (int y = areaY, maxY = areaY + areaHeight; y < maxY; y++) { - sourceData = new Color[areaWidth * areaHeight]; - int i = 0; - for (int y = areaY, maxY = areaY + areaHeight - 1; y <= maxY; y++) - { - for (int x = areaX, maxX = areaX + areaWidth - 1; x <= maxX; x++) - { - int targetIndex = (y * source.Width) + x; - sourceData[i++] = source.Data[targetIndex]; - } - } + int sourceIndex = (y * source.Width) + areaX; + int targetIndex = (y - areaY) * areaWidth; + Array.Copy(source.Data, sourceIndex, sourceData, targetIndex, areaWidth); } - } - // apply - this.PatchImageImpl(sourceData, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode); + this.PatchImageImpl(sourceData, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode); + } + finally + { + ArrayPool<Color>.Shared.Return(sourceData); + } } /// <inheritdoc /> public void PatchImage(Texture2D source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace) { - this.GetPatchBounds(ref sourceArea, ref targetArea, source.Width, source.Height); - - // validate source texture if (source == null) throw new ArgumentNullException(nameof(source), "Can't patch from a null source texture."); + + // get normalized bounds + this.GetPatchBounds(ref sourceArea, ref targetArea, source.Width, source.Height); if (!source.Bounds.Contains(sourceArea.Value)) throw new ArgumentOutOfRangeException(nameof(sourceArea), "The source area is outside the bounds of the source texture."); - // get source data + // get source data & apply int pixelCount = sourceArea.Value.Width * sourceArea.Value.Height; - Color[] sourceData = GC.AllocateUninitializedArray<Color>(pixelCount); - source.GetData(0, sourceArea, sourceData, 0, pixelCount); - - // apply - this.PatchImageImpl(sourceData, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode); + Color[] sourceData = ArrayPool<Color>.Shared.Rent(pixelCount); + try + { + source.GetData(0, sourceArea, sourceData, 0, pixelCount); + this.PatchImageImpl(sourceData, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode); + } + finally + { + ArrayPool<Color>.Shared.Return(sourceData); + } } /// <inheritdoc /> @@ -94,7 +106,7 @@ namespace StardewModdingAPI.Framework.Content return false; Texture2D original = this.Data; - Texture2D texture = new(Game1.graphics.GraphicsDevice, Math.Max(original.Width, minWidth), Math.Max(original.Height, minHeight)); + Texture2D texture = new Texture2D(Game1.graphics.GraphicsDevice, Math.Max(original.Width, minWidth), Math.Max(original.Height, minHeight)).SetName(original.Name); this.ReplaceWith(texture); this.PatchImage(original); return true; @@ -117,19 +129,22 @@ namespace StardewModdingAPI.Framework.Content /// <summary>Overwrite part of the image.</summary> /// <param name="sourceData">The image data to patch into the content.</param> - /// <param name="sourceWidth">The pixel width of the source image.</param> - /// <param name="sourceHeight">The pixel height of the source image.</param> + /// <param name="sourceWidth">The pixel width of the original source image.</param> + /// <param name="sourceHeight">The pixel height of the original source image.</param> /// <param name="sourceArea">The part of the <paramref name="sourceData"/> to copy (or <c>null</c> to take the whole texture). This must be within the bounds of the <paramref name="sourceData"/> texture.</param> /// <param name="targetArea">The part of the content to patch (or <c>null</c> to patch the whole texture). The original content within this area will be erased. This must be within the bounds of the existing spritesheet.</param> /// <param name="patchMode">Indicates how an image should be patched.</param> + /// <param name="startRow">The row to start on, for the sourceData.</param> /// <exception cref="ArgumentNullException">One of the arguments is null.</exception> /// <exception cref="ArgumentOutOfRangeException">The <paramref name="targetArea"/> is outside the bounds of the spritesheet.</exception> /// <exception cref="InvalidOperationException">The content being read isn't an image.</exception> - private void PatchImageImpl(Color[] sourceData, int sourceWidth, int sourceHeight, Rectangle sourceArea, Rectangle targetArea, PatchMode patchMode) + private void PatchImageImpl(Color[] sourceData, int sourceWidth, int sourceHeight, Rectangle sourceArea, Rectangle targetArea, PatchMode patchMode, int startRow = 0) { - // get texture + // get texture info Texture2D target = this.Data; int pixelCount = sourceArea.Width * sourceArea.Height; + int firstPixel = startRow * sourceArea.Width; + int lastPixel = firstPixel + pixelCount - 1; // validate if (sourceArea.X < 0 || sourceArea.Y < 0 || sourceArea.Right > sourceWidth || sourceArea.Bottom > sourceHeight) @@ -139,24 +154,67 @@ namespace StardewModdingAPI.Framework.Content if (sourceArea.Size != targetArea.Size) throw new InvalidOperationException("The source and target areas must be the same size."); - // merge data - if (patchMode == PatchMode.Overlay) + // shortcut: replace the entire area + if (patchMode == PatchMode.Replace) + { + target.SetData(0, targetArea, sourceData, firstPixel, pixelCount); + return; + } + + // skip transparent pixels at the start & end (e.g. large spritesheet with a few sprites replaced) + int startIndex = -1; + int endIndex = -1; + for (int i = firstPixel; i <= lastPixel; i++) + { + if (sourceData[i].A >= AssetDataForImage.MinOpacity) + { + startIndex = i; + break; + } + } + if (startIndex == -1) + return; // blank texture + + for (int i = lastPixel; i >= startIndex; i--) + { + if (sourceData[i].A >= AssetDataForImage.MinOpacity) + { + endIndex = i; + break; + } + } + if (endIndex == -1) + return; // ??? + + // update target rectangle + int sourceOffset; + { + int topOffset = startIndex / sourceArea.Width; + int bottomOffset = endIndex / sourceArea.Width; + + targetArea = new(targetArea.X, targetArea.Y + topOffset - startRow, targetArea.Width, bottomOffset - topOffset + 1); + pixelCount = targetArea.Width * targetArea.Height; + sourceOffset = topOffset * sourceArea.Width; + } + + // apply + Color[] mergedData = ArrayPool<Color>.Shared.Rent(pixelCount); + try { - // get target data - Color[] mergedData = GC.AllocateUninitializedArray<Color>(pixelCount); target.GetData(0, targetArea, mergedData, 0, pixelCount); - // merge pixels - for (int i = 0; i < pixelCount; i++) + for (int i = startIndex; i <= endIndex; i++) { + int targetIndex = i - sourceOffset; + Color above = sourceData[i]; - Color below = mergedData[i]; + Color below = mergedData[targetIndex]; // shortcut transparency - if (above.A < MinOpacity) + if (above.A < AssetDataForImage.MinOpacity) continue; - if (below.A < MinOpacity) - mergedData[i] = above; + if (below.A < AssetDataForImage.MinOpacity || above.A == byte.MaxValue) + mergedData[targetIndex] = above; // merge pixels else @@ -165,7 +223,7 @@ namespace StardewModdingAPI.Framework.Content // premultiplied by the content pipeline. The formula is derived from // https://blogs.msdn.microsoft.com/shawnhar/2009/11/06/premultiplied-alpha/. float alphaBelow = 1 - (above.A / 255f); - mergedData[i] = new Color( + mergedData[targetIndex] = new Color( r: (int)(above.R + (below.R * alphaBelow)), g: (int)(above.G + (below.G * alphaBelow)), b: (int)(above.B + (below.B * alphaBelow)), @@ -176,8 +234,10 @@ namespace StardewModdingAPI.Framework.Content target.SetData(0, targetArea, mergedData, 0, pixelCount); } - else - target.SetData(0, targetArea, sourceData, 0, pixelCount); + finally + { + ArrayPool<Color>.Shared.Return(mergedData); + } } } } diff --git a/src/SMAPI/Framework/Content/AssetInfo.cs b/src/SMAPI/Framework/Content/AssetInfo.cs index af000300..52ef02e6 100644 --- a/src/SMAPI/Framework/Content/AssetInfo.cs +++ b/src/SMAPI/Framework/Content/AssetInfo.cs @@ -45,7 +45,7 @@ namespace StardewModdingAPI.Framework.Content source: null, nounPhrase: $"{nameof(IAssetInfo)}.{nameof(IAssetInfo.AssetName)}", version: "3.14.0", - severity: DeprecationLevel.Info, + severity: DeprecationLevel.PendingRemoval, unlessStackIncludes: new[] { $"{typeof(AssetInterceptorChange).FullName}.{nameof(AssetInterceptorChange.CanIntercept)}", @@ -84,7 +84,7 @@ namespace StardewModdingAPI.Framework.Content source: null, nounPhrase: $"{nameof(IAssetInfo)}.{nameof(IAssetInfo.AssetNameEquals)}", version: "3.14.0", - severity: DeprecationLevel.Info, + severity: DeprecationLevel.PendingRemoval, unlessStackIncludes: new[] { $"{typeof(AssetInterceptorChange).FullName}.{nameof(AssetInterceptorChange.CanIntercept)}", diff --git a/src/SMAPI/Framework/Content/AssetName.cs b/src/SMAPI/Framework/Content/AssetName.cs index 148354a1..8355f9ec 100644 --- a/src/SMAPI/Framework/Content/AssetName.cs +++ b/src/SMAPI/Framework/Content/AssetName.cs @@ -1,6 +1,8 @@ using System; using StardewModdingAPI.Toolkit.Utilities; +using StardewModdingAPI.Utilities.AssetPathUtilities; using StardewValley; +using ToolkitPathUtilities = StardewModdingAPI.Toolkit.Utilities.PathUtilities; namespace StardewModdingAPI.Framework.Content { @@ -94,10 +96,26 @@ namespace StardewModdingAPI.Framework.Content if (string.IsNullOrWhiteSpace(assetName)) return false; - assetName = PathUtilities.NormalizeAssetName(assetName); + AssetNamePartEnumerator curParts = new(useBaseName ? this.BaseName : this.Name); + AssetNamePartEnumerator otherParts = new(assetName.AsSpan().Trim()); - string compareTo = useBaseName ? this.BaseName : this.Name; - return compareTo.Equals(assetName, StringComparison.OrdinalIgnoreCase); + while (true) + { + bool curHasMore = curParts.MoveNext(); + bool otherHasMore = otherParts.MoveNext(); + + // mismatch: lengths differ + if (otherHasMore != curHasMore) + return false; + + // match: both reached the end without a mismatch + if (!curHasMore) + return true; + + // mismatch: current segment is different + if (!curParts.Current.Equals(otherParts.Current, StringComparison.OrdinalIgnoreCase)) + return false; + } } /// <inheritdoc /> @@ -119,42 +137,76 @@ namespace StardewModdingAPI.Framework.Content if (prefix is null) return false; - string rawTrimmed = prefix.Trim(); + // get initial values + ReadOnlySpan<char> trimmedPrefix = prefix.AsSpan().Trim(); + if (trimmedPrefix.Length == 0) + return true; + ReadOnlySpan<char> pathSeparators = new(ToolkitPathUtilities.PossiblePathSeparators); // just to simplify calling other span APIs - // asset keys can't have a leading slash, but NormalizeAssetName will trim them - if (rawTrimmed.StartsWith('/') || rawTrimmed.StartsWith('\\')) + // asset keys can't have a leading slash, but AssetPathYielder will trim them + if (pathSeparators.Contains(trimmedPrefix[0])) return false; - // normalize prefix + // compare segments + AssetNamePartEnumerator curParts = new(this.Name); + AssetNamePartEnumerator prefixParts = new(trimmedPrefix); + while (true) { - string normalized = PathUtilities.NormalizeAssetName(prefix); + bool curHasMore = curParts.MoveNext(); + bool prefixHasMore = prefixParts.MoveNext(); - // keep trailing slash - if (rawTrimmed.EndsWith('/') || rawTrimmed.EndsWith('\\')) - normalized += PathUtilities.PreferredAssetSeparator; + // reached end for one side + if (prefixHasMore != curHasMore) + { + // mismatch: prefix is longer + if (prefixHasMore) + return false; + + // match: every segment in the prefix matched and subfolders are allowed (e.g. prefix 'Data/Events' with target 'Data/Events/Beach') + if (allowSubfolder) + return true; + + // Special case: the prefix ends with a path separator, but subfolders aren't allowed. This case + // matches if there's no further path separator in the asset name *after* the current separator. + // For example, the prefix 'A/B/' matches 'A/B/C' but not 'A/B/C/D'. + return pathSeparators.Contains(trimmedPrefix[^1]) && curParts.Remainder.Length == 0; + } - prefix = normalized; - } + // previous segments matched exactly and both reached the end + // match if prefix doesn't end with '/' (which should only match subfolders) + if (!prefixHasMore) + return !pathSeparators.Contains(trimmedPrefix[^1]); - // compare - if (prefix.Length == 0) - return true; + // compare segment + if (curParts.Current.Length == prefixParts.Current.Length) + { + // mismatch: segments aren't equivalent + if (!curParts.Current.Equals(prefixParts.Current, StringComparison.OrdinalIgnoreCase)) + return false; + } + else + { + // mismatch: prefix has more beyond this, and this segment isn't an exact match + if (prefixParts.Remainder.Length != 0) + return false; - return - this.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) - && ( - allowPartialWord - || this.Name.Length == prefix.Length - || !char.IsLetterOrDigit(prefix[^1]) // last character in suffix is word separator - || !char.IsLetterOrDigit(this.Name[prefix.Length]) // or first character after it is - ) - && ( - allowSubfolder - || this.Name.Length == prefix.Length - || !this.Name[prefix.Length..].Contains(PathUtilities.PreferredAssetSeparator) - ); - } + // mismatch: cur segment doesn't start with prefix + if (!curParts.Current.StartsWith(prefixParts.Current, StringComparison.OrdinalIgnoreCase)) + return false; + + // mismatch: something like "Maps/" would need an exact match + if (pathSeparators.Contains(trimmedPrefix[^1])) + return false; + // mismatch: partial word match not allowed, and the first or last letter of the suffix isn't a word separator + if (!allowPartialWord && char.IsLetterOrDigit(prefixParts.Current[^1]) && char.IsLetterOrDigit(curParts.Current[prefixParts.Current.Length])) + return false; + + // possible match + return allowSubfolder || (pathSeparators.Contains(trimmedPrefix[^1]) ? curParts.Remainder.IndexOfAny(ToolkitPathUtilities.PossiblePathSeparators) < 0 : curParts.Remainder.Length == 0); + } + } + } /// <inheritdoc /> public bool IsDirectlyUnderPath(string? assetFolder) @@ -162,7 +214,7 @@ namespace StardewModdingAPI.Framework.Content if (assetFolder is null) return false; - return this.StartsWith(assetFolder + "/", allowPartialWord: false, allowSubfolder: false); + return this.StartsWith(assetFolder + ToolkitPathUtilities.PreferredPathSeparator, allowPartialWord: false, allowSubfolder: false); } /// <inheritdoc /> diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs index 9e044b44..86415a5f 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -34,9 +34,6 @@ namespace StardewModdingAPI.Framework /// <summary>An asset key prefix for assets from SMAPI mod folders.</summary> private readonly string ManagedPrefix = "SMAPI"; - /// <summary>Whether to use raw image data when possible, instead of initializing an XNA Texture2D instance through the GPU.</summary> - private readonly bool UseRawImageLoading; - /// <summary>Get a file lookup for the given directory.</summary> private readonly Func<string, IFileLookup> GetFileLookup; @@ -132,6 +129,7 @@ namespace StardewModdingAPI.Framework /// <param name="rootDirectory">The root directory to search for content.</param> /// <param name="currentCulture">The current culture for which to localize content.</param> /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="multiplayer">The multiplayer instance whose map cache to update during asset propagation.</param> /// <param name="reflection">Simplifies access to private code.</param> /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> /// <param name="onLoadingFirstAsset">A callback to invoke the first time *any* game content manager loads an asset.</param> @@ -139,8 +137,7 @@ namespace StardewModdingAPI.Framework /// <param name="getFileLookup">Get a file lookup for the given directory.</param> /// <param name="onAssetsInvalidated">A callback to invoke when any asset names have been invalidated from the cache.</param> /// <param name="requestAssetOperations">Get the load/edit operations to apply to an asset by querying registered <see cref="IContentEvents.AssetRequested"/> event handlers.</param> - /// <param name="useRawImageLoading">Whether to use raw image data when possible, instead of initializing an XNA Texture2D instance through the GPU.</param> - public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, Action<BaseContentManager, IAssetName> onAssetLoaded, Func<string, IFileLookup> getFileLookup, Action<IList<IAssetName>> onAssetsInvalidated, Func<IAssetInfo, AssetOperationGroup?> requestAssetOperations, bool useRawImageLoading) + public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Multiplayer multiplayer, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, Action<BaseContentManager, IAssetName> onAssetLoaded, Func<string, IFileLookup> getFileLookup, Action<IList<IAssetName>> onAssetsInvalidated, Func<IAssetInfo, AssetOperationGroup?> requestAssetOperations) { this.GetFileLookup = getFileLookup; this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); @@ -151,7 +148,6 @@ namespace StardewModdingAPI.Framework this.OnAssetsInvalidated = onAssetsInvalidated; this.RequestAssetOperations = requestAssetOperations; this.FullRootDirectory = Path.Combine(Constants.GamePath, rootDirectory); - this.UseRawImageLoading = useRawImageLoading; this.ContentManagers.Add( this.MainContentManager = new GameContentManager( name: "Game1.content", @@ -182,7 +178,7 @@ namespace StardewModdingAPI.Framework this.ContentManagers.Add(contentManagerForAssetPropagation); this.VanillaContentManager = new LocalizedContentManager(serviceProvider, rootDirectory); - this.CoreAssets = new CoreAssetPropagator(this.MainContentManager, contentManagerForAssetPropagation, this.Monitor, reflection, name => this.ParseAssetName(name, allowLocales: true)); + this.CoreAssets = new CoreAssetPropagator(this.MainContentManager, contentManagerForAssetPropagation, this.Monitor, multiplayer, reflection, name => this.ParseAssetName(name, allowLocales: true)); this.LocaleCodes = new Lazy<Dictionary<string, LocalizedContentManager.LanguageCode>>(() => this.GetLocaleCodes(customLanguages: Enumerable.Empty<ModLanguage>())); } @@ -230,8 +226,7 @@ namespace StardewModdingAPI.Framework reflection: this.Reflection, jsonHelper: this.JsonHelper, onDisposing: this.OnDisposing, - fileLookup: this.GetFileLookup(rootDirectory), - useRawImageLoading: this.UseRawImageLoading + fileLookup: this.GetFileLookup(rootDirectory) ); this.ContentManagers.Add(manager); return manager; diff --git a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs index 54f8e2a2..1e9f4ffe 100644 --- a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs @@ -336,7 +336,7 @@ namespace StardewModdingAPI.Framework.ContentManagers { // track asset key if (value is Texture2D texture) - texture.Name = assetName.Name; + texture.SetName(assetName); // save to cache // Note: even if the asset was loaded and cached right before this method was called, diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index cc6f8372..2c068784 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -1,9 +1,11 @@ using System; +using System.Buffers; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; +using System.Runtime.CompilerServices; using BmFont; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Content; @@ -28,9 +30,6 @@ namespace StardewModdingAPI.Framework.ContentManagers /********* ** Fields *********/ - /// <summary>Whether to use raw image data when possible, instead of initializing an XNA Texture2D instance through the GPU.</summary> - private readonly bool UseRawImageLoading; - /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> private readonly JsonHelper JsonHelper; @@ -72,15 +71,13 @@ 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="fileLookup">A lookup for files within the <paramref name="rootDirectory"/>.</param> - /// <param name="useRawImageLoading">Whether to use raw image data when possible, instead of initializing an XNA Texture2D instance through the GPU.</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, IFileLookup fileLookup, bool useRawImageLoading) + 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, IFileLookup fileLookup) : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: true) { this.GameContentManager = gameContentManager; this.FileLookup = fileLookup; this.JsonHelper = jsonHelper; this.ModName = modName; - this.UseRawImageLoading = useRawImageLoading; this.TryLocalizeKeys = false; } @@ -111,7 +108,7 @@ namespace StardewModdingAPI.Framework.ContentManagers if (this.Coordinator.TryParseManagedAssetKey(assetName.Name, out string? contentManagerID, out IAssetName? relativePath)) { if (contentManagerID != this.Name) - throw this.GetLoadError(assetName, ContentLoadErrorType.AccessDenied, "can't load a different mod's managed asset key through this mod content manager."); + this.ThrowLoadError(assetName, ContentLoadErrorType.AccessDenied, "can't load a different mod's managed asset key through this mod content manager."); assetName = relativePath; } } @@ -123,7 +120,7 @@ namespace StardewModdingAPI.Framework.ContentManagers // get file FileInfo file = this.GetModFile<T>(assetName.Name); if (!file.Exists) - throw this.GetLoadError(assetName, ContentLoadErrorType.AssetDoesNotExist, "the specified path doesn't exist."); + this.ThrowLoadError(assetName, ContentLoadErrorType.AssetDoesNotExist, "the specified path doesn't exist."); // load content asset = file.Extension.ToLower() switch @@ -133,7 +130,7 @@ namespace StardewModdingAPI.Framework.ContentManagers ".png" => this.LoadImageFile<T>(assetName, file), ".tbin" or ".tmx" => this.LoadMapFile<T>(assetName, file), ".xnb" => this.LoadXnbFile<T>(assetName), - _ => this.HandleUnknownFileType<T>(assetName, file) + _ => (T)this.HandleUnknownFileType(assetName, file, typeof(T)) }; } catch (Exception ex) @@ -141,7 +138,8 @@ namespace StardewModdingAPI.Framework.ContentManagers if (ex is SContentLoadException) throw; - throw this.GetLoadError(assetName, ContentLoadErrorType.Other, "an unexpected error occurred.", ex); + this.ThrowLoadError(assetName, ContentLoadErrorType.Other, "an unexpected error occurred.", ex); + return default; } // track & return asset @@ -189,7 +187,7 @@ namespace StardewModdingAPI.Framework.ContentManagers private T LoadDataFile<T>(IAssetName assetName, FileInfo file) { if (!this.JsonHelper.ReadJsonFileIfExists(file.FullName, out T? asset)) - throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, "the JSON file is invalid."); // should never happen since we check for file existence before calling this method + this.ThrowLoadError(assetName, ContentLoadErrorType.InvalidData, "the JSON file is invalid."); // should never happen since we check for file existence before calling this method return asset; } @@ -201,48 +199,52 @@ namespace StardewModdingAPI.Framework.ContentManagers private T LoadImageFile<T>(IAssetName assetName, FileInfo file) { this.AssertValidType<T>(assetName, file, typeof(Texture2D), typeof(IRawTextureData)); - bool expectsRawData = typeof(T).IsAssignableTo(typeof(IRawTextureData)); - bool asRawData = expectsRawData || this.UseRawImageLoading; + bool returnRawData = typeof(T).IsAssignableTo(typeof(IRawTextureData)); #if SMAPI_DEPRECATED - // disable raw data if PyTK will rescale the image (until it supports raw data) - if (asRawData && !expectsRawData) + if (!returnRawData && this.ShouldDisableIntermediateRawDataLoad<T>(assetName, file)) { - if (ModContentManager.EnablePyTkLegacyMode) - { - // PyTK intercepts Texture2D file loads to rescale them (e.g. for HD portraits), - // but doesn't support IRawTextureData loads yet. We can't just check if the - // current file has a '.pytk.json' rescale file though, since PyTK may still - // rescale it if the original asset or another edit gets rescaled. - asRawData = false; - this.Monitor.LogOnce("Enabled compatibility mode for PyTK 1.23.* or earlier. This won't cause any issues, but may impact performance. This will no longer be supported in the upcoming SMAPI 4.0.0.", LogLevel.Warn); - } + using FileStream stream = File.OpenRead(file.FullName); + Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream).SetName(assetName); + this.PremultiplyTransparency(texture); + return (T)(object)texture; } #endif - // load - if (asRawData) - { - IRawTextureData raw = this.LoadRawImageData(file, expectsRawData); + IRawTextureData raw = this.LoadRawImageData(file, returnRawData); - if (expectsRawData) - return (T)raw; - else - { - Texture2D texture = new(Game1.graphics.GraphicsDevice, raw.Width, raw.Height); - texture.SetData(raw.Data); - return (T)(object)texture; - } - } + if (returnRawData) + return (T)raw; else { - using FileStream stream = File.OpenRead(file.FullName); - Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream); - texture = this.PremultiplyTransparency(texture); + Texture2D texture = new Texture2D(Game1.graphics.GraphicsDevice, raw.Width, raw.Height).SetName(assetName); + texture.SetData(raw.Data); return (T)(object)texture; } } +#if SMAPI_DEPRECATED + /// <summary>Get whether to disable loading an image as <see cref="IRawTextureData"/> before building a <see cref="Texture2D"/> instance. This isn't called if the mod requested <see cref="IRawTextureData"/> directly.</summary> + /// <typeparam name="T">The type of asset being loaded.</typeparam> + /// <param name="assetName">The asset name relative to the loader root directory.</param> + /// <param name="file">The file being loaded.</param> + private bool ShouldDisableIntermediateRawDataLoad<T>(IAssetName assetName, FileInfo file) + { + // disable raw data if PyTK will rescale the image (until it supports raw data) + if (ModContentManager.EnablePyTkLegacyMode) + { + // PyTK intercepts Texture2D file loads to rescale them (e.g. for HD portraits), + // but doesn't support IRawTextureData loads yet. We can't just check if the + // current file has a '.pytk.json' rescale file though, since PyTK may still + // rescale it if the original asset or another edit gets rescaled. + this.Monitor.LogOnce("Enabled compatibility mode for PyTK 1.23.* or earlier. This won't cause any issues, but may impact performance. This will no longer be supported in the upcoming SMAPI 4.0.0.", LogLevel.Warn); + return true; + } + + return false; + } +#endif + /// <summary>Load the raw image data from a file on disk.</summary> /// <param name="file">The file whose data to load.</param> /// <param name="forRawData">Whether the data is being loaded for an <see cref="IRawTextureData"/> (true) or <see cref="Texture2D"/> (false) instance.</param> @@ -301,7 +303,7 @@ namespace StardewModdingAPI.Framework.ContentManagers private T LoadXnbFile<T>(IAssetName assetName) { if (typeof(IRawTextureData).IsAssignableFrom(typeof(T))) - throw this.GetLoadError(assetName, ContentLoadErrorType.Other, $"can't read XNB file as type {typeof(IRawTextureData)}; that type can only be read from a PNG file."); + this.ThrowLoadError(assetName, ContentLoadErrorType.Other, $"can't read XNB file as type {typeof(IRawTextureData)}; that type can only be read from a PNG file."); // the underlying content manager adds a .xnb extension implicitly, so // we need to strip it here to avoid trying to load a '.xnb.xnb' file. @@ -321,12 +323,15 @@ namespace StardewModdingAPI.Framework.ContentManagers } /// <summary>Handle a request to load a file type that isn't supported by SMAPI.</summary> - /// <typeparam name="T">The expected file type.</typeparam> /// <param name="assetName">The asset name relative to the loader root directory.</param> /// <param name="file">The file to load.</param> - private T HandleUnknownFileType<T>(IAssetName assetName, FileInfo file) + /// <param name="assetType">The expected file type.</param> + private object HandleUnknownFileType(IAssetName assetName, FileInfo file, Type assetType) { - throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidName, $"unknown file extension '{file.Extension}'; must be one of '.fnt', '.json', '.png', '.tbin', '.tmx', or '.xnb'."); + this.ThrowLoadError(assetName, ContentLoadErrorType.InvalidName, $"unknown file extension '{file.Extension}'; must be one of '.fnt', '.json', '.png', '.tbin', '.tmx', or '.xnb'."); + return assetType.IsValueType + ? Activator.CreateInstance(assetType) + : null; } /// <summary>Assert that the asset type is compatible with one of the allowed types.</summary> @@ -338,18 +343,21 @@ namespace StardewModdingAPI.Framework.ContentManagers private void AssertValidType<TAsset>(IAssetName assetName, FileInfo file, params Type[] validTypes) { if (!validTypes.Any(validType => validType.IsAssignableFrom(typeof(TAsset)))) - throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(TAsset)}'; must be type '{string.Join("' or '", validTypes.Select(p => p.FullName))}'."); + this.ThrowLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(TAsset)}'; must be type '{string.Join("' or '", validTypes.Select(p => p.FullName))}'."); } - /// <summary>Get an error which indicates that an asset couldn't be loaded.</summary> + /// <summary>Throw an error which indicates that an asset couldn't be loaded.</summary> /// <param name="errorType">Why loading an asset through the content pipeline failed.</param> /// <param name="assetName">The asset name that failed to load.</param> /// <param name="reasonPhrase">The reason the file couldn't be loaded.</param> /// <param name="exception">The underlying exception, if applicable.</param> + /// <exception cref="SContentLoadException" /> + [DoesNotReturn] [DebuggerStepThrough, DebuggerHidden] - private SContentLoadException GetLoadError(IAssetName assetName, ContentLoadErrorType errorType, string reasonPhrase, Exception? exception = null) + [MethodImpl(MethodImplOptions.NoInlining)] + private void ThrowLoadError(IAssetName assetName, ContentLoadErrorType errorType, string reasonPhrase, Exception? exception = null) { - return new(errorType, $"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}", exception); + throw new SContentLoadException(errorType, $"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}", exception); } /// <summary>Get a file from the mod folder.</summary> @@ -381,26 +389,32 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="texture">The texture to premultiply.</param> /// <returns>Returns a premultiplied texture.</returns> /// <remarks>Based on <a href="https://gamedev.stackexchange.com/a/26037">code by David Gouveia</a>.</remarks> - private Texture2D PremultiplyTransparency(Texture2D texture) + private void PremultiplyTransparency(Texture2D texture) { - // premultiply pixels - Color[] data = GC.AllocateUninitializedArray<Color>(texture.Width * texture.Height); - texture.GetData(data); - bool changed = false; - for (int i = 0; i < data.Length; i++) + int count = texture.Width * texture.Height; + Color[] data = ArrayPool<Color>.Shared.Rent(count); + try { - Color pixel = data[i]; - if (pixel.A is (byte.MinValue or byte.MaxValue)) - continue; // no need to change fully transparent/opaque pixels + texture.GetData(data, 0, count); - data[i] = new Color(pixel.R * pixel.A / byte.MaxValue, pixel.G * pixel.A / byte.MaxValue, pixel.B * pixel.A / byte.MaxValue, pixel.A); // slower version: Color.FromNonPremultiplied(data[i].ToVector4()) - changed = true; - } + bool changed = false; + for (int i = 0; i < count; i++) + { + ref Color pixel = ref data[i]; + if (pixel.A is (byte.MinValue or byte.MaxValue)) + continue; // no need to change fully transparent/opaque pixels - if (changed) - texture.SetData(data); + data[i] = new Color(pixel.R * pixel.A / byte.MaxValue, pixel.G * pixel.A / byte.MaxValue, pixel.B * pixel.A / byte.MaxValue, pixel.A); // slower version: Color.FromNonPremultiplied(data[i].ToVector4()) + changed = true; + } - return texture; + if (changed) + texture.SetData(data, 0, count); + } + finally + { + ArrayPool<Color>.Shared.Return(data); + } } /// <summary>Fix custom map tilesheet paths so they can be found by the content manager.</summary> diff --git a/src/SMAPI/Framework/Deprecations/DeprecationManager.cs b/src/SMAPI/Framework/Deprecations/DeprecationManager.cs index f58f085e..f44b5d7d 100644 --- a/src/SMAPI/Framework/Deprecations/DeprecationManager.cs +++ b/src/SMAPI/Framework/Deprecations/DeprecationManager.cs @@ -101,7 +101,7 @@ namespace StardewModdingAPI.Framework.Deprecations foreach (DeprecationWarning warning in this.QueuedWarnings.OrderBy(p => p.ModName).ThenBy(p => p.NounPhrase)) { // build message - string message = $"{warning.ModName} uses deprecated code ({warning.NounPhrase} is deprecated since SMAPI {warning.Version})."; + string message = $"{warning.ModName} uses deprecated code ({warning.NounPhrase}) and will break in the next major SMAPI update."; // get log level LogLevel level; diff --git a/src/SMAPI/Framework/InternalExtensions.cs b/src/SMAPI/Framework/InternalExtensions.cs index ba9bbcec..7ad30d35 100644 --- a/src/SMAPI/Framework/InternalExtensions.cs +++ b/src/SMAPI/Framework/InternalExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading; using Microsoft.Xna.Framework.Graphics; @@ -163,5 +164,34 @@ namespace StardewModdingAPI.Framework { return reflection.GetField<bool>(spriteBatch, "_beginCalled").GetValue(); } + + /**** + ** Texture2D + ****/ + /// <summary>Set the texture name field.</summary> + /// <param name="texture">The texture whose name to set.</param> + /// <param name="assetName">The asset name to set.</param> + /// <returns>Returns the texture for chaining.</returns> + [return: NotNullIfNotNull("texture")] + public static Texture2D? SetName(this Texture2D? texture, IAssetName assetName) + { + if (texture != null) + texture.Name = assetName.Name; + + return texture; + } + + /// <summary>Set the texture name field.</summary> + /// <param name="texture">The texture whose name to set.</param> + /// <param name="assetName">The asset name to set.</param> + /// <returns>Returns the texture for chaining.</returns> + [return: NotNullIfNotNull("texture")] + public static Texture2D? SetName(this Texture2D? texture, string assetName) + { + if (texture != null) + texture.Name = assetName; + + return texture; + } } } diff --git a/src/SMAPI/Framework/Logging/LogManager.cs b/src/SMAPI/Framework/Logging/LogManager.cs index c0b7c0ba..ffffc9c7 100644 --- a/src/SMAPI/Framework/Logging/LogManager.cs +++ b/src/SMAPI/Framework/Logging/LogManager.cs @@ -223,7 +223,7 @@ namespace StardewModdingAPI.Framework.Logging // show update alert if (File.Exists(Constants.UpdateMarker)) { - string[] rawUpdateFound = File.ReadAllText(Constants.UpdateMarker).Split(new[] { '|' }, 2); + string[] rawUpdateFound = File.ReadAllText(Constants.UpdateMarker).Split('|', 2); if (SemanticVersion.TryParse(rawUpdateFound[0], out ISemanticVersion? updateFound)) { if (Constants.ApiVersion.IsPrerelease() && updateFound.IsNewerThan(Constants.ApiVersion)) @@ -269,7 +269,11 @@ namespace StardewModdingAPI.Framework.Logging public void LogIntro(string modsPath, IDictionary<string, object?> customSettings) { // log platform - this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} (build {Constants.GetBuildVersionLabel()}) on {EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform)}", LogLevel.Info); + this.Monitor.Log($"SMAPI {Constants.ApiVersion} " +#if !SMAPI_DEPRECATED + + "(strict mode) " +#endif + + $"with Stardew Valley {Constants.GameVersion} (build {Constants.GetBuildVersionLabel()}) on {EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform)}", LogLevel.Info); // log basic info this.Monitor.Log($"Mods go here: {modsPath}", LogLevel.Info); @@ -280,6 +284,10 @@ namespace StardewModdingAPI.Framework.Logging // log custom settings if (customSettings.Any()) this.Monitor.Log($"Loaded with custom settings: {string.Join(", ", customSettings.OrderBy(p => p.Key).Select(p => $"{p.Key}: {p.Value}"))}"); + +#if !SMAPI_DEPRECATED + this.Monitor.Log("SMAPI is running in 'strict mode', which removes all deprecated APIs. This can significantly improve performance, but some mods may not work. You can reinstall SMAPI to disable it if you run into problems.", LogLevel.Info); +#endif } /// <summary>Log details for settings that don't match the default.</summary> diff --git a/src/SMAPI/Framework/Logging/LogOnceCacheKey.cs b/src/SMAPI/Framework/Logging/LogOnceCacheKey.cs new file mode 100644 index 00000000..4d31ffeb --- /dev/null +++ b/src/SMAPI/Framework/Logging/LogOnceCacheKey.cs @@ -0,0 +1,10 @@ +using System.Diagnostics.CodeAnalysis; + +namespace StardewModdingAPI.Framework.Logging +{ + /// <summary>The cache key for the <see cref="Monitor.LogOnceCache"/>.</summary> + /// <param name="Message">The log message.</param> + /// <param name="Level">The log level.</param> + [SuppressMessage("ReSharper", "NotAccessedPositionalProperty.Local", Justification = "This is only used as a lookup key.")] + internal readonly record struct LogOnceCacheKey(string Message, LogLevel Level); +} diff --git a/src/SMAPI/Framework/ModHelpers/CommandHelper.cs b/src/SMAPI/Framework/ModHelpers/CommandHelper.cs index b7d4861f..90edc137 100644 --- a/src/SMAPI/Framework/ModHelpers/CommandHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/CommandHelper.cs @@ -43,7 +43,7 @@ namespace StardewModdingAPI.Framework.ModHelpers source: this.Mod, nounPhrase: $"{nameof(IModHelper)}.{nameof(IModHelper.ConsoleCommands)}.{nameof(ICommandHelper.Trigger)}", version: "3.8.1", - severity: DeprecationLevel.Info + severity: DeprecationLevel.PendingRemoval ); return this.CommandManager.Trigger(name, arguments); diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs index 0a1633bf..152b264c 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs @@ -62,7 +62,7 @@ namespace StardewModdingAPI.Framework.ModHelpers source: this.Mod, nounPhrase: $"{nameof(IContentHelper)}.{nameof(IContentHelper.AssetLoaders)}", version: "3.14.0", - severity: DeprecationLevel.Info + severity: DeprecationLevel.PendingRemoval ); return this.ObservableAssetLoaders; @@ -78,7 +78,7 @@ namespace StardewModdingAPI.Framework.ModHelpers source: this.Mod, nounPhrase: $"{nameof(IContentHelper)}.{nameof(IContentHelper.AssetEditors)}", version: "3.14.0", - severity: DeprecationLevel.Info + severity: DeprecationLevel.PendingRemoval ); return this.ObservableAssetEditors; diff --git a/src/SMAPI/Framework/ModHelpers/ModHelper.cs b/src/SMAPI/Framework/ModHelpers/ModHelper.cs index 1cdd8536..531289d0 100644 --- a/src/SMAPI/Framework/ModHelpers/ModHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModHelper.cs @@ -41,7 +41,7 @@ namespace StardewModdingAPI.Framework.ModHelpers source: this.Mod, nounPhrase: $"{nameof(IModHelper)}.{nameof(IModHelper.Content)}", version: "3.14.0", - severity: DeprecationLevel.Info + severity: DeprecationLevel.PendingRemoval ); return this.ContentImpl; diff --git a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs index 348ba225..93edd597 100644 --- a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Generic; using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Internal; namespace StardewModdingAPI.Framework.ModHelpers { @@ -15,8 +17,8 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <summary>Encapsulates monitoring and logging for the mod.</summary> private readonly IMonitor Monitor; - /// <summary>The mod IDs for APIs accessed by this instanced.</summary> - private readonly HashSet<string> AccessedModApis = new(); + /// <summary>The APIs accessed by this instance.</summary> + private readonly Dictionary<string, object?> AccessedModApis = new(); /// <summary>Generates proxy classes to access mod APIs through an arbitrary interface.</summary> private readonly IInterfaceProxyFactory ProxyFactory; @@ -66,11 +68,44 @@ namespace StardewModdingAPI.Framework.ModHelpers return null; } - // get raw API + // get the target mod IModMetadata? mod = this.Registry.Get(uniqueID); - if (mod?.Api != null && this.AccessedModApis.Add(mod.Manifest.UniqueID)) - this.Monitor.Log($"Accessed mod-provided API for {mod.DisplayName}."); - return mod?.Api; + if (mod == null) + return null; + + // fetch API + if (!this.AccessedModApis.TryGetValue(mod.Manifest.UniqueID, out object? api)) + { + // if the target has a global API, this is mutually exclusive with per-mod APIs + if (mod.Api != null) + api = mod.Api; + + // else try to get a per-mod API + else + { + try + { + api = mod.Mod?.GetApi(this.Mod); + if (api != null && !api.GetType().IsPublic) + { + api = null; + this.Monitor.Log($"{mod.DisplayName} provides a per-mod API instance with a non-public type. This isn't currently supported, so the API won't be available to other mods.", LogLevel.Warn); + } + } + catch (Exception ex) + { + this.Monitor.Log($"Failed loading the per-mod API instance from {mod.DisplayName}. Integrations with other mods may not work. Error: {ex.GetLogSummary()}", LogLevel.Error); + api = null; + } + } + + // cache & log API access + this.AccessedModApis[mod.Manifest.UniqueID] = api; + if (api != null) + this.Monitor.Log($"Accessed mod-provided API ({api.GetType().FullName}) for {mod.DisplayName}."); + } + + return api; } /// <inheritdoc /> diff --git a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs index 01037870..ae08d972 100644 --- a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs +++ b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs @@ -221,7 +221,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// </remarks> public static Assembly? ResolveAssembly(string name) { - string shortName = name.Split(new[] { ',' }, 2).First(); // get simple name (without version and culture) + string shortName = name.Split(',', 2).First(); // get simple name (without version and culture) return AppDomain.CurrentDomain .GetAssemblies() .FirstOrDefault(p => p.GetName().Name == shortName); diff --git a/src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs index f5d449c5..4dd9ccc6 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs @@ -58,7 +58,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders MethodReference? methodRef = RewriteHelper.AsMethodReference(instruction); if (methodRef != null && methodRef.DeclaringType.FullName == this.FullTypeName && this.MethodNames.Contains(methodRef.Name)) { - string eventName = methodRef.Name.Split(new[] { '_' }, 2)[1]; + string eventName = methodRef.Name.Split('_', 2)[1]; this.MethodNames.Remove($"add_{eventName}"); this.MethodNames.Remove($"remove_{eventName}"); diff --git a/src/SMAPI/Framework/ModLoading/ModResolver.cs b/src/SMAPI/Framework/ModLoading/ModResolver.cs index fe56f4d2..607bb70d 100644 --- a/src/SMAPI/Framework/ModLoading/ModResolver.cs +++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs @@ -4,11 +4,11 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Framework.ModScanning; using StardewModdingAPI.Toolkit.Framework.UpdateData; using StardewModdingAPI.Toolkit.Serialization.Models; -using StardewModdingAPI.Toolkit.Utilities; using StardewModdingAPI.Toolkit.Utilities.PathLookups; namespace StardewModdingAPI.Framework.ModLoading @@ -126,100 +126,23 @@ namespace StardewModdingAPI.Framework.ModLoading continue; } - // validate DLL / content pack fields + // validate manifest format + if (!ManifestValidator.TryValidateFields(mod.Manifest, out string manifestError)) { - bool hasDll = !string.IsNullOrWhiteSpace(mod.Manifest.EntryDll); - bool isContentPack = mod.Manifest.ContentPackFor != null; - - // validate field presence - if (!hasDll && !isContentPack) - { - mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has no {nameof(IManifest.EntryDll)} or {nameof(IManifest.ContentPackFor)} field; must specify one."); - continue; - } - if (hasDll && isContentPack) - { - mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest sets both {nameof(IManifest.EntryDll)} and {nameof(IManifest.ContentPackFor)}, which are mutually exclusive."); - continue; - } - - // validate DLL - if (hasDll) - { - // invalid filename format - if (mod.Manifest.EntryDll!.Intersect(Path.GetInvalidFileNameChars()).Any()) - { - mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has invalid filename '{mod.Manifest.EntryDll}' for the EntryDLL field."); - continue; - } - - // file doesn't exist - if (validateFilesExist) - { - IFileLookup pathLookup = getFileLookup(mod.DirectoryPath); - FileInfo file = pathLookup.GetFile(mod.Manifest.EntryDll!); - if (!file.Exists) - { - mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist."); - continue; - } - } - } - - // validate content pack - else - { - // invalid content pack ID - if (string.IsNullOrWhiteSpace(mod.Manifest.ContentPackFor!.UniqueID)) - { - mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest declares {nameof(IManifest.ContentPackFor)} without its required {nameof(IManifestContentPackFor.UniqueID)} field."); - continue; - } - } - } - - // validate required fields - { - List<string> missingFields = new List<string>(3); - - if (string.IsNullOrWhiteSpace(mod.Manifest.Name)) - missingFields.Add(nameof(IManifest.Name)); - if (mod.Manifest.Version == null || mod.Manifest.Version.ToString() == "0.0.0") - missingFields.Add(nameof(IManifest.Version)); - if (string.IsNullOrWhiteSpace(mod.Manifest.UniqueID)) - missingFields.Add(nameof(IManifest.UniqueID)); - - if (missingFields.Any()) - { - mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest is missing required fields ({string.Join(", ", missingFields)})."); - continue; - } + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its {manifestError}"); + continue; } - // validate ID format - if (!PathUtilities.IsSlug(mod.Manifest.UniqueID)) - mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, "its manifest specifies an invalid ID (IDs must only contain letters, numbers, underscores, periods, or hyphens)."); - - // validate dependencies - foreach (IManifestDependency? dependency in mod.Manifest.Dependencies) + // check that DLL exists if applicable + if (!string.IsNullOrEmpty(mod.Manifest.EntryDll) && validateFilesExist) { - // null dependency - if (dependency == null) - { - mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has a null entry under {nameof(IManifest.Dependencies)}."); - continue; - } - - // missing ID - if (string.IsNullOrWhiteSpace(dependency.UniqueID)) + IFileLookup pathLookup = getFileLookup(mod.DirectoryPath); + FileInfo file = pathLookup.GetFile(mod.Manifest.EntryDll!); + if (!file.Exists) { - mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has a {nameof(IManifest.Dependencies)} entry with no {nameof(IManifestDependency.UniqueID)} field."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist."); continue; } - - // invalid ID - if (!PathUtilities.IsSlug(dependency.UniqueID)) - mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has a {nameof(IManifest.Dependencies)} entry with an invalid {nameof(IManifestDependency.UniqueID)} field (IDs must only contain letters, numbers, underscores, periods, or hyphens)."); } } @@ -242,10 +165,41 @@ namespace StardewModdingAPI.Framework.ModLoading } } + /// <summary>Apply preliminary overrides to the load order based on the SMAPI configuration.</summary> + /// <param name="mods">The mods to process.</param> + /// <param name="modIdsToLoadEarly">The mod IDs SMAPI should load before any other mods (except those needed to load them).</param> + /// <param name="modIdsToLoadLate">The mod IDs SMAPI should load after any other mods.</param> + public IModMetadata[] ApplyLoadOrderOverrides(IModMetadata[] mods, HashSet<string> modIdsToLoadEarly, HashSet<string> modIdsToLoadLate) + { + if (!modIdsToLoadEarly.Any() && !modIdsToLoadLate.Any()) + return mods; + + string[] earlyArray = modIdsToLoadEarly.ToArray(); + string[] lateArray = modIdsToLoadLate.ToArray(); + + return mods + .OrderBy(mod => + { + string? id = mod.Manifest?.UniqueID; + + if (id is not null) + { + if (modIdsToLoadEarly.TryGetValue(id, out string? actualId)) + return -int.MaxValue + Array.IndexOf(earlyArray, actualId); + + if (modIdsToLoadLate.TryGetValue(id, out actualId)) + return int.MaxValue - Array.IndexOf(lateArray, actualId); + } + + return 0; + }) + .ToArray(); + } + /// <summary>Sort the given mods by the order they should be loaded.</summary> /// <param name="mods">The mods to process.</param> /// <param name="modDatabase">Handles access to SMAPI's internal mod metadata list.</param> - public IEnumerable<IModMetadata> ProcessDependencies(IEnumerable<IModMetadata> mods, ModDatabase modDatabase) + public IEnumerable<IModMetadata> ProcessDependencies(IReadOnlyList<IModMetadata> mods, ModDatabase modDatabase) { // initialize metadata mods = mods.ToArray(); @@ -261,7 +215,7 @@ namespace StardewModdingAPI.Framework.ModLoading // sort mods foreach (IModMetadata mod in mods) - this.ProcessDependencies(mods.ToArray(), modDatabase, mod, states, sortedMods, new List<IModMetadata>()); + this.ProcessDependencies(mods, modDatabase, mod, states, sortedMods, new List<IModMetadata>()); return sortedMods.Reverse(); } @@ -278,7 +232,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="sortedMods">The list in which to save mods sorted by dependency order.</param> /// <param name="currentChain">The current change of mod dependencies.</param> /// <returns>Returns the mod dependency status.</returns> - private ModDependencyStatus ProcessDependencies(IModMetadata[] mods, ModDatabase modDatabase, IModMetadata mod, IDictionary<IModMetadata, ModDependencyStatus> states, Stack<IModMetadata> sortedMods, ICollection<IModMetadata> currentChain) + private ModDependencyStatus ProcessDependencies(IReadOnlyList<IModMetadata> mods, ModDatabase modDatabase, IModMetadata mod, IDictionary<IModMetadata, ModDependencyStatus> states, Stack<IModMetadata> sortedMods, ICollection<IModMetadata> currentChain) { // check if already visited switch (states[mod]) @@ -409,7 +363,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>Get the dependencies declared in a manifest.</summary> /// <param name="manifest">The mod manifest.</param> /// <param name="loadedMods">The loaded mods.</param> - private IEnumerable<ModDependency> GetDependenciesFrom(IManifest manifest, IModMetadata[] loadedMods) + private IEnumerable<ModDependency> GetDependenciesFrom(IManifest manifest, IReadOnlyList<IModMetadata> loadedMods) { IModMetadata? FindMod(string id) => loadedMods.FirstOrDefault(m => m.HasID(id)); diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs index 9444c046..87970e6c 100644 --- a/src/SMAPI/Framework/Models/SConfig.cs +++ b/src/SMAPI/Framework/Models/SConfig.cs @@ -16,14 +16,15 @@ namespace StardewModdingAPI.Framework.Models private static readonly IDictionary<string, object> DefaultValues = new Dictionary<string, object> { [nameof(CheckForUpdates)] = true, + [nameof(ListenForConsoleInput)] = true, [nameof(ParanoidWarnings)] = Constants.IsDebugBuild, [nameof(UseBetaChannel)] = Constants.ApiVersion.IsPrerelease(), [nameof(GitHubProjectName)] = "Pathoschild/SMAPI", [nameof(WebApiBaseUrl)] = "https://smapi.io/api/", [nameof(LogNetworkTraffic)] = false, [nameof(RewriteMods)] = true, - [nameof(UseRawImageLoading)] = true, - [nameof(UseCaseInsensitivePaths)] = Constants.Platform is Platform.Android or Platform.Linux + [nameof(UseCaseInsensitivePaths)] = Constants.Platform is Platform.Android or Platform.Linux, + [nameof(SuppressHarmonyDebugMode)] = true }; /// <summary>The default values for <see cref="SuppressUpdateChecks"/>, to log changes if different.</summary> @@ -48,6 +49,9 @@ namespace StardewModdingAPI.Framework.Models /// <summary>Whether to check for newer versions of SMAPI and mods on startup.</summary> public bool CheckForUpdates { get; set; } + /// <summary>Whether SMAPI should listen for console input to support console commands.</summary> + public bool ListenForConsoleInput { get; set; } + /// <summary>Whether to add a section to the 'mod issues' list for mods which which directly use potentially sensitive .NET APIs like file or shell access.</summary> public bool ParanoidWarnings { get; set; } @@ -67,9 +71,6 @@ namespace StardewModdingAPI.Framework.Models /// <summary>Whether SMAPI should rewrite mods for compatibility.</summary> public bool RewriteMods { get; set; } - /// <summary>Whether to use raw image data when possible, instead of initializing an XNA Texture2D instance through the GPU.</summary> - public bool UseRawImageLoading { get; set; } - /// <summary>Whether to make SMAPI file APIs case-insensitive, even on Linux.</summary> public bool UseCaseInsensitivePaths { get; set; } @@ -79,42 +80,57 @@ namespace StardewModdingAPI.Framework.Models /// <summary>The colors to use for text written to the SMAPI console.</summary> public ColorSchemeConfig ConsoleColors { get; set; } + /// <summary>Whether to prevent mods from enabling Harmony's debug mode, which impacts performance and creates a file on your desktop. Debug mode should never be enabled by a released mod.</summary> + public bool SuppressHarmonyDebugMode { get; set; } + /// <summary>The mod IDs SMAPI should ignore when performing update checks or validating update keys.</summary> public HashSet<string> SuppressUpdateChecks { get; set; } + /// <summary>The mod IDs SMAPI should load before any other mods (except those needed to load them).</summary> + public HashSet<string> ModsToLoadEarly { get; set; } + + /// <summary>The mod IDs SMAPI should load after any other mods.</summary> + public HashSet<string> ModsToLoadLate { get; set; } + /******** ** Public methods ********/ /// <summary>Construct an instance.</summary> - /// <param name="developerMode">Whether to enable development features.</param> - /// <param name="checkForUpdates">Whether to check for newer versions of SMAPI and mods on startup.</param> - /// <param name="paranoidWarnings">Whether to add a section to the 'mod issues' list for mods which which directly use potentially sensitive .NET APIs like file or shell access.</param> - /// <param name="useBetaChannel">Whether to show beta versions as valid updates.</param> - /// <param name="gitHubProjectName">SMAPI's GitHub project name, used to perform update checks.</param> - /// <param name="webApiBaseUrl">The base URL for SMAPI's web API, used to perform update checks.</param> - /// <param name="verboseLogging">The log contexts for which to enable verbose logging, which may show a lot more information to simplify troubleshooting.</param> - /// <param name="rewriteMods">Whether SMAPI should rewrite mods for compatibility.</param> - /// <param name="useRawImageLoading">Whether to use raw image data when possible, instead of initializing an XNA Texture2D instance through the GPU.</param> - /// <param name="useCaseInsensitivePaths">>Whether to make SMAPI file APIs case-insensitive, even on Linux.</param> - /// <param name="logNetworkTraffic">Whether SMAPI should log network traffic.</param> - /// <param name="consoleColors">The colors to use for text written to the SMAPI console.</param> - /// <param name="suppressUpdateChecks">The mod IDs SMAPI should ignore when performing update checks or validating update keys.</param> - public SConfig(bool developerMode, bool? checkForUpdates, bool? paranoidWarnings, bool? useBetaChannel, string gitHubProjectName, string webApiBaseUrl, string[]? verboseLogging, bool? rewriteMods, bool? useRawImageLoading, bool? useCaseInsensitivePaths, bool? logNetworkTraffic, ColorSchemeConfig consoleColors, string[]? suppressUpdateChecks) + /// <param name="developerMode"><inheritdoc cref="DeveloperMode" path="/summary" /></param> + /// <param name="checkForUpdates"><inheritdoc cref="CheckForUpdates" path="/summary" /></param> + /// <param name="listenForConsoleInput"><inheritdoc cref="ListenForConsoleInput" path="/summary" /></param> + /// <param name="paranoidWarnings"><inheritdoc cref="ParanoidWarnings" path="/summary" /></param> + /// <param name="useBetaChannel"><inheritdoc cref="UseBetaChannel" path="/summary" /></param> + /// <param name="gitHubProjectName"><inheritdoc cref="GitHubProjectName" path="/summary" /></param> + /// <param name="webApiBaseUrl"><inheritdoc cref="WebApiBaseUrl" path="/summary" /></param> + /// <param name="verboseLogging"><inheritdoc cref="VerboseLogging" path="/summary" /></param> + /// <param name="rewriteMods"><inheritdoc cref="RewriteMods" path="/summary" /></param> + /// <param name="useCaseInsensitivePaths"><inheritdoc cref="UseCaseInsensitivePaths" path="/summary" /></param> + /// <param name="logNetworkTraffic"><inheritdoc cref="LogNetworkTraffic" path="/summary" /></param> + /// <param name="consoleColors"><inheritdoc cref="ConsoleColors" path="/summary" /></param> + /// <param name="suppressHarmonyDebugMode"><inheritdoc cref="SuppressHarmonyDebugMode" path="/summary" /></param> + /// <param name="suppressUpdateChecks"><inheritdoc cref="SuppressUpdateChecks" path="/summary" /></param> + /// <param name="modsToLoadEarly"><inheritdoc cref="ModsToLoadEarly" path="/summary" /></param> + /// <param name="modsToLoadLate"><inheritdoc cref="ModsToLoadLate" path="/summary" /></param> + public SConfig(bool developerMode, bool? checkForUpdates, bool? listenForConsoleInput, bool? paranoidWarnings, bool? useBetaChannel, string gitHubProjectName, string webApiBaseUrl, string[]? verboseLogging, bool? rewriteMods, bool? useCaseInsensitivePaths, bool? logNetworkTraffic, ColorSchemeConfig consoleColors, bool? suppressHarmonyDebugMode, string[]? suppressUpdateChecks, string[]? modsToLoadEarly, string[]? modsToLoadLate) { this.DeveloperMode = developerMode; this.CheckForUpdates = checkForUpdates ?? (bool)SConfig.DefaultValues[nameof(this.CheckForUpdates)]; + this.ListenForConsoleInput = listenForConsoleInput ?? (bool)SConfig.DefaultValues[nameof(this.ListenForConsoleInput)]; this.ParanoidWarnings = paranoidWarnings ?? (bool)SConfig.DefaultValues[nameof(this.ParanoidWarnings)]; this.UseBetaChannel = useBetaChannel ?? (bool)SConfig.DefaultValues[nameof(this.UseBetaChannel)]; this.GitHubProjectName = gitHubProjectName; this.WebApiBaseUrl = webApiBaseUrl; this.VerboseLogging = new HashSet<string>(verboseLogging ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase); this.RewriteMods = rewriteMods ?? (bool)SConfig.DefaultValues[nameof(this.RewriteMods)]; - this.UseRawImageLoading = useRawImageLoading ?? (bool)SConfig.DefaultValues[nameof(this.UseRawImageLoading)]; this.UseCaseInsensitivePaths = useCaseInsensitivePaths ?? (bool)SConfig.DefaultValues[nameof(this.UseCaseInsensitivePaths)]; this.LogNetworkTraffic = logNetworkTraffic ?? (bool)SConfig.DefaultValues[nameof(this.LogNetworkTraffic)]; this.ConsoleColors = consoleColors; + this.SuppressHarmonyDebugMode = suppressHarmonyDebugMode ?? (bool)SConfig.DefaultValues[nameof(this.SuppressHarmonyDebugMode)]; this.SuppressUpdateChecks = new HashSet<string>(suppressUpdateChecks ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase); + this.ModsToLoadEarly = new HashSet<string>(modsToLoadEarly ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase); + this.ModsToLoadLate = new HashSet<string>(modsToLoadLate ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase); } /// <summary>Override the value of <see cref="DeveloperMode"/>.</summary> @@ -136,6 +152,12 @@ namespace StardewModdingAPI.Framework.Models custom[name] = value; } + if (this.ModsToLoadEarly.Any()) + custom[nameof(this.ModsToLoadEarly)] = $"[{string.Join(", ", this.ModsToLoadEarly)}]"; + + if (this.ModsToLoadLate.Any()) + custom[nameof(this.ModsToLoadLate)] = $"[{string.Join(", ", this.ModsToLoadLate)}]"; + if (!this.SuppressUpdateChecks.SetEquals(SConfig.DefaultSuppressUpdateChecks)) custom[nameof(this.SuppressUpdateChecks)] = $"[{string.Join(", ", this.SuppressUpdateChecks)}]"; diff --git a/src/SMAPI/Framework/Monitor.cs b/src/SMAPI/Framework/Monitor.cs index 6b53daff..4ed2c9bb 100644 --- a/src/SMAPI/Framework/Monitor.cs +++ b/src/SMAPI/Framework/Monitor.cs @@ -25,10 +25,13 @@ namespace StardewModdingAPI.Framework private readonly LogFileManager LogFile; /// <summary>The maximum length of the <see cref="LogLevel"/> values.</summary> - private static readonly int MaxLevelLength = (from level in Enum.GetValues(typeof(LogLevel)).Cast<LogLevel>() select level.ToString().Length).Max(); + private static readonly int MaxLevelLength = Enum.GetValues<LogLevel>().Max(level => level.ToString().Length); + + /// <summary>The cached representation for each level when added to a log header.</summary> + private static readonly Dictionary<ConsoleLogLevel, string> LogStrings = Enum.GetValues<ConsoleLogLevel>().ToDictionary(level => level, level => level.ToString().ToUpper().PadRight(Monitor.MaxLevelLength)); /// <summary>A cache of messages that should only be logged once.</summary> - private readonly HashSet<string> LogOnceCache = new(); + private readonly HashSet<LogOnceCacheKey> LogOnceCache = new(); /// <summary>Get the screen ID that should be logged to distinguish between players in split-screen mode, if any.</summary> private readonly Func<int?> GetScreenIdForLog; @@ -84,7 +87,7 @@ namespace StardewModdingAPI.Framework /// <inheritdoc /> public void LogOnce(string message, LogLevel level = LogLevel.Trace) { - if (this.LogOnceCache.Add($"{message}|{level}")) + if (this.LogOnceCache.Add(new LogOnceCacheKey(message, level))) this.LogImpl(this.Source, message, (ConsoleLogLevel)level); } @@ -147,7 +150,7 @@ namespace StardewModdingAPI.Framework /// <param name="level">The log level.</param> private string GenerateMessagePrefix(string source, ConsoleLogLevel level) { - string levelStr = level.ToString().ToUpper().PadRight(Monitor.MaxLevelLength); + string levelStr = Monitor.LogStrings[level]; int? playerIndex = this.GetScreenIdForLog(); return $"[{DateTime.Now:HH:mm:ss} {levelStr}{(playerIndex != null ? $" screen_{playerIndex}" : "")} {source}]"; diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index 16ff2537..abba7f3b 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -258,7 +258,11 @@ namespace StardewModdingAPI.Framework monitor: this.Monitor, reflection: this.Reflection, eventManager: this.EventManager, - modHooks: new SModHooks(this.OnNewDayAfterFade, this.Monitor), + modHooks: new SModHooks( + parent: new ModHooks(), + beforeNewDayAfterFade: this.OnNewDayAfterFade, + monitor: this.Monitor + ), multiplayer: this.Multiplayer, exitGameImmediately: this.ExitGameImmediately, @@ -423,8 +427,29 @@ namespace StardewModdingAPI.Framework this.Monitor.Log($" Skipped {mod.GetRelativePathWithRoot()} (folder name starts with a dot)."); mods = mods.Where(p => !p.IsIgnored).ToArray(); - // load mods + // validate manifests resolver.ValidateManifests(mods, Constants.ApiVersion, toolkit.GetUpdateUrl, getFileLookup: this.GetFileLookup); + + // apply load order customizations + if (this.Settings.ModsToLoadEarly.Any() || this.Settings.ModsToLoadLate.Any()) + { + HashSet<string> installedIds = new HashSet<string>(mods.Select(p => p.Manifest?.UniqueID).Where(p => p is not null), StringComparer.OrdinalIgnoreCase); + + string[] missingEarlyMods = this.Settings.ModsToLoadEarly.Where(id => !installedIds.Contains(id)).OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToArray(); + string[] missingLateMods = this.Settings.ModsToLoadLate.Where(id => !installedIds.Contains(id)).OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToArray(); + string[] duplicateMods = this.Settings.ModsToLoadLate.Where(id => this.Settings.ModsToLoadEarly.Contains(id)).OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToArray(); + + if (missingEarlyMods.Any()) + this.Monitor.Log($" The 'smapi-internal/config.json' file lists mod IDs in {nameof(this.Settings.ModsToLoadEarly)} which aren't installed: '{string.Join("', '", missingEarlyMods)}'.", LogLevel.Warn); + if (missingLateMods.Any()) + this.Monitor.Log($" The 'smapi-internal/config.json' file lists mod IDs in {nameof(this.Settings.ModsToLoadLate)} which aren't installed: '{string.Join("', '", missingLateMods)}'.", LogLevel.Warn); + if (duplicateMods.Any()) + this.Monitor.Log($" The 'smapi-internal/config.json' file lists mod IDs which are in both {nameof(this.Settings.ModsToLoadEarly)} and {nameof(this.Settings.ModsToLoadLate)}: '{string.Join("', '", duplicateMods)}'. These will be loaded early.", LogLevel.Warn); + + mods = resolver.ApplyLoadOrderOverrides(mods, this.Settings.ModsToLoadEarly, this.Settings.ModsToLoadLate); + } + + // load mods mods = resolver.ProcessDependencies(mods, modDatabase).ToArray(); this.LoadMods(mods, this.Toolkit.JsonHelper, this.ContentCore, modDatabase); @@ -447,14 +472,17 @@ namespace StardewModdingAPI.Framework this.Monitor.Log("SMAPI found problems in your game's content files which are likely to cause errors or crashes. Consider uninstalling XNB mods or reinstalling the game.", LogLevel.Error); // start SMAPI console - new Thread( - () => this.LogManager.RunConsoleInputLoop( - commandManager: this.CommandManager, - reloadTranslations: this.ReloadTranslations, - handleInput: input => this.RawCommandQueue.Add(input), - continueWhile: () => this.IsGameRunning && !this.IsExiting - ) - ).Start(); + if (this.Settings.ListenForConsoleInput) + { + new Thread( + () => this.LogManager.RunConsoleInputLoop( + commandManager: this.CommandManager, + reloadTranslations: this.ReloadTranslations, + handleInput: input => this.RawCommandQueue.Add(input), + continueWhile: () => this.IsGameRunning && !this.IsExiting + ) + ).Start(); + } } /// <summary>Raised after an instance finishes loading its initial content.</summary> @@ -501,6 +529,15 @@ namespace StardewModdingAPI.Framework return; } + /********* + ** Prevent Harmony debug mode + *********/ + if (HarmonyLib.Harmony.DEBUG && this.Settings.SuppressHarmonyDebugMode) + { + HarmonyLib.Harmony.DEBUG = false; + this.Monitor.LogOnce("A mod enabled Harmony debug mode, which impacts performance and creates a file on your desktop. SMAPI will try to keep it disabled. (You can allow debug mode by editing the smapi-internal/config.json file.)", LogLevel.Warn); + } + #if SMAPI_DEPRECATED /********* ** Reload assets when interceptors are added/removed @@ -1318,14 +1355,14 @@ namespace StardewModdingAPI.Framework rootDirectory: rootDirectory, currentCulture: Thread.CurrentThread.CurrentUICulture, monitor: this.Monitor, + multiplayer: this.Multiplayer, reflection: this.Reflection, jsonHelper: this.Toolkit.JsonHelper, onLoadingFirstAsset: this.InitializeBeforeFirstAssetLoaded, onAssetLoaded: this.OnAssetLoaded, onAssetsInvalidated: this.OnAssetsInvalidated, getFileLookup: this.GetFileLookup, - requestAssetOperations: this.RequestAssetOperations, - useRawImageLoading: this.Settings.UseRawImageLoading + requestAssetOperations: this.RequestAssetOperations ); if (this.ContentCore.Language != this.Translator.LocaleEnum) this.Translator.SetLocale(this.ContentCore.GetLocale(), this.ContentCore.Language); @@ -1384,7 +1421,7 @@ namespace StardewModdingAPI.Framework } // check min length for specific types - switch (fields[SObject.objectInfoTypeIndex].Split(new[] { ' ' }, 2)[0]) + switch (fields[SObject.objectInfoTypeIndex].Split(' ', 2)[0]) { case "Cooking": if (fields.Length < SObject.objectInfoBuffDurationIndex + 1) @@ -1672,45 +1709,52 @@ namespace StardewModdingAPI.Framework // initialize translations this.ReloadTranslations(loaded); -#if SMAPI_DEPRECATED // set temporary PyTK compatibility mode // This is part of a three-part fix for PyTK 1.23.* and earlier. When removing this, // search 'Platonymous.Toolkit' to find the other part in SMAPI and Content Patcher. { IModInfo? pyTk = this.ModRegistry.Get("Platonymous.Toolkit"); - ModContentManager.EnablePyTkLegacyMode = pyTk is not null && pyTk.Manifest.Version.IsOlderThan("1.24.0"); - } + if (pyTk is not null && pyTk.Manifest.Version.IsOlderThan("1.24.0")) +#if SMAPI_DEPRECATED + ModContentManager.EnablePyTkLegacyMode = true; +#else + this.Monitor.Log("PyTK's image scaling is not compatible with SMAPI strict mode.", LogLevel.Warn); #endif + } // initialize loaded non-content-pack mods this.Monitor.Log("Launching mods...", LogLevel.Debug); foreach (IModMetadata metadata in loadedMods) { + IMod mod = + metadata.Mod + ?? throw new InvalidOperationException($"The '{metadata.DisplayName}' mod is not initialized correctly."); // should never happen, but avoids nullability warnings + #if SMAPI_DEPRECATED // add interceptors - if (metadata.Mod?.Helper is ModHelper helper) + if (mod.Helper is ModHelper helper) { // ReSharper disable SuspiciousTypeConversion.Global - if (metadata.Mod is IAssetEditor editor) + if (mod is IAssetEditor editor) { SCore.DeprecationManager.Warn( source: metadata, nounPhrase: $"{nameof(IAssetEditor)}", version: "3.14.0", - severity: DeprecationLevel.Info, + severity: DeprecationLevel.PendingRemoval, logStackTrace: false ); this.ContentCore.Editors.Add(new ModLinked<IAssetEditor>(metadata, editor)); } - if (metadata.Mod is IAssetLoader loader) + if (mod is IAssetLoader loader) { SCore.DeprecationManager.Warn( source: metadata, nounPhrase: $"{nameof(IAssetLoader)}", version: "3.14.0", - severity: DeprecationLevel.Info, + severity: DeprecationLevel.PendingRemoval, logStackTrace: false ); @@ -1742,42 +1786,48 @@ namespace StardewModdingAPI.Framework metadata, $"using {name} without bundling it", "3.14.7", - DeprecationLevel.Info, + DeprecationLevel.PendingRemoval, logStackTrace: false ); } } #endif - // call entry method + // initialize mod Context.HeuristicModsRunningCode.Push(metadata); - try - { - IMod mod = metadata.Mod!; - mod.Entry(mod.Helper!); - } - catch (Exception ex) { - metadata.LogAsMod($"Mod crashed on entry and might not work correctly. Technical details:\n{ex.GetLogSummary()}", LogLevel.Error); - } + // call entry method + try + { + mod.Entry(mod.Helper); + } + catch (Exception ex) + { + metadata.LogAsMod($"Mod crashed on entry and might not work correctly. Technical details:\n{ex.GetLogSummary()}", LogLevel.Error); + } - // get mod API - try - { - object? api = metadata.Mod!.GetApi(); - if (api != null && !api.GetType().IsPublic) + // get mod API + try + { + object? api = mod.GetApi(); + if (api != null && !api.GetType().IsPublic) + { + api = null; + this.Monitor.Log($"{metadata.DisplayName} provides an API instance with a non-public type. This isn't currently supported, so the API won't be available to other mods.", LogLevel.Warn); + } + + if (api != null) + this.Monitor.Log($" Found mod-provided API ({api.GetType().FullName})."); + metadata.SetApi(api); + } + catch (Exception ex) { - api = null; - this.Monitor.Log($"{metadata.DisplayName} provides an API instance with a non-public type. This isn't currently supported, so the API won't be available to other mods.", LogLevel.Warn); + this.Monitor.Log($"Failed loading mod-provided API for {metadata.DisplayName}. Integrations with other mods may not work. Error: {ex.GetLogSummary()}", LogLevel.Error); } - if (api != null) - this.Monitor.Log($" Found mod-provided API ({api.GetType().FullName})."); - metadata.SetApi(api); - } - catch (Exception ex) - { - this.Monitor.Log($"Failed loading mod-provided API for {metadata.DisplayName}. Integrations with other mods may not work. Error: {ex.GetLogSummary()}", LogLevel.Error); + // validate mod doesn't implement both GetApi() and GetApi(mod) + if (metadata.Api != null && mod.GetType().GetMethod(nameof(Mod.GetApi), new[] { typeof(IModInfo) })!.DeclaringType != typeof(Mod)) + metadata.LogAsMod($"Mod implements both {nameof(Mod.GetApi)}() and {nameof(Mod.GetApi)}({nameof(IModInfo)}), which isn't allowed. The latter will be ignored.", LogLevel.Error); } Context.HeuristicModsRunningCode.TryPop(out _); } diff --git a/src/SMAPI/Framework/SModHooks.cs b/src/SMAPI/Framework/SModHooks.cs index a7736c8b..ac4f242c 100644 --- a/src/SMAPI/Framework/SModHooks.cs +++ b/src/SMAPI/Framework/SModHooks.cs @@ -1,11 +1,12 @@ using System; using System.Threading.Tasks; +using StardewModdingAPI.Utilities; using StardewValley; namespace StardewModdingAPI.Framework { /// <summary>Invokes callbacks for mod hooks provided by the game.</summary> - internal class SModHooks : ModHooks + internal class SModHooks : DelegatingModHooks { /********* ** Fields @@ -21,25 +22,24 @@ namespace StardewModdingAPI.Framework ** Public methods *********/ /// <summary>Construct an instance.</summary> + /// <param name="parent">The underlying hooks to call by default.</param> /// <param name="beforeNewDayAfterFade">A callback to invoke before <see cref="Game1.newDayAfterFade"/> runs.</param> /// <param name="monitor">Writes messages to the console.</param> - public SModHooks(Action beforeNewDayAfterFade, IMonitor monitor) + public SModHooks(ModHooks parent, Action beforeNewDayAfterFade, IMonitor monitor) + : base(parent) { this.BeforeNewDayAfterFade = beforeNewDayAfterFade; this.Monitor = monitor; } - /// <summary>A hook invoked when <see cref="Game1.newDayAfterFade"/> is called.</summary> - /// <param name="action">The vanilla <see cref="Game1.newDayAfterFade"/> logic.</param> + /// <inheritdoc /> public override void OnGame1_NewDayAfterFade(Action action) { this.BeforeNewDayAfterFade(); action(); } - /// <summary>Start an asynchronous task for the game.</summary> - /// <param name="task">The task to start.</param> - /// <param name="id">A unique key which identifies the task.</param> + /// <inheritdoc /> public override Task StartTask(Task task, string id) { this.Monitor.Log($"Synchronizing '{id}' task..."); @@ -48,9 +48,7 @@ namespace StardewModdingAPI.Framework return task; } - /// <summary>Start an asynchronous task for the game.</summary> - /// <param name="task">The task to start.</param> - /// <param name="id">A unique key which identifies the task.</param> + /// <inheritdoc /> public override Task<T> StartTask<T>(Task<T> task, string id) { this.Monitor.Log($"Synchronizing '{id}' task..."); diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs index 0b13434a..b24f4178 100644 --- a/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs +++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs @@ -63,7 +63,7 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers { if (this.LastValues.Count > 0) { - this.AddedImpl.AddRange(this.LastValues); + this.RemovedImpl.AddRange(this.LastValues); this.LastValues.Clear(); } return; diff --git a/src/SMAPI/IMod.cs b/src/SMAPI/IMod.cs index b81ba0e3..87c9880c 100644 --- a/src/SMAPI/IMod.cs +++ b/src/SMAPI/IMod.cs @@ -23,7 +23,15 @@ namespace StardewModdingAPI /// <param name="helper">Provides simplified APIs for writing mods.</param> void Entry(IModHelper helper); - /// <summary>Get an API that other mods can access. This is always called after <see cref="Entry"/>.</summary> + /// <summary>Get an <a href="https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Integrations">API that other mods can access</a>. This is always called after <see cref="Entry"/>, and is only called once even if multiple mods access it.</summary> + /// <remarks>You can implement <see cref="GetApi()"/> to provide one instance to all mods, or <see cref="GetApi(IModInfo)"/> to provide a separate instance per mod. These are mutually exclusive, so you can only implement one of them.</remarks> + /// <remarks>Returns the API instance, or <c>null</c> if the mod has no API.</remarks> object? GetApi(); + + /// <summary>Get an <a href="https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Integrations">API that other mods can access</a>. This is always called after <see cref="Entry"/>, and is called once per mod that accesses the API (even if they access it multiple times).</summary> + /// <param name="mod">The mod accessing the API.</param> + /// <remarks>Returns the API instance, or <c>null</c> if the mod has no API. Note that <paramref name="mod"/> is provided for informational purposes only, and that denying API access to specific mods is strongly discouraged and may be considered abusive.</remarks> + /// <inheritdoc cref="GetApi()" include="/Remarks" /> + object? GetApi(IModInfo mod); } } diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs index 1ef9a8f2..97ae32e4 100644 --- a/src/SMAPI/Metadata/CoreAssetPropagator.cs +++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs @@ -40,6 +40,9 @@ namespace StardewModdingAPI.Metadata /// <summary>Writes messages to the console.</summary> private readonly IMonitor Monitor; + /// <summary>The multiplayer instance whose map cache to update.</summary> + private readonly Multiplayer Multiplayer; + /// <summary>Simplifies access to private game code.</summary> private readonly Reflector Reflection; @@ -70,13 +73,15 @@ namespace StardewModdingAPI.Metadata /// <param name="mainContent">The main content manager through which to reload assets.</param> /// <param name="disposableContent">An internal content manager used only for asset propagation.</param> /// <param name="monitor">Writes messages to the console.</param> + /// <param name="multiplayer">The multiplayer instance whose map cache to update.</param> /// <param name="reflection">Simplifies access to private code.</param> /// <param name="parseAssetName">Parse a raw asset name.</param> - public CoreAssetPropagator(LocalizedContentManager mainContent, GameContentManagerForAssetPropagation disposableContent, IMonitor monitor, Reflector reflection, Func<string, IAssetName> parseAssetName) + public CoreAssetPropagator(LocalizedContentManager mainContent, GameContentManagerForAssetPropagation disposableContent, IMonitor monitor, Multiplayer multiplayer, Reflector reflection, Func<string, IAssetName> parseAssetName) { this.MainContentManager = mainContent; this.DisposableContentManager = disposableContent; this.Monitor = monitor; + this.Multiplayer = multiplayer; this.Reflection = reflection; this.ParseAssetName = parseAssetName; } @@ -1166,6 +1171,9 @@ namespace StardewModdingAPI.Metadata GameLocation location = locationInfo.Location; Vector2? playerPos = Game1.player?.Position; + // remove from multiplayer cache + this.Multiplayer.cachedMultiplayerMaps.Remove(location.NameOrUniqueName); + // reload map location.interiorDoors.Clear(); // prevent errors when doors try to update tiles which no longer exist location.reloadMap(); diff --git a/src/SMAPI/Mod.cs b/src/SMAPI/Mod.cs index f764752b..01157886 100644 --- a/src/SMAPI/Mod.cs +++ b/src/SMAPI/Mod.cs @@ -30,6 +30,12 @@ namespace StardewModdingAPI return null; } + /// <inheritdoc /> + public virtual object? GetApi(IModInfo mod) + { + return null; + } + /// <summary>Release or reset unmanaged resources.</summary> public void Dispose() { diff --git a/src/SMAPI/SMAPI.config.json b/src/SMAPI/SMAPI.config.json index 97e8e00c..0d00db4d 100644 --- a/src/SMAPI/SMAPI.config.json +++ b/src/SMAPI/SMAPI.config.json @@ -42,6 +42,12 @@ copy all the settings, or you may cause bugs due to overridden changes in future "DeveloperMode": true, /** + * Whether SMAPI should listen for console input. Disabling this will prevent you from using + * console commands. On some specific Linux systems, disabling this may reduce CPU usage. + */ + "ListenForConsoleInput": true, + + /** * Whether SMAPI should rewrite mods for compatibility. This may prevent older mods from * loading, but bypasses a Visual Studio crash when debugging. */ @@ -55,18 +61,6 @@ copy all the settings, or you may cause bugs due to overridden changes in future "UseCaseInsensitivePaths": null, /** - * Whether to use the experimental Pintail API proxying library, instead of the original - * proxying built into SMAPI itself. - */ - "UsePintail": true, - - /** - * Whether to use raw image data when possible, instead of initializing an XNA Texture2D - * instance through the GPU. - */ - "UseRawImageLoading": true, - - /** * Whether to add a section to the 'mod issues' list for mods which directly use potentially * sensitive .NET APIs like file or shell access. Note that many mods do this legitimately as * part of their normal functionality, so these warnings are meaningless without further @@ -139,11 +133,31 @@ copy all the settings, or you may cause bugs due to overridden changes in future }, /** + * Whether to prevent mods from enabling Harmony's debug mode, which impacts performance and + * creates a file on your desktop. Debug mode should never be enabled by a released mod. + * + * If you actually need debug mode to test your own mod, set this to false. + */ + "SuppressHarmonyDebugMode": true, + + /** * The mod IDs SMAPI should ignore when performing update checks or validating update keys. */ "SuppressUpdateChecks": [ "SMAPI.ConsoleCommands", "SMAPI.ErrorHandler", "SMAPI.SaveBackup" - ] + ], + + /** + * The mod IDs SMAPI should load before any other mods (except those needed to load them) + * or after any other mods. + * + * This lets you manually fix the load order if needed, but this is a last resort — SMAPI + * automatically adjusts the load order based on mods' dependencies, so needing to manually + * edit the order is usually a problem with one or both mods' metadata that can be reported to + * the mod author. + */ + "ModsToLoadEarly": [], + "ModsToLoadLate": [] } diff --git a/src/SMAPI/SMAPI.csproj b/src/SMAPI/SMAPI.csproj index 36db0545..530b75aa 100644 --- a/src/SMAPI/SMAPI.csproj +++ b/src/SMAPI/SMAPI.csproj @@ -8,7 +8,6 @@ <OutputType>Exe</OutputType> <GenerateDocumentationFile>true</GenerateDocumentationFile> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> - <LargeAddressAware Condition="'$(OS)' == 'Windows_NT'">true</LargeAddressAware> <ApplicationIcon>icon.ico</ApplicationIcon> <!--copy dependency DLLs to bin folder so we can include them in installer bundle --> @@ -22,12 +21,11 @@ <Import Project="..\..\build\common.targets" /> <ItemGroup> - <PackageReference Include="LargeAddressAware" Version="1.0.5" /> <PackageReference Include="Mono.Cecil" Version="0.11.4" /> <PackageReference Include="MonoMod.Common" Version="22.3.5.1" /> - <PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> - <PackageReference Include="Pathoschild.Http.FluentClient" Version="4.1.1" /> - <PackageReference Include="Pintail" Version="2.2.1" /> + <PackageReference Include="Newtonsoft.Json" Version="13.0.2" /> + <PackageReference Include="Pathoschild.Http.FluentClient" Version="4.3.0" /> + <PackageReference Include="Pintail" Version="2.2.2" /> <PackageReference Include="Platonymous.TMXTile" Version="1.5.9" /> <PackageReference Include="System.Reflection.Emit" Version="4.7.0" /> diff --git a/src/SMAPI/Utilities/AssetPathUtilities/AssetNamePartEnumerator.cs b/src/SMAPI/Utilities/AssetPathUtilities/AssetNamePartEnumerator.cs new file mode 100644 index 00000000..11987ed6 --- /dev/null +++ b/src/SMAPI/Utilities/AssetPathUtilities/AssetNamePartEnumerator.cs @@ -0,0 +1,70 @@ +using System; +using ToolkitPathUtilities = StardewModdingAPI.Toolkit.Utilities.PathUtilities; + +namespace StardewModdingAPI.Utilities.AssetPathUtilities +{ + /// <summary>Handles enumerating the normalized segments in an asset name.</summary> + internal ref struct AssetNamePartEnumerator + { + /********* + ** Fields + *********/ + /// <summary>The backing field for <see cref="Remainder"/>.</summary> + private ReadOnlySpan<char> RemainderImpl; + + + /********* + ** Properties + *********/ + /// <summary>The remainder of the asset name being enumerated, ignoring segments which have already been yielded.</summary> + public ReadOnlySpan<char> Remainder => this.RemainderImpl; + + /// <summary>Get the current segment.</summary> + public ReadOnlySpan<char> Current { get; private set; } = default; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="assetName">The asset name to enumerate.</param> + public AssetNamePartEnumerator(ReadOnlySpan<char> assetName) + { + this.RemainderImpl = AssetNamePartEnumerator.TrimLeadingPathSeparators(assetName); + } + + /// <summary>Move the enumerator to the next segment.</summary> + /// <returns>Returns true if a new value was found (accessible via <see cref="Current"/>).</returns> + public bool MoveNext() + { + if (this.RemainderImpl.Length == 0) + return false; + + int index = this.RemainderImpl.IndexOfAny(ToolkitPathUtilities.PossiblePathSeparators); + + // no more separator characters found, I'm done. + if (index < 0) + { + this.Current = this.RemainderImpl; + this.RemainderImpl = ReadOnlySpan<char>.Empty; + return true; + } + + // Yield the next separate character bit + this.Current = this.RemainderImpl[..index]; + this.RemainderImpl = AssetNamePartEnumerator.TrimLeadingPathSeparators(this.RemainderImpl[(index + 1)..]); + return true; + } + + + /********* + ** Private methods + *********/ + /// <summary>Trim path separators at the start of the given path or segment.</summary> + /// <param name="span">The path or segment to trim.</param> + private static ReadOnlySpan<char> TrimLeadingPathSeparators(ReadOnlySpan<char> span) + { + return span.TrimStart(new ReadOnlySpan<char>(ToolkitPathUtilities.PossiblePathSeparators)); + } + } +} diff --git a/src/SMAPI/Utilities/DelegatingModHooks.cs b/src/SMAPI/Utilities/DelegatingModHooks.cs new file mode 100644 index 00000000..3ebcf997 --- /dev/null +++ b/src/SMAPI/Utilities/DelegatingModHooks.cs @@ -0,0 +1,137 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Xna.Framework.Input; +using StardewModdingAPI.Events; +using StardewModdingAPI.Framework; +using StardewValley; +using StardewValley.Events; + +namespace StardewModdingAPI.Utilities +{ + /// <summary>An implementation of <see cref="ModHooks"/> which automatically calls the parent instance for any method that's not overridden.</summary> + /// <remarks>The mod hooks are primarily meant for SMAPI to use. Using this directly in mods is a last resort, since it's very easy to break SMAPI this way. This class requires that SMAPI is present in the parent chain.</remarks> + public class DelegatingModHooks : ModHooks + { + /********* + ** Accessors + *********/ + /// <summary>The underlying instance to delegate to by default.</summary> + public ModHooks Parent { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="modHooks">The underlying instance to delegate to by default.</param> + public DelegatingModHooks(ModHooks modHooks) + { + this.AssertSmapiInChain(modHooks); + + this.Parent = modHooks; + } + + /// <summary>Raised before the in-game clock changes.</summary> + /// <param name="action">Run the vanilla update logic.</param> + /// <remarks>In mods, consider using <see cref="IGameLoopEvents.TimeChanged"/> instead.</remarks> + public override void OnGame1_PerformTenMinuteClockUpdate(Action action) + { + this.Parent.OnGame1_PerformTenMinuteClockUpdate(action); + } + + /// <summary>Raised before initializing the new day and saving.</summary> + /// <param name="action">Run the vanilla update logic.</param> + /// <remarks>In mods, consider using <see cref="IGameLoopEvents.DayEnding"/> or <see cref="IGameLoopEvents.Saving"/> instead.</remarks> + public override void OnGame1_NewDayAfterFade(Action action) + { + this.Parent.OnGame1_NewDayAfterFade(action); + } + + /// <summary>Raised before showing the end-of-day menus (e.g. shipping menus, level-up screen, etc).</summary> + /// <param name="action">Run the vanilla update logic.</param> + public override void OnGame1_ShowEndOfNightStuff(Action action) + { + this.Parent.OnGame1_ShowEndOfNightStuff(action); + } + + /// <summary>Raised before updating the gamepad, mouse, and keyboard input state.</summary> + /// <param name="keyboardState">The keyboard state.</param> + /// <param name="mouseState">The mouse state.</param> + /// <param name="gamePadState">The gamepad state.</param> + /// <param name="action">Run the vanilla update logic.</param> + /// <remarks>In mods, consider using <see cref="IInputEvents"/> instead.</remarks> + public override void OnGame1_UpdateControlInput(ref KeyboardState keyboardState, ref MouseState mouseState, ref GamePadState gamePadState, Action action) + { + this.Parent.OnGame1_UpdateControlInput(ref keyboardState, ref mouseState, ref gamePadState, action); + } + + /// <summary>Raised before a location is updated for the local player entering it.</summary> + /// <param name="location">The location that will be updated.</param> + /// <param name="action">Run the vanilla update logic.</param> + /// <remarks>In mods, consider using <see cref="IPlayerEvents.Warped"/> instead.</remarks> + public override void OnGameLocation_ResetForPlayerEntry(GameLocation location, Action action) + { + this.Parent.OnGameLocation_ResetForPlayerEntry(location, action); + } + + /// <summary>Raised before the game checks for an action to trigger for a player interaction with a tile.</summary> + /// <param name="location">The location being checked.</param> + /// <param name="tileLocation">The tile position being checked.</param> + /// <param name="viewport">The game's current position and size within the map, measured in pixels.</param> + /// <param name="who">The player interacting with the tile.</param> + /// <param name="action">Run the vanilla update logic.</param> + /// <returns>Returns whether the interaction was handled.</returns> + public override bool OnGameLocation_CheckAction(GameLocation location, xTile.Dimensions.Location tileLocation, xTile.Dimensions.Rectangle viewport, Farmer who, Func<bool> action) + { + return this.Parent.OnGameLocation_CheckAction(location, tileLocation, viewport, who, action); + } + + /// <summary>Raised before the game picks a night event to show on the farm after the player sleeps.</summary> + /// <param name="action">Run the vanilla update logic.</param> + /// <returns>Returns the selected farm event.</returns> + public override FarmEvent OnUtility_PickFarmEvent(Func<FarmEvent> action) + { + return this.Parent.OnUtility_PickFarmEvent(action); + } + + /// <summary>Start an asynchronous task for the game.</summary> + /// <param name="task">The task to start.</param> + /// <param name="id">A unique key which identifies the task.</param> + public override Task StartTask(Task task, string id) + { + return this.Parent.StartTask(task, id); + } + + /// <summary>Start an asynchronous task for the game.</summary> + /// <typeparam name="T">The type returned by the task when it completes.</typeparam> + /// <param name="task">The task to start.</param> + /// <param name="id">A unique key which identifies the task.</param> + public override Task<T> StartTask<T>(Task<T> task, string id) + { + return this.Parent.StartTask<T>(task, id); + } + + + /********* + ** Private methods + *********/ + /// <summary>Assert that SMAPI's mod hook implementation is in the inheritance chain.</summary> + /// <param name="hooks">The mod hooks to check.</param> + private void AssertSmapiInChain(ModHooks hooks) + { + // this is SMAPI + if (this is SModHooks) + return; + + // SMAPI in delegated chain + for (ModHooks? cur = hooks; cur != null; cur = (cur as DelegatingModHooks)?.Parent) + { + if (cur is SModHooks) + return; + } + + // SMAPI not found + throw new InvalidOperationException($"Can't create a {nameof(DelegatingModHooks)} instance without SMAPI's mod hooks in the parent chain."); + } + } +} diff --git a/src/SMAPI/Utilities/Keybind.cs b/src/SMAPI/Utilities/Keybind.cs index 3455ce77..3532620d 100644 --- a/src/SMAPI/Utilities/Keybind.cs +++ b/src/SMAPI/Utilities/Keybind.cs @@ -54,12 +54,12 @@ namespace StardewModdingAPI.Utilities } // parse buttons - string[] rawButtons = input.Split('+'); + string[] rawButtons = input.Split('+', StringSplitOptions.TrimEntries); SButton[] buttons = new SButton[rawButtons.Length]; List<string> rawErrors = new List<string>(); for (int i = 0; i < buttons.Length; i++) { - string rawButton = rawButtons[i].Trim(); + string rawButton = rawButtons[i]; if (string.IsNullOrWhiteSpace(rawButton)) rawErrors.Add("Invalid empty button value"); else if (!Enum.TryParse(rawButton, ignoreCase: true, out SButton button)) diff --git a/src/SMAPI/Utilities/PerScreen.cs b/src/SMAPI/Utilities/PerScreen.cs index 468df0bd..674ec760 100644 --- a/src/SMAPI/Utilities/PerScreen.cs +++ b/src/SMAPI/Utilities/PerScreen.cs @@ -59,7 +59,7 @@ namespace StardewModdingAPI.Utilities null, $"calling the {nameof(PerScreen<T>)} constructor with null", "3.14.0", - DeprecationLevel.Info + DeprecationLevel.PendingRemoval ); #else throw new ArgumentNullException(nameof(createNewState)); @@ -101,6 +101,12 @@ namespace StardewModdingAPI.Utilities this.RemoveScreens(_ => true); } + /// <summary>Get whether the current screen has a value created yet.</summary> + public bool IsActiveForScreen() + { + return this.States.ContainsKey(Context.ScreenId); + } + /********* ** Private methods diff --git a/src/SMAPI/i18n/ko.json b/src/SMAPI/i18n/ko.json index 8d267e5e..8122a9e2 100644 --- a/src/SMAPI/i18n/ko.json +++ b/src/SMAPI/i18n/ko.json @@ -1,6 +1,6 @@ { // short date format for SDate // tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2) - "generic.date": "{{season}} {{day}}", - "generic.date-with-year": "{{year}} 학년 {{season}} {{day}}" + "generic.date": "{{season}} {{day}}일", + "generic.date-with-year": "{{year}}년차 {{season}} {{day}}일" } |