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 name="F:HarmonyLib.ExceptionBlockType.BeginExceptFilterBlock">
<summary>The beginning of an except filter block</summary>
<summary>The beginning of an except filter block (currently not supported to use in a patch)</summary>
</member>
<member name="F:HarmonyLib.ExceptionBlockType.BeginFaultBlock">
@ -2660,6 +2660,18 @@
<param name="operand">The optional operand</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 name="M:HarmonyLib.CodeMatch.#ctor(HarmonyLib.CodeInstruction,System.String)">
<summary>Creates a code match</summary>
@ -3216,6 +3228,13 @@
<param name="e">The enum</param>
<returns>True if the instruction loads the constant</returns>
</member>
<member name="M:HarmonyLib.CodeInstructionExtensions.LoadsConstant(HarmonyLib.CodeInstruction,System.String)">
<summary>Tests if the code instruction loads a string constant</summary>
<param name="code">The <see cref="T:HarmonyLib.CodeInstruction"/></param>
<param name="str">The string</param>
<returns>True if the instruction loads the constant</returns>
</member>
<member name="M:HarmonyLib.CodeInstructionExtensions.LoadsField(HarmonyLib.CodeInstruction,System.Reflection.FieldInfo,System.Boolean)">
<summary>Tests if the code instruction loads a field</summary>
@ -3346,7 +3365,11 @@
<summary>A file log for debugging</summary>
</member>
<member name="F:HarmonyLib.FileLog.logPath">
<member name="P:HarmonyLib.FileLog.LogWriter">
<summary>Set this to make Harmony write its log content to this stream</summary>
</member>
<member name="P:HarmonyLib.FileLog.LogPath">
<summary>Full pathname of the log file, defaults to a file called <c>harmony.log.txt</c> on your Desktop</summary>
</member>

View File

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

View File

