Merge branch 'develop' into stable

This commit is contained in:
Jesse Plamondon-Willard 2022-10-09 20:11:34 -04:00
commit 93a748996c
No known key found for this signature in database
GPG Key ID: CF8B1456B3E29F49
47 changed files with 615 additions and 250 deletions

Binary file not shown.

View File

@ -845,7 +845,7 @@
</member> </member>
<member name="F:HarmonyLib.ExceptionBlockType.BeginExceptFilterBlock"> <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>
<member name="F:HarmonyLib.ExceptionBlockType.BeginFaultBlock"> <member name="F:HarmonyLib.ExceptionBlockType.BeginFaultBlock">
@ -2660,6 +2660,18 @@
<param name="operand">The optional operand</param> <param name="operand">The optional operand</param>
<param name="name">The optional name</param> <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>
<member name="M:HarmonyLib.CodeMatch.#ctor(HarmonyLib.CodeInstruction,System.String)"> <member name="M:HarmonyLib.CodeMatch.#ctor(HarmonyLib.CodeInstruction,System.String)">
<summary>Creates a code match</summary> <summary>Creates a code match</summary>
@ -3216,6 +3228,13 @@
<param name="e">The enum</param> <param name="e">The enum</param>
<returns>True if the instruction loads the constant</returns> <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>
<member name="M:HarmonyLib.CodeInstructionExtensions.LoadsField(HarmonyLib.CodeInstruction,System.Reflection.FieldInfo,System.Boolean)"> <member name="M:HarmonyLib.CodeInstructionExtensions.LoadsField(HarmonyLib.CodeInstruction,System.Reflection.FieldInfo,System.Boolean)">
<summary>Tests if the code instruction loads a field</summary> <summary>Tests if the code instruction loads a field</summary>
@ -3346,7 +3365,11 @@
<summary>A file log for debugging</summary> <summary>A file log for debugging</summary>
</member> </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> <summary>Full pathname of the log file, defaults to a file called <c>harmony.log.txt</c> on your Desktop</summary>
</member> </member>

View File

@ -7,7 +7,7 @@ repo. It imports the other MSBuild files as needed.
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup> <PropertyGroup>
<!--set general build properties --> <!--set general build properties -->
<Version>3.16.2</Version> <Version>3.17.0</Version>
<Product>SMAPI</Product> <Product>SMAPI</Product>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths> <AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths>

View File