@ -158,6 +158,10 @@ foreach ($folder in $folders) {
cp "$smapiBin/$name" "$bundlePath/smapi-internal"
}
if ($folder -eq "windows") {
cp "$smapiBin/VdfConverter.dll" "$bundlePath/smapi-internal"
}
cp "$smapiBin/SMAPI.config.json" "$bundlePath/smapi-internal/config.json"
cp "$smapiBin/SMAPI.metadata.json" "$bundlePath/smapi-internal/metadata.json"
if ($folder -eq "linux" -or $folder -eq "macOS") {

View File

@ -7,6 +7,28 @@
_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
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.
## Release notes
### Upcoming release
### 4.0.2
Released 09 October 2022.
* Switched to the newer crossplatform `portable` debug symbols (thanks to lanturnalis!).
* Fixed `BundleExtraAssemblies` option being partly case-sensitive.
* Fixed `BundleExtraAssemblies` not applying `All` value to game assemblies.
### 4.0.1
Released 14 April 2022.

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.
### Custom Harmony build
SMAPI uses [a custom build of Harmony](https://github.com/Pathoschild/Harmony#readme), which is
included in the `build` folder. To use a different build, just replace `0Harmony.dll` in that
SMAPI uses [a custom build of Harmony 2.2.2](https://github.com/Pathoschild/Harmony#readme), which
is included in the `build` folder. To use a different build, just replace `0Harmony.dll` in that
folder before compiling.
## Prepare a release

View File

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

View File

@ -27,8 +27,12 @@
<EnableGameDebugging Condition="'$(EnableGameDebugging)' == ''">true</EnableGameDebugging>
<BundleExtraAssemblies Condition="'$(BundleExtraAssemblies)' == ''"></BundleExtraAssemblies>
<!-- simplify conditions -->
<_BundleExtraAssembliesForGame>$([System.Text.RegularExpressions.Regex]::IsMatch('$(BundleExtraAssemblies)', '\bGame|All\b', RegexOptions.IgnoreCase))</_BundleExtraAssembliesForGame>
<_BundleExtraAssembliesForAny>$([System.Text.RegularExpressions.Regex]::IsMatch('$(BundleExtraAssemblies)', '\bGame|System|ThirdParty|All\b', RegexOptions.IgnoreCase))</_BundleExtraAssembliesForAny>
<!-- coppy referenced DLLs into build output -->
<CopyLocalLockFileAssemblies Condition="$(BundleExtraAssemblies.Contains('ThirdParty')) OR $(BundleExtraAssemblies.Contains('Game')) OR $(BundleExtraAssemblies.Contains('System')) OR $(BundleExtraAssemblies.Contains('All'))">true</CopyLocalLockFileAssemblies>
<CopyLocalLockFileAssemblies Condition="$(_BundleExtraAssembliesForAny)">true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<PropertyGroup Condition="'$(OS)' == 'Windows_NT' AND '$(EnableGameDebugging)' == 'true'">
@ -44,17 +48,17 @@
**********************************************-->
<ItemGroup>
<!-- game -->
<Reference Include="Stardew Valley" HintPath="$(GamePath)\Stardew Valley.dll" Private="$(BundleExtraAssemblies.Contains('Game'))" />
<Reference Include="StardewValley.GameData" HintPath="$(GamePath)\StardewValley.GameData.dll" Private="$(BundleExtraAssemblies.Contains('Game'))" />
<Reference Include="MonoGame.Framework" HintPath="$(GamePath)\MonoGame.Framework.dll" Private="$(BundleExtraAssemblies.Contains('Game'))" />
<Reference Include="xTile" HintPath="$(GamePath)\xTile.dll" Private="$(BundleExtraAssemblies.Contains('Game'))" />
<Reference Include="Stardew Valley" HintPath="$(GamePath)\Stardew Valley.dll" Private="$(_BundleExtraAssembliesForGame)" />
<Reference Include="StardewValley.GameData" HintPath="$(GamePath)\StardewValley.GameData.dll" Private="$(_BundleExtraAssembliesForGame)" />
<Reference Include="MonoGame.Framework" HintPath="$(GamePath)\MonoGame.Framework.dll" Private="$(_BundleExtraAssembliesForGame)" />
<Reference Include="xTile" HintPath="$(GamePath)\xTile.dll" Private="$(_BundleExtraAssembliesForGame)" />
<!-- SMAPI -->
<Reference Include="StardewModdingAPI" HintPath="$(GamePath)\StardewModdingAPI.dll" Private="$(BundleExtraAssemblies.Contains('Game'))" />
<Reference Include="SMAPI.Toolkit.CoreInterfaces" HintPath="$(GamePath)\smapi-internal\SMAPI.Toolkit.CoreInterfaces.dll" Private="$(BundleExtraAssemblies.Contains('Game'))" />
<Reference Include="StardewModdingAPI" HintPath="$(GamePath)\StardewModdingAPI.dll" Private="$(_BundleExtraAssembliesForGame)" />
<Reference Include="SMAPI.Toolkit.CoreInterfaces" HintPath="$(GamePath)\smapi-internal\SMAPI.Toolkit.CoreInterfaces.dll" Private="$(_BundleExtraAssembliesForGame)" />
<!-- Harmony -->
<Reference Include="0Harmony" Condition="'$(EnableHarmony)' == 'true'" HintPath="$(GamePath)\smapi-internal\0Harmony.dll" Private="$(BundleExtraAssemblies.Contains('Game'))" />
<Reference Include="0Harmony" Condition="'$(EnableHarmony)' == 'true'" HintPath="$(GamePath)\smapi-internal\0Harmony.dll" Private="$(_BundleExtraAssembliesForGame)" />
</ItemGroup>

View File

@ -63,7 +63,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
/// <param name="color">The color to set.</param>
private bool TryParseColor(string input, out Color color)
{
string[] colorHexes = input.Split(new[] { ',' }, 3);
string[] colorHexes = input.Split(',', 3);
if (int.TryParse(colorHexes[0], out int r) && int.TryParse(colorHexes[1], out int g) && int.TryParse(colorHexes[2], out int b))
{
color = new Color(r, g, b);

View File

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

View File

@ -1,9 +1,9 @@
{
"Name": "Error Handler",
"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.",
"UniqueID": "SMAPI.ErrorHandler",
"EntryDll": "ErrorHandler.dll",
"MinimumApiVersion": "3.16.2"
"MinimumApiVersion": "3.17.0"
}

View File

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

View File

@ -136,11 +136,11 @@ namespace SMAPI.Tests.Utilities
foreach (string rawPair in stateMap.Split(','))
{
// parse values
string[] parts = rawPair.Split(new[] { ':' }, 2);
string[] parts = rawPair.Split(':', 2, StringSplitOptions.TrimEntries);
if (!Enum.TryParse(parts[0], ignoreCase: true, out SButton curButton))
Assert.Fail($"The state map is invalid: unknown button value '{parts[0].Trim()}'");
Assert.Fail($"The state map is invalid: unknown button value '{parts[0]}'");
if (!Enum.TryParse(parts[1], ignoreCase: true, out SButtonState state))
Assert.Fail($"The state map is invalid: unknown state value '{parts[1].Trim()}'");
Assert.Fail($"The state map is invalid: unknown state value '{parts[1]}'");
// get state
if (curButton == button)

View File

@ -382,11 +382,11 @@ namespace SMAPI.Tests.Utilities
{
// act
string json = JsonConvert.SerializeObject(new SemanticVersion(versionStr));
SemanticVersion after = JsonConvert.DeserializeObject<SemanticVersion>(json);
SemanticVersion? after = JsonConvert.DeserializeObject<SemanticVersion>(json);
// assert
Assert.IsNotNull(after, "The semantic version after deserialization is unexpectedly null.");
Assert.AreEqual(versionStr, after.ToString(), "The semantic version after deserialization doesn't match the input version.");
Assert.AreEqual(versionStr, after!.ToString(), "The semantic version after deserialization doesn't match the input version.");
}

View File

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

View File

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

View File

@ -11,9 +11,10 @@
<ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.11.43" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="Pathoschild.Http.FluentClient" Version="4.1.1" />
<PackageReference Include="Pathoschild.Http.FluentClient" Version="4.2.0" />
<PackageReference Include="System.Management" Version="5.0.0" Condition="'$(OS)' == 'Windows_NT'" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" Condition="'$(OS)' == 'Windows_NT'" />
<PackageReference Include="VdfConverter" Version="1.0.3" Condition="'$(OS)' == 'Windows_NT'" Private="False" />
</ItemGroup>
<ItemGroup>

View File

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using Newtonsoft.Json;
using StardewModdingAPI.Toolkit.Serialization.Converters;
@ -90,13 +91,13 @@ namespace StardewModdingAPI.Toolkit.Serialization.Models
[JsonConstructor]
public Manifest(string uniqueId, string name, string author, string description, ISemanticVersion version, ISemanticVersion? minimumApiVersion, string? entryDll, IManifestContentPackFor? contentPackFor, IManifestDependency[]? dependencies, string[]? updateKeys)
{
this.UniqueID = this.NormalizeWhitespace(uniqueId);
this.Name = this.NormalizeWhitespace(name);
this.Author = this.NormalizeWhitespace(author);
this.Description = this.NormalizeWhitespace(description);
this.UniqueID = this.NormalizeField(uniqueId);
this.Name = this.NormalizeField(name, replaceSquareBrackets: true);
this.Author = this.NormalizeField(author);
this.Description = this.NormalizeField(description);
this.Version = version;
this.MinimumApiVersion = minimumApiVersion;
this.EntryDll = this.NormalizeWhitespace(entryDll);
this.EntryDll = this.NormalizeField(entryDll);
this.ContentPackFor = contentPackFor;
this.Dependencies = dependencies ?? Array.Empty<IManifestDependency>();
this.UpdateKeys = updateKeys ?? Array.Empty<string>();
@ -113,17 +114,47 @@ namespace StardewModdingAPI.Toolkit.Serialization.Models
/*********
** Private methods
*********/
/// <summary>Normalize whitespace in a raw string.</summary>
/// <summary>Normalize a manifest field to strip newlines, trim whitespace, and optionally strip square brackets.</summary>
/// <param name="input">The input to strip.</param>
/// <param name="replaceSquareBrackets">Whether to replace square brackets with round ones. This is used in the mod name to avoid breaking the log format.</param>
#if NET5_0_OR_GREATER
[return: NotNullIfNotNull("input")]
#endif
private string? NormalizeWhitespace(string? input)
private string? NormalizeField(string? input, bool replaceSquareBrackets = false)
{
return input
?.Trim()
.Replace("\r", "")
.Replace("\n", "");
input = input?.Trim();
if (!string.IsNullOrEmpty(input))
{
StringBuilder? builder = null;
for (int i = 0; i < input.Length; i++)
{
switch (input[i])
{
case '\r':
case '\n':
builder ??= new StringBuilder(input);
builder[i] = ' ';
break;
case '[' when replaceSquareBrackets:
builder ??= new StringBuilder(input);
builder[i] = '(';
break;
case ']' when replaceSquareBrackets:
builder ??= new StringBuilder(input);
builder[i] = ')';
break;
}
}
if (builder != null)
input = builder.ToString();
}
return input;
}
}
}

View File

@ -159,11 +159,20 @@ namespace StardewModdingAPI.Web.Controllers
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
if (this.IsNewer(data.Version, main?.Version))
main = new ModEntryVersionModel(data.Version, data.Url!);
if (this.IsNewer(data.PreviewVersion, optional?.Version))
optional = new ModEntryVersionModel(data.PreviewVersion, data.Url!);
if (this.IsNewer(curMain, main?.Version))
main = new ModEntryVersionModel(curMain, data.Url!);
if (this.IsNewer(curPreview, optional?.Version))
optional = new ModEntryVersionModel(curPreview, data.Url!);
}
// 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')]");
if (node != null)
{
string[] errorParts = node.InnerText.Trim().Split(new[] { '\n' }, 2, System.StringSplitOptions.RemoveEmptyEntries);
string[] errorParts = node.InnerText.Trim().Split('\n', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
string errorCode = errorParts[0];
string? errorText = errorParts.Length > 1 ? errorParts[1] : null;
switch (errorCode.Trim().ToLower())
switch (errorCode.ToLower())
{
case "not found":
return null;

View File

@ -199,8 +199,15 @@ namespace StardewModdingAPI.Web.Framework.LogParsing
log.ApiVersion = match.Groups["apiVersion"].Value;
log.GameVersion = match.Groups["gameVersion"].Value;
log.OperatingSystem = match.Groups["os"].Value;
smapiMod.OverrideVersion(log.ApiVersion);
const string strictModeSuffix = " (strict mode)";
if (log.ApiVersion.EndsWith(strictModeSuffix))
{
log.IsStrictMode = true;
log.ApiVersion = log.ApiVersion[..^strictModeSuffix.Length];
}
smapiMod.OverrideVersion(log.ApiVersion);
log.ApiVersionParsed = smapiMod.GetParsedVersion();
}

View File

@ -25,6 +25,9 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models
/****
** Log data
****/
/// <summary>Whether SMAPI is running in strict mode, which disables all deprecated APIs.</summary>
public bool IsStrictMode { get; set; }
/// <summary>The SMAPI version.</summary>
public string? ApiVersion { get; set; }

View File

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

View File

@ -40,7 +40,7 @@
<meta name="robots" content="noindex" />
}
<link rel="stylesheet" href="~/Content/css/file-upload.css" />
<link rel="stylesheet" href="~/Content/css/log-parser.css" />
<link rel="stylesheet" href="~/Content/css/log-parser.css?r=20221009" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tabbyjs@12.0.3/dist/css/tabby-ui-vertical.min.css" />
<script src="https://cdn.jsdelivr.net/npm/tabbyjs@12.0.3" crossorigin="anonymous"></script>
@ -243,7 +243,7 @@ else if (log?.IsValid == true)
@if (log?.IsValid == true)
{
<div id="output">
@if (outdatedMods.Any() || errorHandler is null || hasOlderErrorHandler || isPyTkCompatibilityMode)
@if (outdatedMods.Any() || errorHandler is null || hasOlderErrorHandler || isPyTkCompatibilityMode || log.IsStrictMode)
{
<h2>Suggested fixes</h2>
<ul id="fix-list">
@ -256,9 +256,16 @@ else if (log?.IsValid == true)
<li>Your <strong>Error Handler</strong> mod is older than SMAPI. You may be missing some game/mod error fixes. You can <a href="https://stardewvalleywiki.com/Modding:Player_Guide#Install_SMAPI">reinstall SMAPI</a> to update it.</li>
}
@if (isPyTkCompatibilityMode)
{
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())
{
<li>
@ -307,6 +314,10 @@ else if (log?.IsValid == true)
</table>
</li>
}
@if (log.IsStrictMode)
{
<li class="notice">SMAPI is running in 'strict mode', which removes all deprecated APIs. This can significantly improve performance, but some mods may not work. You can <a href="https://stardewvalleywiki.com/Modding:Player_Guide#Install_SMAPI">reinstall SMAPI</a> to disable it if you run into problems.</li>
}
</ul>
}
@ -329,7 +340,13 @@ else if (log?.IsValid == true)
</tr>
<tr>
<th>SMAPI:</th>
<td v-pre>@log.ApiVersion</td>
<td v-pre>
@log.ApiVersion
@if (log.IsStrictMode)
{
<strong>(strict mode)</strong>
}
</td>
</tr>
<tr>
<th>Folder:</th>
@ -361,7 +378,7 @@ else if (log?.IsValid == true)
ContentPacks: contentPacks?.TryGetValue(mod.Name, out LogModInfo[]? contentPackList) == true ? contentPackList : Array.Empty<LogModInfo>()
))
.ToList();
if (contentPacks?.TryGetValue("", out LogModInfo[] invalidPacks) == true)
if (contentPacks?.TryGetValue("", out LogModInfo[]? invalidPacks) == true)
{
modsWithContentPacks.Add((
Mod: new LogModInfo(ModType.CodeMod, "<invalid content packs>", "", "", ""),

View File

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

View File

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

View File

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

View File

@ -34,9 +34,6 @@ namespace StardewModdingAPI.Framework
/// <summary>An asset key prefix for assets from SMAPI mod folders.</summary>
private readonly string ManagedPrefix = "SMAPI";
/// <summary>Whether to use raw image data when possible, instead of initializing an XNA Texture2D instance through the GPU.</summary>
private readonly bool UseRawImageLoading;
/// <summary>Get a file lookup for the given directory.</summary>
private readonly Func<string, IFileLookup> GetFileLookup;
@ -139,8 +136,7 @@ namespace StardewModdingAPI.Framework
/// <param name="getFileLookup">Get a file lookup for the given directory.</param>
/// <param name="onAssetsInvalidated">A callback to invoke when any asset names have been invalidated from the cache.</param>
/// <param name="requestAssetOperations">Get the load/edit operations to apply to an asset by querying registered <see cref="IContentEvents.AssetRequested"/> event handlers.</param>
/// <param name="useRawImageLoading">Whether to use raw image data when possible, instead of initializing an XNA Texture2D instance through the GPU.</param>
public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, Action<BaseContentManager, IAssetName> onAssetLoaded, Func<string, IFileLookup> getFileLookup, Action<IList<IAssetName>> onAssetsInvalidated, Func<IAssetInfo, AssetOperationGroup?> requestAssetOperations, bool useRawImageLoading)
public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, Action<BaseContentManager, IAssetName> onAssetLoaded, Func<string, IFileLookup> getFileLookup, Action<IList<IAssetName>> onAssetsInvalidated, Func<IAssetInfo, AssetOperationGroup?> requestAssetOperations)
{
this.GetFileLookup = getFileLookup;
this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
@ -151,7 +147,6 @@ namespace StardewModdingAPI.Framework
this.OnAssetsInvalidated = onAssetsInvalidated;
this.RequestAssetOperations = requestAssetOperations;
this.FullRootDirectory = Path.Combine(Constants.GamePath, rootDirectory);
this.UseRawImageLoading = useRawImageLoading;
this.ContentManagers.Add(
this.MainContentManager = new GameContentManager(
name: "Game1.content",
@ -230,8 +225,7 @@ namespace StardewModdingAPI.Framework
reflection: this.Reflection,
jsonHelper: this.JsonHelper,
onDisposing: this.OnDisposing,
fileLookup: this.GetFileLookup(rootDirectory),
useRawImageLoading: this.UseRawImageLoading
fileLookup: this.GetFileLookup(rootDirectory)
);
this.ContentManagers.Add(manager);
return manager;

View File

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

View File

@ -1,9 +1,11 @@
using System;
using System.Buffers;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using BmFont;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Content;
@ -28,9 +30,6 @@ namespace StardewModdingAPI.Framework.ContentManagers
/*********
** Fields
*********/
/// <summary>Whether to use raw image data when possible, instead of initializing an XNA Texture2D instance through the GPU.</summary>
private readonly bool UseRawImageLoading;
/// <summary>Encapsulates SMAPI's JSON file parsing.</summary>
private readonly JsonHelper JsonHelper;
@ -72,15 +71,13 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param>
/// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param>
/// <param name="fileLookup">A lookup for files within the <paramref name="rootDirectory"/>.</param>
/// <param name="useRawImageLoading">Whether to use raw image data when possible, instead of initializing an XNA Texture2D instance through the GPU.</param>
public ModContentManager(string name, IContentManager gameContentManager, IServiceProvider serviceProvider, string modName, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action<BaseContentManager> onDisposing, IFileLookup fileLookup, bool useRawImageLoading)
public ModContentManager(string name, IContentManager gameContentManager, IServiceProvider serviceProvider, string modName, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action<BaseContentManager> onDisposing, IFileLookup fileLookup)
: base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: true)
{
this.GameContentManager = gameContentManager;
this.FileLookup = fileLookup;
this.JsonHelper = jsonHelper;
this.ModName = modName;
this.UseRawImageLoading = useRawImageLoading;
this.TryLocalizeKeys = false;
}
@ -111,7 +108,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
if (this.Coordinator.TryParseManagedAssetKey(assetName.Name, out string? contentManagerID, out IAssetName? relativePath))
{
if (contentManagerID != this.Name)
throw this.GetLoadError(assetName, ContentLoadErrorType.AccessDenied, "can't load a different mod's managed asset key through this mod content manager.");
this.ThrowLoadError(assetName, ContentLoadErrorType.AccessDenied, "can't load a different mod's managed asset key through this mod content manager.");
assetName = relativePath;
}
}
@ -123,7 +120,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
// get file
FileInfo file = this.GetModFile<T>(assetName.Name);
if (!file.Exists)
throw this.GetLoadError(assetName, ContentLoadErrorType.AssetDoesNotExist, "the specified path doesn't exist.");
this.ThrowLoadError(assetName, ContentLoadErrorType.AssetDoesNotExist, "the specified path doesn't exist.");
// load content
asset = file.Extension.ToLower() switch
@ -141,7 +138,8 @@ namespace StardewModdingAPI.Framework.ContentManagers
if (ex is SContentLoadException)
throw;
throw this.GetLoadError(assetName, ContentLoadErrorType.Other, "an unexpected error occurred.", ex);
this.ThrowLoadError(assetName, ContentLoadErrorType.Other, "an unexpected error occurred.", ex);
return default;
}
// track & return asset
@ -189,7 +187,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
private T LoadDataFile<T>(IAssetName assetName, FileInfo file)
{
if (!this.JsonHelper.ReadJsonFileIfExists(file.FullName, out T? asset))
throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, "the JSON file is invalid."); // should never happen since we check for file existence before calling this method
this.ThrowLoadError(assetName, ContentLoadErrorType.InvalidData, "the JSON file is invalid."); // should never happen since we check for file existence before calling this method
return asset;
}
@ -201,48 +199,52 @@ namespace StardewModdingAPI.Framework.ContentManagers
private T LoadImageFile<T>(IAssetName assetName, FileInfo file)
{
this.AssertValidType<T>(assetName, file, typeof(Texture2D), typeof(IRawTextureData));
bool expectsRawData = typeof(T).IsAssignableTo(typeof(IRawTextureData));
bool asRawData = expectsRawData || this.UseRawImageLoading;
bool returnRawData = typeof(T).IsAssignableTo(typeof(IRawTextureData));
#if SMAPI_DEPRECATED
// disable raw data if PyTK will rescale the image (until it supports raw data)
if (asRawData && !expectsRawData)
if (!returnRawData && this.ShouldDisableIntermediateRawDataLoad<T>(assetName, file))
{
using FileStream stream = File.OpenRead(file.FullName);
Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream).SetName(assetName);
this.PremultiplyTransparency(texture);
return (T)(object)texture;
}
#endif
IRawTextureData raw = this.LoadRawImageData(file, returnRawData);
if (returnRawData)
return (T)raw;
else
{
Texture2D texture = new Texture2D(Game1.graphics.GraphicsDevice, raw.Width, raw.Height).SetName(assetName);
texture.SetData(raw.Data);
return (T)(object)texture;
}
}
#if SMAPI_DEPRECATED
/// <summary>Get whether to disable loading an image as <see cref="IRawTextureData"/> before building a <see cref="Texture2D"/> instance. This isn't called if the mod requested <see cref="IRawTextureData"/> directly.</summary>
/// <typeparam name="T">The type of asset being loaded.</typeparam>
/// <param name="assetName">The asset name relative to the loader root directory.</param>
/// <param name="file">The file being loaded.</param>
private bool ShouldDisableIntermediateRawDataLoad<T>(IAssetName assetName, FileInfo file)
{
// disable raw data if PyTK will rescale the image (until it supports raw data)
if (ModContentManager.EnablePyTkLegacyMode)
{
// PyTK intercepts Texture2D file loads to rescale them (e.g. for HD portraits),
// but doesn't support IRawTextureData loads yet. We can't just check if the
// current file has a '.pytk.json' rescale file though, since PyTK may still
// rescale it if the original asset or another edit gets rescaled.
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);
return true;
}
return false;
}
#endif
// load
if (asRawData)
{
IRawTextureData raw = this.LoadRawImageData(file, expectsRawData);
if (expectsRawData)
return (T)raw;
else
{
Texture2D texture = new(Game1.graphics.GraphicsDevice, raw.Width, raw.Height);
texture.SetData(raw.Data);
return (T)(object)texture;
}
}
else
{
using FileStream stream = File.OpenRead(file.FullName);
Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream);
texture = this.PremultiplyTransparency(texture);
return (T)(object)texture;
}
}
/// <summary>Load the raw image data from a file on disk.</summary>
/// <param name="file">The file whose data to load.</param>
/// <param name="forRawData">Whether the data is being loaded for an <see cref="IRawTextureData"/> (true) or <see cref="Texture2D"/> (false) instance.</param>
@ -301,7 +303,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
private T LoadXnbFile<T>(IAssetName assetName)
{
if (typeof(IRawTextureData).IsAssignableFrom(typeof(T)))
throw this.GetLoadError(assetName, ContentLoadErrorType.Other, $"can't read XNB file as type {typeof(IRawTextureData)}; that type can only be read from a PNG file.");
this.ThrowLoadError(assetName, ContentLoadErrorType.Other, $"can't read XNB file as type {typeof(IRawTextureData)}; that type can only be read from a PNG file.");
// the underlying content manager adds a .xnb extension implicitly, so
// we need to strip it here to avoid trying to load a '.xnb.xnb' file.
@ -326,7 +328,8 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <param name="file">The file to load.</param>
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>
@ -338,18 +341,21 @@ namespace StardewModdingAPI.Framework.ContentManagers
private void AssertValidType<TAsset>(IAssetName assetName, FileInfo file, params Type[] validTypes)
{
if (!validTypes.Any(validType => validType.IsAssignableFrom(typeof(TAsset))))
throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(TAsset)}'; must be type '{string.Join("' or '", validTypes.Select(p => p.FullName))}'.");
this.ThrowLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(TAsset)}'; must be type '{string.Join("' or '", validTypes.Select(p => p.FullName))}'.");
}
/// <summary>Get an error which indicates that an asset couldn't be loaded.</summary>
/// <summary>Throw an error which indicates that an asset couldn't be loaded.</summary>
/// <param name="errorType">Why loading an asset through the content pipeline failed.</param>
/// <param name="assetName">The asset name that failed to load.</param>
/// <param name="reasonPhrase">The reason the file couldn't be loaded.</param>
/// <param name="exception">The underlying exception, if applicable.</param>
/// <exception cref="SContentLoadException" />
[DoesNotReturn]
[DebuggerStepThrough, DebuggerHidden]
private SContentLoadException GetLoadError(IAssetName assetName, ContentLoadErrorType errorType, string reasonPhrase, Exception? exception = null)
[MethodImpl(MethodImplOptions.NoInlining)]
private void ThrowLoadError(IAssetName assetName, ContentLoadErrorType errorType, string reasonPhrase, Exception? exception = null)
{
return new(errorType, $"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}", exception);
throw new SContentLoadException(errorType, $"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}", exception);
}
/// <summary>Get a file from the mod folder.</summary>
@ -381,15 +387,18 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <param name="texture">The texture to premultiply.</param>
/// <returns>Returns a premultiplied texture.</returns>
/// <remarks>Based on <a href="https://gamedev.stackexchange.com/a/26037">code by David Gouveia</a>.</remarks>
private Texture2D PremultiplyTransparency(Texture2D texture)
private void PremultiplyTransparency(Texture2D texture)
{
// premultiply pixels
Color[] data = GC.AllocateUninitializedArray<Color>(texture.Width * texture.Height);
texture.GetData(data);
int count = texture.Width * texture.Height;
Color[] data = ArrayPool<Color>.Shared.Rent(count);
try
{
texture.GetData(data, 0, count);
bool changed = false;
for (int i = 0; i < data.Length; i++)
for (int i = 0; i < count; i++)
{
Color pixel = data[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
@ -398,9 +407,12 @@ namespace StardewModdingAPI.Framework.ContentManagers
}
if (changed)
texture.SetData(data);
return texture;
texture.SetData(data, 0, count);
}
finally
{
ArrayPool<Color>.Shared.Return(data);
}
}
/// <summary>Fix custom map tilesheet paths so they can be found by the content manager.</summary>

View File

@ -101,7 +101,7 @@ namespace StardewModdingAPI.Framework.Deprecations
foreach (DeprecationWarning warning in this.QueuedWarnings.OrderBy(p => p.ModName).ThenBy(p => p.NounPhrase))
{
// build message
string message = $"{warning.ModName} uses deprecated code ({warning.NounPhrase} is deprecated since SMAPI {warning.Version}).";
string message = $"{warning.ModName} uses deprecated code ({warning.NounPhrase}) and will break in the upcoming SMAPI 4.0.0.";
// get log level
LogLevel level;

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using Microsoft.Xna.Framework.Graphics;
@ -163,5 +164,34 @@ namespace StardewModdingAPI.Framework
{
return reflection.GetField<bool>(spriteBatch, "_beginCalled").GetValue();
}
/****
** Texture2D
****/
/// <summary>Set the texture name field.</summary>
/// <param name="texture">The texture whose name to set.</param>
/// <param name="assetName">The asset name to set.</param>
/// <returns>Returns the texture for chaining.</returns>
[return: NotNullIfNotNull("texture")]
public static Texture2D? SetName(this Texture2D? texture, IAssetName assetName)
{
if (texture != null)
texture.Name = assetName.Name;
return texture;
}
/// <summary>Set the texture name field.</summary>
/// <param name="texture">The texture whose name to set.</param>
/// <param name="assetName">The asset name to set.</param>
/// <returns>Returns the texture for chaining.</returns>
[return: NotNullIfNotNull("texture")]
public static Texture2D? SetName(this Texture2D? texture, string assetName)
{
if (texture != null)
texture.Name = assetName;
return texture;
}
}
}

View File

@ -223,7 +223,7 @@ namespace StardewModdingAPI.Framework.Logging
// show update alert
if (File.Exists(Constants.UpdateMarker))
{
string[] rawUpdateFound = File.ReadAllText(Constants.UpdateMarker).Split(new[] { '|' }, 2);
string[] rawUpdateFound = File.ReadAllText(Constants.UpdateMarker).Split('|', 2);
if (SemanticVersion.TryParse(rawUpdateFound[0], out ISemanticVersion? updateFound))
{
if (Constants.ApiVersion.IsPrerelease() && updateFound.IsNewerThan(Constants.ApiVersion))
@ -269,7 +269,11 @@ namespace StardewModdingAPI.Framework.Logging
public void LogIntro(string modsPath, IDictionary<string, object?> customSettings)
{
// log platform
this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} (build {Constants.GetBuildVersionLabel()}) on {EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform)}", LogLevel.Info);
this.Monitor.Log($"SMAPI {Constants.ApiVersion} "
#if !SMAPI_DEPRECATED
+ "(strict mode) "
#endif
+ $"with Stardew Valley {Constants.GameVersion} (build {Constants.GetBuildVersionLabel()}) on {EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform)}", LogLevel.Info);
// log basic info
this.Monitor.Log($"Mods go here: {modsPath}", LogLevel.Info);
@ -280,6 +284,10 @@ namespace StardewModdingAPI.Framework.Logging
// log custom settings
if (customSettings.Any())
this.Monitor.Log($"Loaded with custom settings: {string.Join(", ", customSettings.OrderBy(p => p.Key).Select(p => $"{p.Key}: {p.Value}"))}");
#if !SMAPI_DEPRECATED
this.Monitor.Log("SMAPI is running in 'strict mode', which removes all deprecated APIs. This can significantly improve performance, but some mods may not work. You can reinstall SMAPI to disable it if you run into problems.", LogLevel.Info);
#endif
}
/// <summary>Log details for settings that don't match the default.</summary>

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 StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Internal;
namespace StardewModdingAPI.Framework.ModHelpers
{
@ -15,8 +17,8 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <summary>Encapsulates monitoring and logging for the mod.</summary>
private readonly IMonitor Monitor;
/// <summary>The mod IDs for APIs accessed by this instanced.</summary>
private readonly HashSet<string> AccessedModApis = new();
/// <summary>The APIs accessed by this instance.</summary>
private readonly Dictionary<string, object?> AccessedModApis = new();
/// <summary>Generates proxy classes to access mod APIs through an arbitrary interface.</summary>
private readonly IInterfaceProxyFactory ProxyFactory;
@ -66,11 +68,44 @@ namespace StardewModdingAPI.Framework.ModHelpers
return null;
}
// get raw API
// get the target mod
IModMetadata? mod = this.Registry.Get(uniqueID);
if (mod?.Api != null && this.AccessedModApis.Add(mod.Manifest.UniqueID))
this.Monitor.Log($"Accessed mod-provided API for {mod.DisplayName}.");
return mod?.Api;
if (mod == null)
return null;
// fetch API
if (!this.AccessedModApis.TryGetValue(mod.Manifest.UniqueID, out object? api))
{
// if the target has a global API, this is mutually exclusive with per-mod APIs
if (mod.Api != null)
api = mod.Api;
// else try to get a per-mod API
else
{
try
{
api = mod.Mod?.GetApi(this.Mod);
if (api != null && !api.GetType().IsPublic)
{
api = null;
this.Monitor.Log($"{mod.DisplayName} provides a per-mod API instance with a non-public type. This isn't currently supported, so the API won't be available to other mods.", LogLevel.Warn);
}
}
catch (Exception ex)
{
this.Monitor.Log($"Failed loading the per-mod API instance from {mod.DisplayName}. Integrations with other mods may not work. Error: {ex.GetLogSummary()}", LogLevel.Error);
api = null;
}
}
// cache & log API access
this.AccessedModApis[mod.Manifest.UniqueID] = api;
if (api != null)
this.Monitor.Log($"Accessed mod-provided API ({api.GetType().FullName}) for {mod.DisplayName}.");
}
return api;
}
/// <inheritdoc />