@ -158,6 +158,10 @@ foreach ($folder in $folders) {
cp "$smapiBin/$name" "$bundlePath/smapi-internal" 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.config.json" "$bundlePath/smapi-internal/config.json"
cp "$smapiBin/SMAPI.metadata.json" "$bundlePath/smapi-internal/metadata.json" cp "$smapiBin/SMAPI.metadata.json" "$bundlePath/smapi-internal/metadata.json"
if ($folder -eq "linux" -or $folder -eq "macOS") { if ($folder -eq "linux" -or $folder -eq "macOS") {

View File

@ -7,6 +7,28 @@
_If needed, you can update to SMAPI 3.16.0 first and then install the latest version._ _If needed, you can update to SMAPI 3.16.0 first and then install the latest version._
--> -->
## 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 ## 3.16.2
Released 31 August 2022 for Stardew Valley 1.5.6 or later. Released 31 August 2022 for Stardew Valley 1.5.6 or later.

View File

@ -412,8 +412,12 @@ The NuGet package is generated automatically in `StardewModdingAPI.ModBuildConfi
when you compile it. when you compile it.
## Release notes ## Release notes
### Upcoming release ### 4.0.2
Released 09 October 2022.
* Switched to the newer crossplatform `portable` debug symbols (thanks to lanturnalis!). * 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 ### 4.0.1
Released 14 April 2022. Released 14 April 2022.

View File

@ -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. debugger attached, so you can intercept errors and step through the code being executed.
### Custom Harmony build ### Custom Harmony build
SMAPI uses [a custom build of Harmony](https://github.com/Pathoschild/Harmony#readme), which is SMAPI uses [a custom build of Harmony 2.2.2](https://github.com/Pathoschild/Harmony#readme), which
included in the `build` folder. To use a different build, just replace `0Harmony.dll` in that is included in the `build` folder. To use a different build, just replace `0Harmony.dll` in that
folder before compiling. folder before compiling.
## Prepare a release ## Prepare a release

View File

@ -10,7 +10,7 @@
<!--NuGet package--> <!--NuGet package-->
<PackageId>Pathoschild.Stardew.ModBuildConfig</PackageId> <PackageId>Pathoschild.Stardew.ModBuildConfig</PackageId>
<Title>Build package for SMAPI mods</Title> <Title>Build package for SMAPI mods</Title>
<Version>4.0.1</Version> <Version>4.0.2</Version>
<Authors>Pathoschild</Authors> <Authors>Pathoschild</Authors>
<Description>Automates the build configuration for crossplatform Stardew Valley SMAPI mods. For SMAPI 3.13.0 or later.</Description> <Description>Automates the build configuration for crossplatform Stardew Valley SMAPI mods. For SMAPI 3.13.0 or later.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression> <PackageLicenseExpression>MIT</PackageLicenseExpression>

View File

@ -27,8 +27,12 @@
<EnableGameDebugging Condition="'$(EnableGameDebugging)' == ''">true</EnableGameDebugging> <EnableGameDebugging Condition="'$(EnableGameDebugging)' == ''">true</EnableGameDebugging>
<BundleExtraAssemblies Condition="'$(BundleExtraAssemblies)' == ''"></BundleExtraAssemblies> <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 --> <!-- 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>
<PropertyGroup Condition="'$(OS)' == 'Windows_NT' AND '$(EnableGameDebugging)' == 'true'"> <PropertyGroup Condition="'$(OS)' == 'Windows_NT' AND '$(EnableGameDebugging)' == 'true'">
@ -44,17 +48,17 @@
**********************************************--> **********************************************-->
<ItemGroup> <ItemGroup>
<!-- game --> <!-- game -->
<Reference Include="Stardew Valley" HintPath="$(GamePath)\Stardew Valley.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="$(BundleExtraAssemblies.Contains('Game'))" /> <Reference Include="StardewValley.GameData" HintPath="$(GamePath)\StardewValley.GameData.dll" Private="$(_BundleExtraAssembliesForGame)" />
<Reference Include="MonoGame.Framework" HintPath="$(GamePath)\MonoGame.Framework.dll" Private="$(BundleExtraAssemblies.Contains('Game'))" /> <Reference Include="MonoGame.Framework" HintPath="$(GamePath)\MonoGame.Framework.dll" Private="$(_BundleExtraAssembliesForGame)" />
<Reference Include="xTile" HintPath="$(GamePath)\xTile.dll" Private="$(BundleExtraAssemblies.Contains('Game'))" /> <Reference Include="xTile" HintPath="$(GamePath)\xTile.dll" Private="$(_BundleExtraAssembliesForGame)" />
<!-- SMAPI --> <!-- SMAPI -->
<Reference Include="StardewModdingAPI" HintPath="$(GamePath)\StardewModdingAPI.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="$(BundleExtraAssemblies.Contains('Game'))" /> <Reference Include="SMAPI.Toolkit.CoreInterfaces" HintPath="$(GamePath)\smapi-internal\SMAPI.Toolkit.CoreInterfaces.dll" Private="$(_BundleExtraAssembliesForGame)" />
<!-- Harmony --> <!-- 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> </ItemGroup>

View File

@ -63,7 +63,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
/// <param name="color">The color to set.</param> /// <param name="color">The color to set.</param>
private bool TryParseColor(string input, out Color color) 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)) 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); color = new Color(r, g, b);

View File

@ -1,9 +1,9 @@
{ {
"Name": "Console Commands", "Name": "Console Commands",
"Author": "SMAPI", "Author": "SMAPI",
"Version": "3.16.2", "Version": "3.17.0",
"Description": "Adds SMAPI console commands that let you manipulate the game.", "Description": "Adds SMAPI console commands that let you manipulate the game.",
"UniqueID": "SMAPI.ConsoleCommands", "UniqueID": "SMAPI.ConsoleCommands",
"EntryDll": "ConsoleCommands.dll", "EntryDll": "ConsoleCommands.dll",
"MinimumApiVersion": "3.16.2" "MinimumApiVersion": "3.17.0"
} }

View File

@ -1,9 +1,9 @@
{ {
"Name": "Error Handler", "Name": "Error Handler",
"Author": "SMAPI", "Author": "SMAPI",
"Version": "3.16.2", "Version": "3.17.0",
"Description": "Handles some common vanilla errors to log more useful info or avoid breaking the game.", "Description": "Handles some common vanilla errors to log more useful info or avoid breaking the game.",
"UniqueID": "SMAPI.ErrorHandler", "UniqueID": "SMAPI.ErrorHandler",
"EntryDll": "ErrorHandler.dll", "EntryDll": "ErrorHandler.dll",
"MinimumApiVersion": "3.16.2" "MinimumApiVersion": "3.17.0"
} }

View File

@ -1,9 +1,9 @@
{ {
"Name": "Save Backup", "Name": "Save Backup",
"Author": "SMAPI", "Author": "SMAPI",
"Version": "3.16.2", "Version": "3.17.0",
"Description": "Automatically backs up all your saves once per day into its folder.", "Description": "Automatically backs up all your saves once per day into its folder.",
"UniqueID": "SMAPI.SaveBackup", "UniqueID": "SMAPI.SaveBackup",
"EntryDll": "SaveBackup.dll", "EntryDll": "SaveBackup.dll",
"MinimumApiVersion": "3.16.2" "MinimumApiVersion": "3.17.0"
} }

View File

@ -136,11 +136,11 @@ namespace SMAPI.Tests.Utilities
foreach (string rawPair in stateMap.Split(',')) foreach (string rawPair in stateMap.Split(','))
{ {
// parse values // 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)) 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)) 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 // get state
if (curButton == button) if (curButton == button)

View File

@ -382,11 +382,11 @@ namespace SMAPI.Tests.Utilities
{ {
// act // act
string json = JsonConvert.SerializeObject(new SemanticVersion(versionStr)); string json = JsonConvert.SerializeObject(new SemanticVersion(versionStr));
SemanticVersion after = JsonConvert.DeserializeObject<SemanticVersion>(json); SemanticVersion? after = JsonConvert.DeserializeObject<SemanticVersion>(json);
// assert // assert
Assert.IsNotNull(after, "The semantic version after deserialization is unexpectedly null."); 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.");
} }

View File

@ -9,6 +9,7 @@ using StardewModdingAPI.Toolkit.Utilities;
using System.Reflection; using System.Reflection;
#if SMAPI_FOR_WINDOWS #if SMAPI_FOR_WINDOWS
using Microsoft.Win32; using Microsoft.Win32;
using VdfParser;
#endif #endif
namespace StardewModdingAPI.Toolkit.Framework.GameScanning namespace StardewModdingAPI.Toolkit.Framework.GameScanning
@ -23,6 +24,9 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning
/// <summary>The current OS.</summary> /// <summary>The current OS.</summary>
private readonly Platform Platform; private readonly Platform Platform;
/// <summary>The Steam app ID for Stardew Valley.</summary>
private const string SteamAppId = "413150";
/********* /*********
** Public methods ** Public methods
@ -145,7 +149,7 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning
#if SMAPI_FOR_WINDOWS #if SMAPI_FOR_WINDOWS
IDictionary<string, string> registryKeys = new Dictionary<string, string> 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 [@"SOFTWARE\WOW6432Node\GOG.com\Games\1453375253"] = "PATH", // GOG on 64-bit Windows
}; };
foreach (var pair in registryKeys) foreach (var pair in registryKeys)
@ -158,7 +162,15 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning
// via Steam library path // via Steam library path
string? steamPath = this.GetCurrentUserRegistryValue(@"Software\Valve\Steam", "SteamPath"); string? steamPath = this.GetCurrentUserRegistryValue(@"Software\Valve\Steam", "SteamPath");
if (steamPath != null) if (steamPath != null)
{
// conventional path
yield return Path.Combine(steamPath.Replace('/', '\\'), @"steamapps\common\Stardew Valley"); 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 #endif
// default GOG/Steam paths // default GOG/Steam paths
@ -243,6 +255,42 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning
using (openKey) using (openKey)
return (string?)openKey.GetValue(name); 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)
{
if (steamPath == null)
return null;
// get raw .vdf data
string libraryFoldersPath = Path.Combine(steamPath.Replace('/', '\\'), "steamapps\\libraryfolders.vdf");
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;
}
#endif #endif
} }
} }

View File

@ -45,10 +45,14 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning
".png", ".png",
".psd", ".psd",
".tif", ".tif",
".xcf", // gimp files
// archives // archives
".rar", ".rar",
".zip", ".zip",
".7z",
".tar",
".tar.gz",
// backup files // backup files
".backup", ".backup",

View File

@ -11,9 +11,10 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.11.43" /> <PackageReference Include="HtmlAgilityPack" Version="1.11.43" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="Pathoschild.Http.FluentClient" Version="4.1.1" /> <PackageReference Include="Pathoschild.Http.FluentClient" Version="4.2.0" />
<PackageReference Include="System.Management" Version="5.0.0" Condition="'$(OS)' == 'Windows_NT'" /> <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="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>
<ItemGroup> <ItemGroup>

View File

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Text;
using Newtonsoft.Json; using Newtonsoft.Json;
using StardewModdingAPI.Toolkit.Serialization.Converters; using StardewModdingAPI.Toolkit.Serialization.Converters;
@ -90,13 +91,13 @@ namespace StardewModdingAPI.Toolkit.Serialization.Models
[JsonConstructor] [JsonConstructor]
public Manifest(string uniqueId, string name, string author, string description, ISemanticVersion version, ISemanticVersion? minimumApiVersion, string? entryDll, IManifestContentPackFor? contentPackFor, IManifestDependency[]? dependencies, string[]? updateKeys) 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.UniqueID = this.NormalizeField(uniqueId);
this.Name = this.NormalizeWhitespace(name); this.Name = this.NormalizeField(name, replaceSquareBrackets: true);
this.Author = this.NormalizeWhitespace(author); this.Author = this.NormalizeField(author);
this.Description = this.NormalizeWhitespace(description); this.Description = this.NormalizeField(description);
this.Version = version; this.Version = version;
this.MinimumApiVersion = minimumApiVersion; this.MinimumApiVersion = minimumApiVersion;
this.EntryDll = this.NormalizeWhitespace(entryDll); this.EntryDll = this.NormalizeField(entryDll);
this.ContentPackFor = contentPackFor; this.ContentPackFor = contentPackFor;
this.Dependencies = dependencies ?? Array.Empty<IManifestDependency>(); this.Dependencies = dependencies ?? Array.Empty<IManifestDependency>();
this.UpdateKeys = updateKeys ?? Array.Empty<string>(); this.UpdateKeys = updateKeys ?? Array.Empty<string>();
@ -113,17 +114,47 @@ namespace StardewModdingAPI.Toolkit.Serialization.Models
/********* /*********
** Private methods ** 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="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 #if NET5_0_OR_GREATER
[return: NotNullIfNotNull("input")] [return: NotNullIfNotNull("input")]
#endif #endif
private string? NormalizeWhitespace(string? input) private string? NormalizeField(string? input, bool replaceSquareBrackets = false)
{ {
return input input = input?.Trim();
?.Trim()
.Replace("\r", "") if (!string.IsNullOrEmpty(input))
.Replace("\n", ""); {
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;
} }
} }
} }

View File

@ -159,11 +159,20 @@ namespace StardewModdingAPI.Web.Controllers
continue; continue;
} }
// if there's only a prerelease version (e.g. from GitHub), don't override the main version
ISemanticVersion? curMain = data.Version;
ISemanticVersion? curPreview = data.PreviewVersion;
if (curPreview == null && curMain?.IsPrerelease() == true)
{
curPreview = curMain;
curMain = null;
}
// handle versions // handle versions
if (this.IsNewer(data.Version, main?.Version)) if (this.IsNewer(curMain, main?.Version))
main = new ModEntryVersionModel(data.Version, data.Url!); main = new ModEntryVersionModel(curMain, data.Url!);
if (this.IsNewer(data.PreviewVersion, optional?.Version)) if (this.IsNewer(curPreview, optional?.Version))
optional = new ModEntryVersionModel(data.PreviewVersion, data.Url!); optional = new ModEntryVersionModel(curPreview, data.Url!);
} }
// get unofficial version // get unofficial version

View File

@ -121,10 +121,10 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus
HtmlNode? node = doc.DocumentNode.SelectSingleNode("//div[contains(@class, 'site-notice')][contains(@class, 'warning')]"); HtmlNode? node = doc.DocumentNode.SelectSingleNode("//div[contains(@class, 'site-notice')][contains(@class, 'warning')]");
if (node != null) 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 errorCode = errorParts[0];
string? errorText = errorParts.Length > 1 ? errorParts[1] : null; string? errorText = errorParts.Length > 1 ? errorParts[1] : null;
switch (errorCode.Trim().ToLower()) switch (errorCode.ToLower())
{ {
case "not found": case "not found":
return null; return null;

View File

@ -199,8 +199,15 @@ namespace StardewModdingAPI.Web.Framework.LogParsing
log.ApiVersion = match.Groups["apiVersion"].Value; log.ApiVersion = match.Groups["apiVersion"].Value;
log.GameVersion = match.Groups["gameVersion"].Value; log.GameVersion = match.Groups["gameVersion"].Value;
log.OperatingSystem = match.Groups["os"].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(); log.ApiVersionParsed = smapiMod.GetParsedVersion();
} }

View File

@ -25,6 +25,9 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models
/**** /****
** Log data ** 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> /// <summary>The SMAPI version.</summary>
public string? ApiVersion { get; set; } public string? ApiVersion { get; set; }

View File

@ -25,7 +25,7 @@
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.5" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.5" />
<PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.14" /> <PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.14" />
<PackageReference Include="Pathoschild.FluentNexus" Version="1.0.5" /> <PackageReference Include="Pathoschild.FluentNexus" Version="1.0.5" />
<PackageReference Include="Pathoschild.Http.FluentClient" Version="4.1.1" /> <PackageReference Include="Pathoschild.Http.FluentClient" Version="4.2.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<DotNetCliToolReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Tools" Version="1.0.1" /> <DotNetCliToolReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Tools" Version="1.0.1" />

View File

@ -40,7 +40,7 @@
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
} }
<link rel="stylesheet" href="~/Content/css/file-upload.css" /> <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" /> <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/tabbyjs@12.0.3" crossorigin="anonymous"></script>
@ -243,7 +243,7 @@ else if (log?.IsValid == true)
@if (log?.IsValid == true) @if (log?.IsValid == true)
{ {
<div id="output"> <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> <h2>Suggested fixes</h2>
<ul id="fix-list"> <ul id="fix-list">
@ -257,7 +257,14 @@ else if (log?.IsValid == true)
} }
@if (isPyTkCompatibilityMode) @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()) @if (outdatedMods.Any())
{ {
@ -307,6 +314,10 @@ else if (log?.IsValid == true)
</table> </table>
</li> </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> </ul>
} }
@ -329,7 +340,13 @@ else if (log?.IsValid == true)
</tr> </tr>
<tr> <tr>
<th>SMAPI:</th> <th>SMAPI:</th>
<td v-pre>@log.ApiVersion</td> <td v-pre>
@log.ApiVersion
@if (log.IsStrictMode)
{
<strong>(strict mode)</strong>
}
</td>
</tr> </tr>
<tr> <tr>
<th>Folder:</th> <th>Folder:</th>
@ -361,7 +378,7 @@ else if (log?.IsValid == true)
ContentPacks: contentPacks?.TryGetValue(mod.Name, out LogModInfo[]? contentPackList) == true ? contentPackList : Array.Empty<LogModInfo>() ContentPacks: contentPacks?.TryGetValue(mod.Name, out LogModInfo[]? contentPackList) == true ? contentPackList : Array.Empty<LogModInfo>()
)) ))
.ToList(); .ToList();
if (contentPacks?.TryGetValue("", out LogModInfo[] invalidPacks) == true) if (contentPacks?.TryGetValue("", out LogModInfo[]? invalidPacks) == true)
{ {
modsWithContentPacks.Add(( modsWithContentPacks.Add((
Mod: new LogModInfo(ModType.CodeMod, "<invalid content packs>", "", "", ""), Mod: new LogModInfo(ModType.CodeMod, "<invalid content packs>", "", "", ""),

View File

@ -73,6 +73,11 @@ table caption {
margin-bottom: 0.5em; margin-bottom: 0.5em;
} }
#fix-list li.notice {
background: #EEFFEE;
border-color: #080;
}
#fix-list li.important { #fix-list li.important {
background: #FCC; background: #FCC;
border-color: #800; border-color: #800;

View File

@ -52,7 +52,7 @@ namespace StardewModdingAPI
internal static int? LogScreenId { get; set; } internal static int? LogScreenId { get; set; }
/// <summary>SMAPI's current raw semantic version.</summary> /// <summary>SMAPI's current raw semantic version.</summary>
internal static string RawApiVersion = "3.16.2"; internal static string RawApiVersion = "3.17.0";
} }
/// <summary>Contains SMAPI's constants and assumptions.</summary> /// <summary>Contains SMAPI's constants and assumptions.</summary>

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Buffers;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Graphics;
@ -32,59 +33,70 @@ namespace StardewModdingAPI.Framework.Content
/// <inheritdoc /> /// <inheritdoc />
public void PatchImage(IRawTextureData source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace) 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) if (source == null)
throw new ArgumentNullException(nameof(source), "Can't patch from null source data."); throw new ArgumentNullException(nameof(source), "Can't patch from null source data.");
// get the pixels for the source area // get normalized bounds
Color[] sourceData; this.GetPatchBounds(ref sourceArea, ref targetArea, source.Width, source.Height);
{ if (source.Data.Length < (sourceArea.Value.Bottom - 1) * source.Width + sourceArea.Value.Right)
int areaX = sourceArea.Value.X; throw new ArgumentException("Can't apply image patch because the source image is smaller than the source area.", nameof(source));
int areaY = sourceArea.Value.Y; int areaX = sourceArea.Value.X;
int areaWidth = sourceArea.Value.Width; int areaY = sourceArea.Value.Y;
int areaHeight = sourceArea.Value.Height; int areaWidth = sourceArea.Value.Width;
int areaHeight = sourceArea.Value.Height;
if (areaX == 0 && areaY == 0 && areaWidth == source.Width && areaHeight == source.Height) // shortcut: if the area width matches the source image, we can apply the image as-is without needing
sourceData = source.Data; // to copy the pixels into a smaller subset. It's fine if the source is taller than the area, since we'll
else // just ignore the extra data at the end of the pixel array.
{ if (areaWidth == source.Width)
sourceData = new Color[areaWidth * areaHeight]; {
int i = 0; this.PatchImageImpl(source.Data, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode, areaY);
for (int y = areaY, maxY = areaY + areaHeight - 1; y <= maxY; y++) return;
{
for (int x = areaX, maxX = areaX + areaWidth - 1; x <= maxX; x++)
{
int targetIndex = (y * source.Width) + x;
sourceData[i++] = source.Data[targetIndex];
}
}
}
} }
// apply // else copy the pixels within the smaller area & apply that
this.PatchImageImpl(sourceData, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode); int pixelCount = areaWidth * areaHeight;
Color[] sourceData = ArrayPool<Color>.Shared.Rent(pixelCount);
try
{
for (int y = areaY, maxY = areaY + areaHeight; y < maxY; y++)
{
int sourceIndex = (y * source.Width) + areaX;
int targetIndex = (y - areaY) * areaWidth;
Array.Copy(source.Data, sourceIndex, sourceData, targetIndex, areaWidth);
}
this.PatchImageImpl(sourceData, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode);
}
finally
{
ArrayPool<Color>.Shared.Return(sourceData);
}
} }
/// <inheritdoc /> /// <inheritdoc />
public void PatchImage(Texture2D source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace) 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) if (source == null)
throw new ArgumentNullException(nameof(source), "Can't patch from a null source texture."); 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)) if (!source.Bounds.Contains(sourceArea.Value))
throw new ArgumentOutOfRangeException(nameof(sourceArea), "The source area is outside the bounds of the source texture."); 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; int pixelCount = sourceArea.Value.Width * sourceArea.Value.Height;
Color[] sourceData = GC.AllocateUninitializedArray<Color>(pixelCount); Color[] sourceData = ArrayPool<Color>.Shared.Rent(pixelCount);
source.GetData(0, sourceArea, sourceData, 0, pixelCount); try
{
// apply source.GetData(0, sourceArea, sourceData, 0, pixelCount);
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 /> /// <inheritdoc />
@ -94,7 +106,7 @@ namespace StardewModdingAPI.Framework.Content
return false; return false;
Texture2D original = this.Data; 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.ReplaceWith(texture);
this.PatchImage(original); this.PatchImage(original);
return true; return true;
@ -117,15 +129,16 @@ namespace StardewModdingAPI.Framework.Content
/// <summary>Overwrite part of the image.</summary> /// <summary>Overwrite part of the image.</summary>
/// <param name="sourceData">The image data to patch into the content.</param> /// <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="sourceWidth">The pixel width of the original source image.</param>
/// <param name="sourceHeight">The pixel height of the 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="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="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="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="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="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> /// <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
Texture2D target = this.Data; Texture2D target = this.Data;
@ -139,24 +152,69 @@ namespace StardewModdingAPI.Framework.Content
if (sourceArea.Size != targetArea.Size) if (sourceArea.Size != targetArea.Size)
throw new InvalidOperationException("The source and target areas must be the same size."); throw new InvalidOperationException("The source and target areas must be the same size.");
// merge data // shortcut: replace the entire area
if (patchMode == PatchMode.Overlay) if (patchMode == PatchMode.Replace)
{
target.SetData(0, targetArea, sourceData, startRow * sourceArea.Width, 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 = startRow * sourceArea.Width; i < pixelCount; i++)
{
if (sourceData[i].A >= AssetDataForImage.MinOpacity)
{
startIndex = i;
break;
}
}
if (startIndex == -1)
return; // blank texture
for (int i = startRow * sourceArea.Width + pixelCount - 1; 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, 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); target.GetData(0, targetArea, mergedData, 0, pixelCount);
// merge pixels for (int i = startIndex; i <= endIndex; i++)
for (int i = 0; i < pixelCount; i++)
{ {
int targetIndex = i - sourceOffset;
Color above = sourceData[i]; Color above = sourceData[i];
Color below = mergedData[i]; Color below = mergedData[targetIndex];
// shortcut transparency // shortcut transparency
if (above.A < MinOpacity) if (above.A < AssetDataForImage.MinOpacity)
continue; continue;
if (below.A < MinOpacity) if (below.A < AssetDataForImage.MinOpacity || above.A == byte.MaxValue)
mergedData[i] = above; mergedData[targetIndex] = above;
// merge pixels // merge pixels
else else
@ -165,7 +223,7 @@ namespace StardewModdingAPI.Framework.Content
// premultiplied by the content pipeline. The formula is derived from // premultiplied by the content pipeline. The formula is derived from
// https://blogs.msdn.microsoft.com/shawnhar/2009/11/06/premultiplied-alpha/. // https://blogs.msdn.microsoft.com/shawnhar/2009/11/06/premultiplied-alpha/.
float alphaBelow = 1 - (above.A / 255f); float alphaBelow = 1 - (above.A / 255f);
mergedData[i] = new Color( mergedData[targetIndex] = new Color(
r: (int)(above.R + (below.R * alphaBelow)), r: (int)(above.R + (below.R * alphaBelow)),
g: (int)(above.G + (below.G * alphaBelow)), g: (int)(above.G + (below.G * alphaBelow)),
b: (int)(above.B + (below.B * alphaBelow)), b: (int)(above.B + (below.B * alphaBelow)),
@ -176,8 +234,10 @@ namespace StardewModdingAPI.Framework.Content
target.SetData(0, targetArea, mergedData, 0, pixelCount); target.SetData(0, targetArea, mergedData, 0, pixelCount);
} }
else finally
target.SetData(0, targetArea, sourceData, 0, pixelCount); {
ArrayPool<Color>.Shared.Return(mergedData);
}
} }
} }
} }

View File

@ -34,9 +34,6 @@ namespace StardewModdingAPI.Framework
/// <summary>An asset key prefix for assets from SMAPI mod folders.</summary> /// <summary>An asset key prefix for assets from SMAPI mod folders.</summary>
private readonly string ManagedPrefix = "SMAPI"; 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> /// <summary>Get a file lookup for the given directory.</summary>
private readonly Func<string, IFileLookup> GetFileLookup; private readonly Func<string, IFileLookup> GetFileLookup;
@ -139,8 +136,7 @@ namespace StardewModdingAPI.Framework
/// <param name="getFileLookup">Get a file lookup for the given directory.</param> /// <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="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="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)
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)
{ {
this.GetFileLookup = getFileLookup; this.GetFileLookup = getFileLookup;
this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
@ -151,7 +147,6 @@ namespace StardewModdingAPI.Framework
this.OnAssetsInvalidated = onAssetsInvalidated; this.OnAssetsInvalidated = onAssetsInvalidated;
this.RequestAssetOperations = requestAssetOperations; this.RequestAssetOperations = requestAssetOperations;
this.FullRootDirectory = Path.Combine(Constants.GamePath, rootDirectory); this.FullRootDirectory = Path.Combine(Constants.GamePath, rootDirectory);
this.UseRawImageLoading = useRawImageLoading;
this.ContentManagers.Add( this.ContentManagers.Add(
this.MainContentManager = new GameContentManager( this.MainContentManager = new GameContentManager(
name: "Game1.content", name: "Game1.content",
@ -230,8 +225,7 @@ namespace StardewModdingAPI.Framework
reflection: this.Reflection, reflection: this.Reflection,
jsonHelper: this.JsonHelper, jsonHelper: this.JsonHelper,
onDisposing: this.OnDisposing, onDisposing: this.OnDisposing,
fileLookup: this.GetFileLookup(rootDirectory), fileLookup: this.GetFileLookup(rootDirectory)
useRawImageLoading: this.UseRawImageLoading
); );
this.ContentManagers.Add(manager); this.ContentManagers.Add(manager);
return manager; return manager;

View File

@ -336,7 +336,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
{ {
// track asset key // track asset key
if (value is Texture2D texture) if (value is Texture2D texture)
texture.Name = assetName.Name; texture.SetName(assetName);
// save to cache // save to cache
// Note: even if the asset was loaded and cached right before this method was called, // Note: even if the asset was loaded and cached right before this method was called,

View File

@ -1,9 +1,11 @@
using System; using System;
using System.Buffers;
using System.Diagnostics; using System.Diagnostics;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Runtime.CompilerServices;
using BmFont; using BmFont;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Content;
@ -28,9 +30,6 @@ namespace StardewModdingAPI.Framework.ContentManagers
/********* /*********
** Fields ** 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> /// <summary>Encapsulates SMAPI's JSON file parsing.</summary>
private readonly JsonHelper JsonHelper; private readonly JsonHelper JsonHelper;
@ -72,15 +71,13 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> /// <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="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="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)
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)
: base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: true) : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: true)
{ {
this.GameContentManager = gameContentManager; this.GameContentManager = gameContentManager;
this.FileLookup = fileLookup; this.FileLookup = fileLookup;
this.JsonHelper = jsonHelper; this.JsonHelper = jsonHelper;
this.ModName = modName; this.ModName = modName;
this.UseRawImageLoading = useRawImageLoading;
this.TryLocalizeKeys = false; this.TryLocalizeKeys = false;
} }
@ -111,7 +108,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
if (this.Coordinator.TryParseManagedAssetKey(assetName.Name, out string? contentManagerID, out IAssetName? relativePath)) if (this.Coordinator.TryParseManagedAssetKey(assetName.Name, out string? contentManagerID, out IAssetName? relativePath))
{ {
if (contentManagerID != this.Name) 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; assetName = relativePath;
} }
} }
@ -123,7 +120,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
// get file // get file
FileInfo file = this.GetModFile<T>(assetName.Name); FileInfo file = this.GetModFile<T>(assetName.Name);
if (!file.Exists) 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 // load content
asset = file.Extension.ToLower() switch asset = file.Extension.ToLower() switch
@ -141,7 +138,8 @@ namespace StardewModdingAPI.Framework.ContentManagers
if (ex is SContentLoadException) if (ex is SContentLoadException)
throw; 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 // track & return asset
@ -189,7 +187,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
private T LoadDataFile<T>(IAssetName assetName, FileInfo file) private T LoadDataFile<T>(IAssetName assetName, FileInfo file)
{ {
if (!this.JsonHelper.ReadJsonFileIfExists(file.FullName, out T? asset)) 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; return asset;
} }
@ -201,48 +199,52 @@ namespace StardewModdingAPI.Framework.ContentManagers
private T LoadImageFile<T>(IAssetName assetName, FileInfo file) private T LoadImageFile<T>(IAssetName assetName, FileInfo file)
{ {
this.AssertValidType<T>(assetName, file, typeof(Texture2D), typeof(IRawTextureData)); this.AssertValidType<T>(assetName, file, typeof(Texture2D), typeof(IRawTextureData));
bool expectsRawData = typeof(T).IsAssignableTo(typeof(IRawTextureData)); bool returnRawData = typeof(T).IsAssignableTo(typeof(IRawTextureData));
bool asRawData = expectsRawData || this.UseRawImageLoading;
#if SMAPI_DEPRECATED #if SMAPI_DEPRECATED
// disable raw data if PyTK will rescale the image (until it supports raw data) if (!returnRawData && this.ShouldDisableIntermediateRawDataLoad<T>(assetName, file))
if (asRawData && !expectsRawData)
{ {
if (ModContentManager.EnablePyTkLegacyMode) using FileStream stream = File.OpenRead(file.FullName);
{ Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream).SetName(assetName);
// PyTK intercepts Texture2D file loads to rescale them (e.g. for HD portraits), this.PremultiplyTransparency(texture);
// but doesn't support IRawTextureData loads yet. We can't just check if the return (T)(object)texture;
// 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);
}
} }
#endif #endif
// load IRawTextureData raw = this.LoadRawImageData(file, returnRawData);
if (asRawData)
{
IRawTextureData raw = this.LoadRawImageData(file, expectsRawData);
if (expectsRawData) if (returnRawData)
return (T)raw; return (T)raw;
else
{
Texture2D texture = new(Game1.graphics.GraphicsDevice, raw.Width, raw.Height);
texture.SetData(raw.Data);
return (T)(object)texture;
}
}
else else
{ {
using FileStream stream = File.OpenRead(file.FullName); Texture2D texture = new Texture2D(Game1.graphics.GraphicsDevice, raw.Width, raw.Height).SetName(assetName);
Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream); texture.SetData(raw.Data);
texture = this.PremultiplyTransparency(texture);
return (T)(object)texture; 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> /// <summary>Load the raw image data from a file on disk.</summary>
/// <param name="file">The file whose data to load.</param> /// <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> /// <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) private T LoadXnbFile<T>(IAssetName assetName)
{ {
if (typeof(IRawTextureData).IsAssignableFrom(typeof(T))) 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 // 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. // we need to strip it here to avoid trying to load a '.xnb.xnb' file.
@ -326,7 +328,8 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <param name="file">The file to load.</param> /// <param name="file">The file to load.</param>
private T HandleUnknownFileType<T>(IAssetName assetName, FileInfo file) private T HandleUnknownFileType<T>(IAssetName assetName, FileInfo file)
{ {
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 default;
} }
/// <summary>Assert that the asset type is compatible with one of the allowed types.</summary> /// <summary>Assert that the asset type is compatible with one of the allowed types.</summary>
@ -338,18 +341,21 @@ namespace StardewModdingAPI.Framework.ContentManagers
private void AssertValidType<TAsset>(IAssetName assetName, FileInfo file, params Type[] validTypes) private void AssertValidType<TAsset>(IAssetName assetName, FileInfo file, params Type[] validTypes)
{ {
if (!validTypes.Any(validType => validType.IsAssignableFrom(typeof(TAsset)))) 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="errorType">Why loading an asset through the content pipeline failed.</param>
/// <param name="assetName">The asset name that failed to load.</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="reasonPhrase">The reason the file couldn't be loaded.</param>
/// <param name="exception">The underlying exception, if applicable.</param> /// <param name="exception">The underlying exception, if applicable.</param>
/// <exception cref="SContentLoadException" />
[DoesNotReturn]
[DebuggerStepThrough, DebuggerHidden] [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> /// <summary>Get a file from the mod folder.</summary>
@ -381,26 +387,32 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <param name="texture">The texture to premultiply.</param> /// <param name="texture">The texture to premultiply.</param>
/// <returns>Returns a premultiplied texture.</returns> /// <returns>Returns a premultiplied texture.</returns>
/// <remarks>Based on <a href="https://gamedev.stackexchange.com/a/26037">code by David Gouveia</a>.</remarks> /// <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 int count = texture.Width * texture.Height;
Color[] data = GC.AllocateUninitializedArray<Color>(texture.Width * texture.Height); Color[] data = ArrayPool<Color>.Shared.Rent(count);
texture.GetData(data); try
bool changed = false;
for (int i = 0; i < data.Length; i++)
{ {
Color pixel = data[i]; texture.GetData(data, 0, count);
if (pixel.A is (byte.MinValue or byte.MaxValue))
continue; // no need to change fully transparent/opaque pixels
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()) bool changed = false;
changed = true; 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
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;
}
if (changed)
texture.SetData(data, 0, count);
}
finally
{
ArrayPool<Color>.Shared.Return(data);
} }
if (changed)
texture.SetData(data);
return texture;
} }
/// <summary>Fix custom map tilesheet paths so they can be found by the content manager.</summary> /// <summary>Fix custom map tilesheet paths so they can be found by the content manager.</summary>

View File

@ -101,7 +101,7 @@ namespace StardewModdingAPI.Framework.Deprecations
foreach (DeprecationWarning warning in this.QueuedWarnings.OrderBy(p => p.ModName).ThenBy(p => p.NounPhrase)) foreach (DeprecationWarning warning in this.QueuedWarnings.OrderBy(p => p.ModName).ThenBy(p => p.NounPhrase))
{ {
// build message // 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 upcoming SMAPI 4.0.0.";
// get log level // get log level
LogLevel level; LogLevel level;

View File

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Graphics;
@ -163,5 +164,34 @@ namespace StardewModdingAPI.Framework
{ {
return reflection.GetField<bool>(spriteBatch, "_beginCalled").GetValue(); 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;
}
} }
} }

View File

@ -223,7 +223,7 @@ namespace StardewModdingAPI.Framework.Logging
// show update alert // show update alert
if (File.Exists(Constants.UpdateMarker)) 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 (SemanticVersion.TryParse(rawUpdateFound[0], out ISemanticVersion? updateFound))
{ {
if (Constants.ApiVersion.IsPrerelease() && updateFound.IsNewerThan(Constants.ApiVersion)) 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) public void LogIntro(string modsPath, IDictionary<string, object?> customSettings)
{ {
// log platform // 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 // log basic info
this.Monitor.Log($"Mods go here: {modsPath}", LogLevel.Info); this.Monitor.Log($"Mods go here: {modsPath}", LogLevel.Info);
@ -280,6 +284,10 @@ namespace StardewModdingAPI.Framework.Logging
// log custom settings // log custom settings
if (customSettings.Any()) if (customSettings.Any())
this.Monitor.Log($"Loaded with custom settings: {string.Join(", ", customSettings.OrderBy(p => p.Key).Select(p => $"{p.Key}: {p.Value}"))}"); 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> /// <summary>Log details for settings that don't match the default.</summary>

View File

@ -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);
}

View File

@ -1,5 +1,7 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Internal;
namespace StardewModdingAPI.Framework.ModHelpers namespace StardewModdingAPI.Framework.ModHelpers
{ {
@ -15,8 +17,8 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <summary>Encapsulates monitoring and logging for the mod.</summary> /// <summary>Encapsulates monitoring and logging for the mod.</summary>
private readonly IMonitor Monitor; private readonly IMonitor Monitor;
/// <summary>The mod IDs for APIs accessed by this instanced.</summary> /// <summary>The APIs accessed by this instance.</summary>
private readonly HashSet<string> AccessedModApis = new(); private readonly Dictionary<string, object?> AccessedModApis = new();
/// <summary>Generates proxy classes to access mod APIs through an arbitrary interface.</summary> /// <summary>Generates proxy classes to access mod APIs through an arbitrary interface.</summary>
private readonly IInterfaceProxyFactory ProxyFactory; private readonly IInterfaceProxyFactory ProxyFactory;
@ -66,11 +68,44 @@ namespace StardewModdingAPI.Framework.ModHelpers
return null; return null;
} }
// get raw API // get the target mod
IModMetadata? mod = this.Registry.Get(uniqueID); IModMetadata? mod = this.Registry.Get(uniqueID);
if (mod?.Api != null && this.AccessedModApis.Add(mod.Manifest.UniqueID)) if (mod == null)
this.Monitor.Log($"Accessed mod-provided API for {mod.DisplayName}."); return null;
return mod?.Api;
// 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 /> /// <inheritdoc />

View File

@ -221,7 +221,7 @@ namespace StardewModdingAPI.Framework.ModLoading
/// </remarks> /// </remarks>
public static Assembly? ResolveAssembly(string name) 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 return AppDomain.CurrentDomain
.GetAssemblies() .GetAssemblies()
.FirstOrDefault(p => p.GetName().Name == shortName); .FirstOrDefault(p => p.GetName().Name == shortName);

View File

@ -58,7 +58,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
MethodReference? methodRef = RewriteHelper.AsMethodReference(instruction); MethodReference? methodRef = RewriteHelper.AsMethodReference(instruction);
if (methodRef != null && methodRef.DeclaringType.FullName == this.FullTypeName && this.MethodNames.Contains(methodRef.Name)) 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($"add_{eventName}");
this.MethodNames.Remove($"remove_{eventName}"); this.MethodNames.Remove($"remove_{eventName}");

View File

@ -22,8 +22,8 @@ namespace StardewModdingAPI.Framework.Models
[nameof(WebApiBaseUrl)] = "https://smapi.io/api/", [nameof(WebApiBaseUrl)] = "https://smapi.io/api/",
[nameof(LogNetworkTraffic)] = false, [nameof(LogNetworkTraffic)] = false,
[nameof(RewriteMods)] = true, [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> /// <summary>The default values for <see cref="SuppressUpdateChecks"/>, to log changes if different.</summary>
@ -67,9 +67,6 @@ namespace StardewModdingAPI.Framework.Models
/// <summary>Whether SMAPI should rewrite mods for compatibility.</summary> /// <summary>Whether SMAPI should rewrite mods for compatibility.</summary>
public bool RewriteMods { get; set; } 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> /// <summary>Whether to make SMAPI file APIs case-insensitive, even on Linux.</summary>
public bool UseCaseInsensitivePaths { get; set; } public bool UseCaseInsensitivePaths { get; set; }
@ -79,6 +76,9 @@ namespace StardewModdingAPI.Framework.Models
/// <summary>The colors to use for text written to the SMAPI console.</summary> /// <summary>The colors to use for text written to the SMAPI console.</summary>
public ColorSchemeConfig ConsoleColors { get; set; } 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> /// <summary>The mod IDs SMAPI should ignore when performing update checks or validating update keys.</summary>
public HashSet<string> SuppressUpdateChecks { get; set; } public HashSet<string> SuppressUpdateChecks { get; set; }
@ -95,12 +95,12 @@ namespace StardewModdingAPI.Framework.Models
/// <param name="webApiBaseUrl">The base URL for SMAPI's web API, 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="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="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="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="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="consoleColors">The colors to use for text written to the SMAPI console.</param>
/// <param name="suppressHarmonyDebugMode">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.</param>
/// <param name="suppressUpdateChecks">The mod IDs SMAPI should ignore when performing update checks or validating update keys.</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) public SConfig(bool developerMode, bool? checkForUpdates, bool? paranoidWarnings, bool? useBetaChannel, string gitHubProjectName, string webApiBaseUrl, string[]? verboseLogging, bool? rewriteMods, bool? useCaseInsensitivePaths, bool? logNetworkTraffic, ColorSchemeConfig consoleColors, bool? suppressHarmonyDebugMode, string[]? suppressUpdateChecks)
{ {
this.DeveloperMode = developerMode; this.DeveloperMode = developerMode;
this.CheckForUpdates = checkForUpdates ?? (bool)SConfig.DefaultValues[nameof(this.CheckForUpdates)]; this.CheckForUpdates = checkForUpdates ?? (bool)SConfig.DefaultValues[nameof(this.CheckForUpdates)];
@ -110,10 +110,10 @@ namespace StardewModdingAPI.Framework.Models
this.WebApiBaseUrl = webApiBaseUrl; this.WebApiBaseUrl = webApiBaseUrl;
this.VerboseLogging = new HashSet<string>(verboseLogging ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase); this.VerboseLogging = new HashSet<string>(verboseLogging ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase);
this.RewriteMods = rewriteMods ?? (bool)SConfig.DefaultValues[nameof(this.RewriteMods)]; 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.UseCaseInsensitivePaths = useCaseInsensitivePaths ?? (bool)SConfig.DefaultValues[nameof(this.UseCaseInsensitivePaths)];
this.LogNetworkTraffic = logNetworkTraffic ?? (bool)SConfig.DefaultValues[nameof(this.LogNetworkTraffic)]; this.LogNetworkTraffic = logNetworkTraffic ?? (bool)SConfig.DefaultValues[nameof(this.LogNetworkTraffic)];
this.ConsoleColors = consoleColors; this.ConsoleColors = consoleColors;
this.SuppressHarmonyDebugMode = suppressHarmonyDebugMode ?? (bool)SConfig.DefaultValues[nameof(this.SuppressHarmonyDebugMode)];
this.SuppressUpdateChecks = new HashSet<string>(suppressUpdateChecks ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase); this.SuppressUpdateChecks = new HashSet<string>(suppressUpdateChecks ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase);
} }

View File

@ -25,10 +25,13 @@ namespace StardewModdingAPI.Framework
private readonly LogFileManager LogFile; private readonly LogFileManager LogFile;
/// <summary>The maximum length of the <see cref="LogLevel"/> values.</summary> /// <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> /// <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> /// <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; private readonly Func<int?> GetScreenIdForLog;
@ -84,7 +87,7 @@ namespace StardewModdingAPI.Framework
/// <inheritdoc /> /// <inheritdoc />
public void LogOnce(string message, LogLevel level = LogLevel.Trace) 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); this.LogImpl(this.Source, message, (ConsoleLogLevel)level);
} }
@ -147,7 +150,7 @@ namespace StardewModdingAPI.Framework
/// <param name="level">The log level.</param> /// <param name="level">The log level.</param>
private string GenerateMessagePrefix(string source, ConsoleLogLevel level) private string GenerateMessagePrefix(string source, ConsoleLogLevel level)
{ {
string levelStr = level.ToString().ToUpper().PadRight(Monitor.MaxLevelLength); string levelStr = Monitor.LogStrings[level];
int? playerIndex = this.GetScreenIdForLog(); int? playerIndex = this.GetScreenIdForLog();
return $"[{DateTime.Now:HH:mm:ss} {levelStr}{(playerIndex != null ? $" screen_{playerIndex}" : "")} {source}]"; return $"[{DateTime.Now:HH:mm:ss} {levelStr}{(playerIndex != null ? $" screen_{playerIndex}" : "")} {source}]";

View File

@ -501,6 +501,15 @@ namespace StardewModdingAPI.Framework
return; 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 #if SMAPI_DEPRECATED
/********* /*********
** Reload assets when interceptors are added/removed ** Reload assets when interceptors are added/removed
@ -1324,8 +1333,7 @@ namespace StardewModdingAPI.Framework
onAssetLoaded: this.OnAssetLoaded, onAssetLoaded: this.OnAssetLoaded,
onAssetsInvalidated: this.OnAssetsInvalidated, onAssetsInvalidated: this.OnAssetsInvalidated,
getFileLookup: this.GetFileLookup, getFileLookup: this.GetFileLookup,
requestAssetOperations: this.RequestAssetOperations, requestAssetOperations: this.RequestAssetOperations
useRawImageLoading: this.Settings.UseRawImageLoading
); );
if (this.ContentCore.Language != this.Translator.LocaleEnum) if (this.ContentCore.Language != this.Translator.LocaleEnum)
this.Translator.SetLocale(this.ContentCore.GetLocale(), this.ContentCore.Language); this.Translator.SetLocale(this.ContentCore.GetLocale(), this.ContentCore.Language);
@ -1384,7 +1392,7 @@ namespace StardewModdingAPI.Framework
} }
// check min length for specific types // check min length for specific types
switch (fields[SObject.objectInfoTypeIndex].Split(new[] { ' ' }, 2)[0]) switch (fields[SObject.objectInfoTypeIndex].Split(' ', 2)[0])
{ {
case "Cooking": case "Cooking":
if (fields.Length < SObject.objectInfoBuffDurationIndex + 1) if (fields.Length < SObject.objectInfoBuffDurationIndex + 1)
@ -1672,26 +1680,33 @@ namespace StardewModdingAPI.Framework
// initialize translations // initialize translations
this.ReloadTranslations(loaded); this.ReloadTranslations(loaded);
#if SMAPI_DEPRECATED
// set temporary PyTK compatibility mode // set temporary PyTK compatibility mode
// This is part of a three-part fix for PyTK 1.23.* and earlier. When removing this, // 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. // search 'Platonymous.Toolkit' to find the other part in SMAPI and Content Patcher.
{ {
IModInfo? pyTk = this.ModRegistry.Get("Platonymous.Toolkit"); 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 #endif
}
// initialize loaded non-content-pack mods // initialize loaded non-content-pack mods
this.Monitor.Log("Launching mods...", LogLevel.Debug); this.Monitor.Log("Launching mods...", LogLevel.Debug);
foreach (IModMetadata metadata in loadedMods) 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 #if SMAPI_DEPRECATED
// add interceptors // add interceptors
if (metadata.Mod?.Helper is ModHelper helper) if (mod.Helper is ModHelper helper)
{ {
// ReSharper disable SuspiciousTypeConversion.Global // ReSharper disable SuspiciousTypeConversion.Global
if (metadata.Mod is IAssetEditor editor) if (mod is IAssetEditor editor)
{ {
SCore.DeprecationManager.Warn( SCore.DeprecationManager.Warn(
source: metadata, source: metadata,
@ -1704,7 +1719,7 @@ namespace StardewModdingAPI.Framework
this.ContentCore.Editors.Add(new ModLinked<IAssetEditor>(metadata, editor)); this.ContentCore.Editors.Add(new ModLinked<IAssetEditor>(metadata, editor));
} }
if (metadata.Mod is IAssetLoader loader) if (mod is IAssetLoader loader)
{ {
SCore.DeprecationManager.Warn( SCore.DeprecationManager.Warn(
source: metadata, source: metadata,
@ -1749,35 +1764,41 @@ namespace StardewModdingAPI.Framework
} }
#endif #endif
// call entry method // initialize mod
Context.HeuristicModsRunningCode.Push(metadata); Context.HeuristicModsRunningCode.Push(metadata);
try
{ {
IMod mod = metadata.Mod!; // call entry method
mod.Entry(mod.Helper!); try
}
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)
{ {
api = null; mod.Entry(mod.Helper!);
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); }
catch (Exception ex)
{
metadata.LogAsMod($"Mod crashed on entry and might not work correctly. Technical details:\n{ex.GetLogSummary()}", LogLevel.Error);
} }
if (api != null) // get mod API
this.Monitor.Log($" Found mod-provided API ({api.GetType().FullName})."); try
metadata.SetApi(api); {
} object? api = mod.GetApi();
catch (Exception ex) if (api != null && !api.GetType().IsPublic)
{ {
this.Monitor.Log($"Failed loading mod-provided API for {metadata.DisplayName}. Integrations with other mods may not work. Error: {ex.GetLogSummary()}", LogLevel.Error); 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)
{
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 Type[] { 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 _); Context.HeuristicModsRunningCode.TryPop(out _);
} }

View File

@ -63,7 +63,7 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
{ {
if (this.LastValues.Count > 0) if (this.LastValues.Count > 0)
{ {
this.AddedImpl.AddRange(this.LastValues); this.RemovedImpl.AddRange(this.LastValues);
this.LastValues.Clear(); this.LastValues.Clear();
} }
return; return;

View File

@ -23,7 +23,15 @@ namespace StardewModdingAPI
/// <param name="helper">Provides simplified APIs for writing mods.</param> /// <param name="helper">Provides simplified APIs for writing mods.</param>
void Entry(IModHelper helper); 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(); 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);
} }
} }

View File

@ -30,6 +30,12 @@ namespace StardewModdingAPI
return null; return null;
} }
/// <inheritdoc />
public virtual object? GetApi(IModInfo mod)
{
return null;
}
/// <summary>Release or reset unmanaged resources.</summary> /// <summary>Release or reset unmanaged resources.</summary>
public void Dispose() public void Dispose()
{ {

View File

@ -54,18 +54,6 @@ copy all the settings, or you may cause bugs due to overridden changes in future
*/ */
"UseCaseInsensitivePaths": null, "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 * 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 * sensitive .NET APIs like file or shell access. Note that many mods do this legitimately as
@ -138,6 +126,14 @@ 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. * The mod IDs SMAPI should ignore when performing update checks or validating update keys.
*/ */

View File

@ -26,7 +26,7 @@
<PackageReference Include="Mono.Cecil" Version="0.11.4" /> <PackageReference Include="Mono.Cecil" Version="0.11.4" />
<PackageReference Include="MonoMod.Common" Version="22.3.5.1" /> <PackageReference Include="MonoMod.Common" Version="22.3.5.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="Pathoschild.Http.FluentClient" Version="4.1.1" /> <PackageReference Include="Pathoschild.Http.FluentClient" Version="4.2.0" />
<PackageReference Include="Pintail" Version="2.2.1" /> <PackageReference Include="Pintail" Version="2.2.1" />
<PackageReference Include="Platonymous.TMXTile" Version="1.5.9" /> <PackageReference Include="Platonymous.TMXTile" Version="1.5.9" />
<PackageReference Include="System.Reflection.Emit" Version="4.7.0" /> <PackageReference Include="System.Reflection.Emit" Version="4.7.0" />

View File

@ -54,12 +54,12 @@ namespace StardewModdingAPI.Utilities
} }
// parse buttons // parse buttons
string[] rawButtons = input.Split('+'); string[] rawButtons = input.Split('+', StringSplitOptions.TrimEntries);
SButton[] buttons = new SButton[rawButtons.Length]; SButton[] buttons = new SButton[rawButtons.Length];
List<string> rawErrors = new List<string>(); List<string> rawErrors = new List<string>();
for (int i = 0; i < buttons.Length; i++) for (int i = 0; i < buttons.Length; i++)
{ {
string rawButton = rawButtons[i].Trim(); string rawButton = rawButtons[i];
if (string.IsNullOrWhiteSpace(rawButton)) if (string.IsNullOrWhiteSpace(rawButton))
rawErrors.Add("Invalid empty button value"); rawErrors.Add("Invalid empty button value");
else if (!Enum.TryParse(rawButton, ignoreCase: true, out SButton button)) else if (!Enum.TryParse(rawButton, ignoreCase: true, out SButton button))