View File

@ -221,7 +221,7 @@ namespace StardewModdingAPI.Framework.ModLoading
/// </remarks>
public static Assembly? ResolveAssembly(string name)
{
string shortName = name.Split(new[] { ',' }, 2).First(); // get simple name (without version and culture)
string shortName = name.Split(',', 2).First(); // get simple name (without version and culture)
return AppDomain.CurrentDomain
.GetAssemblies()
.FirstOrDefault(p => p.GetName().Name == shortName);

View File

@ -58,7 +58,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
MethodReference? methodRef = RewriteHelper.AsMethodReference(instruction);
if (methodRef != null && methodRef.DeclaringType.FullName == this.FullTypeName && this.MethodNames.Contains(methodRef.Name))
{
string eventName = methodRef.Name.Split(new[] { '_' }, 2)[1];
string eventName = methodRef.Name.Split('_', 2)[1];
this.MethodNames.Remove($"add_{eventName}");
this.MethodNames.Remove($"remove_{eventName}");

View File

@ -22,8 +22,8 @@ namespace StardewModdingAPI.Framework.Models
[nameof(WebApiBaseUrl)] = "https://smapi.io/api/",
[nameof(LogNetworkTraffic)] = false,
[nameof(RewriteMods)] = true,
[nameof(UseRawImageLoading)] = true,
[nameof(UseCaseInsensitivePaths)] = Constants.Platform is Platform.Android or Platform.Linux
[nameof(UseCaseInsensitivePaths)] = Constants.Platform is Platform.Android or Platform.Linux,
[nameof(SuppressHarmonyDebugMode)] = true
};
/// <summary>The default values for <see cref="SuppressUpdateChecks"/>, to log changes if different.</summary>
@ -67,9 +67,6 @@ namespace StardewModdingAPI.Framework.Models
/// <summary>Whether SMAPI should rewrite mods for compatibility.</summary>
public bool RewriteMods { get; set; }
/// <summary>Whether to use raw image data when possible, instead of initializing an XNA Texture2D instance through the GPU.</summary>
public bool UseRawImageLoading { get; set; }
/// <summary>Whether to make SMAPI file APIs case-insensitive, even on Linux.</summary>
public bool UseCaseInsensitivePaths { get; set; }
@ -79,6 +76,9 @@ namespace StardewModdingAPI.Framework.Models
/// <summary>The colors to use for text written to the SMAPI console.</summary>
public ColorSchemeConfig ConsoleColors { get; set; }
/// <summary>Whether to prevent mods from enabling Harmony's debug mode, which impacts performance and creates a file on your desktop. Debug mode should never be enabled by a released mod.</summary>
public bool SuppressHarmonyDebugMode { get; set; }
/// <summary>The mod IDs SMAPI should ignore when performing update checks or validating update keys.</summary>
public HashSet<string> SuppressUpdateChecks { get; set; }
@ -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="verboseLogging">The log contexts for which to enable verbose logging, which may show a lot more information to simplify troubleshooting.</param>
/// <param name="rewriteMods">Whether SMAPI should rewrite mods for compatibility.</param>
/// <param name="useRawImageLoading">Whether to use raw image data when possible, instead of initializing an XNA Texture2D instance through the GPU.</param>
/// <param name="useCaseInsensitivePaths">>Whether to make SMAPI file APIs case-insensitive, even on Linux.</param>
/// <param name="logNetworkTraffic">Whether SMAPI should log network traffic.</param>
/// <param name="consoleColors">The colors to use for text written to the SMAPI console.</param>
/// <param name="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>
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.CheckForUpdates = checkForUpdates ?? (bool)SConfig.DefaultValues[nameof(this.CheckForUpdates)];
@ -110,10 +110,10 @@ namespace StardewModdingAPI.Framework.Models
this.WebApiBaseUrl = webApiBaseUrl;
this.VerboseLogging = new HashSet<string>(verboseLogging ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase);
this.RewriteMods = rewriteMods ?? (bool)SConfig.DefaultValues[nameof(this.RewriteMods)];
this.UseRawImageLoading = useRawImageLoading ?? (bool)SConfig.DefaultValues[nameof(this.UseRawImageLoading)];
this.UseCaseInsensitivePaths = useCaseInsensitivePaths ?? (bool)SConfig.DefaultValues[nameof(this.UseCaseInsensitivePaths)];
this.LogNetworkTraffic = logNetworkTraffic ?? (bool)SConfig.DefaultValues[nameof(this.LogNetworkTraffic)];
this.ConsoleColors = consoleColors;
this.SuppressHarmonyDebugMode = suppressHarmonyDebugMode ?? (bool)SConfig.DefaultValues[nameof(this.SuppressHarmonyDebugMode)];
this.SuppressUpdateChecks = new HashSet<string>(suppressUpdateChecks ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase);
}

View File

@ -25,10 +25,13 @@ namespace StardewModdingAPI.Framework
private readonly LogFileManager LogFile;
/// <summary>The maximum length of the <see cref="LogLevel"/> values.</summary>
private static readonly int MaxLevelLength = (from level in Enum.GetValues(typeof(LogLevel)).Cast<LogLevel>() select level.ToString().Length).Max();
private static readonly int MaxLevelLength = Enum.GetValues<LogLevel>().Max(level => level.ToString().Length);
/// <summary>The cached representation for each level when added to a log header.</summary>
private static readonly Dictionary<ConsoleLogLevel, string> LogStrings = Enum.GetValues<ConsoleLogLevel>().ToDictionary(level => level, level => level.ToString().ToUpper().PadRight(Monitor.MaxLevelLength));
/// <summary>A cache of messages that should only be logged once.</summary>
private readonly HashSet<string> LogOnceCache = new();
private readonly HashSet<LogOnceCacheKey> LogOnceCache = new();
/// <summary>Get the screen ID that should be logged to distinguish between players in split-screen mode, if any.</summary>
private readonly Func<int?> GetScreenIdForLog;
@ -84,7 +87,7 @@ namespace StardewModdingAPI.Framework
/// <inheritdoc />
public void LogOnce(string message, LogLevel level = LogLevel.Trace)
{
if (this.LogOnceCache.Add($"{message}|{level}"))
if (this.LogOnceCache.Add(new LogOnceCacheKey(message, level)))
this.LogImpl(this.Source, message, (ConsoleLogLevel)level);
}
@ -147,7 +150,7 @@ namespace StardewModdingAPI.Framework
/// <param name="level">The log level.</param>
private string GenerateMessagePrefix(string source, ConsoleLogLevel level)
{
string levelStr = level.ToString().ToUpper().PadRight(Monitor.MaxLevelLength);
string levelStr = Monitor.LogStrings[level];
int? playerIndex = this.GetScreenIdForLog();
return $"[{DateTime.Now:HH:mm:ss} {levelStr}{(playerIndex != null ? $" screen_{playerIndex}" : "")} {source}]";

View File

@ -501,6 +501,15 @@ namespace StardewModdingAPI.Framework
return;
}
/*********
** Prevent Harmony debug mode
*********/
if (HarmonyLib.Harmony.DEBUG && this.Settings.SuppressHarmonyDebugMode)
{
HarmonyLib.Harmony.DEBUG = false;
this.Monitor.LogOnce("A mod enabled Harmony debug mode, which impacts performance and creates a file on your desktop. SMAPI will try to keep it disabled. (You can allow debug mode by editing the smapi-internal/config.json file.)", LogLevel.Warn);
}
#if SMAPI_DEPRECATED
/*********
** Reload assets when interceptors are added/removed
@ -1324,8 +1333,7 @@ namespace StardewModdingAPI.Framework
onAssetLoaded: this.OnAssetLoaded,
onAssetsInvalidated: this.OnAssetsInvalidated,
getFileLookup: this.GetFileLookup,
requestAssetOperations: this.RequestAssetOperations,
useRawImageLoading: this.Settings.UseRawImageLoading
requestAssetOperations: this.RequestAssetOperations
);
if (this.ContentCore.Language != this.Translator.LocaleEnum)
this.Translator.SetLocale(this.ContentCore.GetLocale(), this.ContentCore.Language);
@ -1384,7 +1392,7 @@ namespace StardewModdingAPI.Framework
}
// check min length for specific types
switch (fields[SObject.objectInfoTypeIndex].Split(new[] { ' ' }, 2)[0])
switch (fields[SObject.objectInfoTypeIndex].Split(' ', 2)[0])
{
case "Cooking":
if (fields.Length < SObject.objectInfoBuffDurationIndex + 1)
@ -1672,26 +1680,33 @@ namespace StardewModdingAPI.Framework
// initialize translations
this.ReloadTranslations(loaded);
#if SMAPI_DEPRECATED
// set temporary PyTK compatibility mode
// This is part of a three-part fix for PyTK 1.23.* and earlier. When removing this,
// search 'Platonymous.Toolkit' to find the other part in SMAPI and Content Patcher.
{
IModInfo? pyTk = this.ModRegistry.Get("Platonymous.Toolkit");
ModContentManager.EnablePyTkLegacyMode = pyTk is not null && pyTk.Manifest.Version.IsOlderThan("1.24.0");
}
if (pyTk is not null && pyTk.Manifest.Version.IsOlderThan("1.24.0"))
#if SMAPI_DEPRECATED
ModContentManager.EnablePyTkLegacyMode = true;
#else
this.Monitor.Log("PyTK's image scaling is not compatible with SMAPI strict mode.", LogLevel.Warn);
#endif
}
// initialize loaded non-content-pack mods
this.Monitor.Log("Launching mods...", LogLevel.Debug);
foreach (IModMetadata metadata in loadedMods)
{
IMod mod =
metadata.Mod
?? throw new InvalidOperationException($"The '{metadata.DisplayName}' mod is not initialized correctly."); // should never happen, but avoids nullability warnings
#if SMAPI_DEPRECATED
// add interceptors
if (metadata.Mod?.Helper is ModHelper helper)
if (mod.Helper is ModHelper helper)
{
// ReSharper disable SuspiciousTypeConversion.Global
if (metadata.Mod is IAssetEditor editor)
if (mod is IAssetEditor editor)
{
SCore.DeprecationManager.Warn(
source: metadata,
@ -1704,7 +1719,7 @@ namespace StardewModdingAPI.Framework
this.ContentCore.Editors.Add(new ModLinked<IAssetEditor>(metadata, editor));
}
if (metadata.Mod is IAssetLoader loader)
if (mod is IAssetLoader loader)
{
SCore.DeprecationManager.Warn(
source: metadata,
@ -1749,11 +1764,12 @@ namespace StardewModdingAPI.Framework
}
#endif
// call entry method
// initialize mod
Context.HeuristicModsRunningCode.Push(metadata);
{
// call entry method
try
{
IMod mod = metadata.Mod!;
mod.Entry(mod.Helper!);
}
catch (Exception ex)
@ -1764,7 +1780,7 @@ namespace StardewModdingAPI.Framework
// get mod API
try
{
object? api = metadata.Mod!.GetApi();
object? api = mod.GetApi();
if (api != null && !api.GetType().IsPublic)
{
api = null;
@ -1779,6 +1795,11 @@ namespace StardewModdingAPI.Framework
{
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 _);
}

View File

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

View File

@ -23,7 +23,15 @@ namespace StardewModdingAPI
/// <param name="helper">Provides simplified APIs for writing mods.</param>
void Entry(IModHelper helper);
/// <summary>Get an API that other mods can access. This is always called after <see cref="Entry"/>.</summary>
/// <summary>Get an <a href="https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Integrations">API that other mods can access</a>. This is always called after <see cref="Entry"/>, and is only called once even if multiple mods access it.</summary>
/// <remarks>You can implement <see cref="GetApi()"/> to provide one instance to all mods, or <see cref="GetApi(IModInfo)"/> to provide a separate instance per mod. These are mutually exclusive, so you can only implement one of them.</remarks>
/// <remarks>Returns the API instance, or <c>null</c> if the mod has no API.</remarks>
object? GetApi();
/// <summary>Get an <a href="https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Integrations">API that other mods can access</a>. This is always called after <see cref="Entry"/>, and is called once per mod that accesses the API (even if they access it multiple times).</summary>
/// <param name="mod">The mod accessing the API.</param>
/// <remarks>Returns the API instance, or <c>null</c> if the mod has no API. Note that <paramref name="mod"/> is provided for informational purposes only, and that denying API access to specific mods is strongly discouraged and may be considered abusive.</remarks>
/// <inheritdoc cref="GetApi()" include="/Remarks" />
object? GetApi(IModInfo mod);
}
}

View File

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

View File

@ -54,18 +54,6 @@ copy all the settings, or you may cause bugs due to overridden changes in future
*/
"UseCaseInsensitivePaths": null,
/**
* Whether to use the experimental Pintail API proxying library, instead of the original
* proxying built into SMAPI itself.
*/
"UsePintail": true,
/**
* Whether to use raw image data when possible, instead of initializing an XNA Texture2D
* instance through the GPU.
*/
"UseRawImageLoading": true,
/**
* Whether to add a section to the 'mod issues' list for mods which directly use potentially
* sensitive .NET APIs like file or shell access. Note that many mods do this legitimately as
@ -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.
*/

View File

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

View File

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