summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/release-notes.md4
-rw-r--r--docs/technical/mod-package.md2
-rw-r--r--src/SMAPI.ModBuildConfig/build/smapi.targets20
-rw-r--r--src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs4
-rw-r--r--src/SMAPI.Toolkit/SMAPI.Toolkit.csproj2
-rw-r--r--src/SMAPI.Toolkit/Serialization/Models/Manifest.cs53
-rw-r--r--src/SMAPI.Web/SMAPI.Web.csproj2
-rw-r--r--src/SMAPI/Framework/Content/AssetDataForImage.cs162
-rw-r--r--src/SMAPI/Framework/ContentManagers/ModContentManager.cs65
-rw-r--r--src/SMAPI/Framework/Logging/LogOnceCacheKey.cs10
-rw-r--r--src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs47
-rw-r--r--src/SMAPI/Framework/ModLoading/AssemblyLoader.cs2
-rw-r--r--src/SMAPI/Framework/Monitor.cs11
-rw-r--r--src/SMAPI/Framework/SCore.cs5
-rw-r--r--src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs2
-rw-r--r--src/SMAPI/IMod.cs10
-rw-r--r--src/SMAPI/Mod.cs6
-rw-r--r--src/SMAPI/SMAPI.csproj2
18 files changed, 297 insertions, 112 deletions
diff --git a/docs/release-notes.md b/docs/release-notes.md
index ea459bcb..4875d1cd 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -11,6 +11,10 @@
* For players:
* Fixed update alert shown for a prerelease version on GitHub if it's not marked as prerelease.
+* For mod authors:
+ * SMAPI now treats square brackets in the manifest `Name` field as round brackets, to avoid breaking tools which parse log files.
+ * Updated to [FluentHttpClient](https://github.com/Pathoschild/FluentHttpClient#readme) 4.2.0 (see [changes](https://github.com/Pathoschild/FluentHttpClient/blob/develop/RELEASE-NOTES.md#420)).
+
## 3.16.2
Released 31 August 2022 for Stardew Valley 1.5.6 or later.
diff --git a/docs/technical/mod-package.md b/docs/technical/mod-package.md
index ca78be55..c483754e 100644
--- a/docs/technical/mod-package.md
+++ b/docs/technical/mod-package.md
@@ -414,6 +414,8 @@ when you compile it.
## Release notes
### Upcoming release
* Switched to the newer crossplatform `portable` debug symbols (thanks to lanturnalis!).
+* Fixed `BundleExtraAssemblies` option being partly case-sensitive.
+* Fixed `BundleExtraAssemblies` not applying `All` value to game assemblies.
### 4.0.1
Released 14 April 2022.
diff --git a/src/SMAPI.ModBuildConfig/build/smapi.targets b/src/SMAPI.ModBuildConfig/build/smapi.targets
index 12619439..b4fd312e 100644
--- a/src/SMAPI.ModBuildConfig/build/smapi.targets
+++ b/src/SMAPI.ModBuildConfig/build/smapi.targets
@@ -27,8 +27,12 @@
<EnableGameDebugging Condition="'$(EnableGameDebugging)' == ''">true</EnableGameDebugging>
<BundleExtraAssemblies Condition="'$(BundleExtraAssemblies)' == ''"></BundleExtraAssemblies>
+ <!-- simplify conditions -->
+ <_BundleExtraAssembliesForGame>$([System.Text.RegularExpressions.Regex]::IsMatch('$(BundleExtraAssemblies)', '\bGame|All\b', RegexOptions.IgnoreCase))</_BundleExtraAssembliesForGame>
+ <_BundleExtraAssembliesForAny>$([System.Text.RegularExpressions.Regex]::IsMatch('$(BundleExtraAssemblies)', '\bGame|System|ThirdParty|All\b', RegexOptions.IgnoreCase))</_BundleExtraAssembliesForAny>
+
<!-- coppy referenced DLLs into build output -->
- <CopyLocalLockFileAssemblies Condition="$(BundleExtraAssemblies.Contains('ThirdParty')) OR $(BundleExtraAssemblies.Contains('Game')) OR $(BundleExtraAssemblies.Contains('System')) OR $(BundleExtraAssemblies.Contains('All'))">true</CopyLocalLockFileAssemblies>
+ <CopyLocalLockFileAssemblies Condition="$(_BundleExtraAssembliesForAny)">true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<PropertyGroup Condition="'$(OS)' == 'Windows_NT' AND '$(EnableGameDebugging)' == 'true'">
@@ -44,17 +48,17 @@
**********************************************-->
<ItemGroup>
<!-- game -->
- <Reference Include="Stardew Valley" HintPath="$(GamePath)\Stardew Valley.dll" Private="$(BundleExtraAssemblies.Contains('Game'))" />
- <Reference Include="StardewValley.GameData" HintPath="$(GamePath)\StardewValley.GameData.dll" Private="$(BundleExtraAssemblies.Contains('Game'))" />
- <Reference Include="MonoGame.Framework" HintPath="$(GamePath)\MonoGame.Framework.dll" Private="$(BundleExtraAssemblies.Contains('Game'))" />
- <Reference Include="xTile" HintPath="$(GamePath)\xTile.dll" Private="$(BundleExtraAssemblies.Contains('Game'))" />
+ <Reference Include="Stardew Valley" HintPath="$(GamePath)\Stardew Valley.dll" Private="$(_BundleExtraAssembliesForGame)" />
+ <Reference Include="StardewValley.GameData" HintPath="$(GamePath)\StardewValley.GameData.dll" Private="$(_BundleExtraAssembliesForGame)" />
+ <Reference Include="MonoGame.Framework" HintPath="$(GamePath)\MonoGame.Framework.dll" Private="$(_BundleExtraAssembliesForGame)" />
+ <Reference Include="xTile" HintPath="$(GamePath)\xTile.dll" Private="$(_BundleExtraAssembliesForGame)" />
<!-- SMAPI -->
- <Reference Include="StardewModdingAPI" HintPath="$(GamePath)\StardewModdingAPI.dll" Private="$(BundleExtraAssemblies.Contains('Game'))" />
- <Reference Include="SMAPI.Toolkit.CoreInterfaces" HintPath="$(GamePath)\smapi-internal\SMAPI.Toolkit.CoreInterfaces.dll" Private="$(BundleExtraAssemblies.Contains('Game'))" />
+ <Reference Include="StardewModdingAPI" HintPath="$(GamePath)\StardewModdingAPI.dll" Private="$(_BundleExtraAssembliesForGame)" />
+ <Reference Include="SMAPI.Toolkit.CoreInterfaces" HintPath="$(GamePath)\smapi-internal\SMAPI.Toolkit.CoreInterfaces.dll" Private="$(_BundleExtraAssembliesForGame)" />
<!-- Harmony -->
- <Reference Include="0Harmony" Condition="'$(EnableHarmony)' == 'true'" HintPath="$(GamePath)\smapi-internal\0Harmony.dll" Private="$(BundleExtraAssemblies.Contains('Game'))" />
+ <Reference Include="0Harmony" Condition="'$(EnableHarmony)' == 'true'" HintPath="$(GamePath)\smapi-internal\0Harmony.dll" Private="$(_BundleExtraAssembliesForGame)" />
</ItemGroup>
diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs
index a85ef109..d115810a 100644
--- a/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs
+++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs
@@ -45,10 +45,14 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning
".png",
".psd",
".tif",
+ ".xcf", // gimp files
// archives
".rar",
".zip",
+ ".7z",
+ ".tar",
+ ".tar.gz"
// backup files
".backup",
diff --git a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj
index 6080a85e..0086f38a 100644
--- a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj
+++ b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj
@@ -11,7 +11,7 @@
<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'" />
diff --git a/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs b/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs
index da3ad608..8a449f0a 100644
--- a/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs
+++ b/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
+using System.Text;
using Newtonsoft.Json;
using StardewModdingAPI.Toolkit.Serialization.Converters;
@@ -90,13 +91,13 @@ namespace StardewModdingAPI.Toolkit.Serialization.Models
[JsonConstructor]
public Manifest(string uniqueId, string name, string author, string description, ISemanticVersion version, ISemanticVersion? minimumApiVersion, string? entryDll, IManifestContentPackFor? contentPackFor, IManifestDependency[]? dependencies, string[]? updateKeys)
{
- this.UniqueID = this.NormalizeWhitespace(uniqueId);
- this.Name = this.NormalizeWhitespace(name);
- this.Author = this.NormalizeWhitespace(author);
- this.Description = this.NormalizeWhitespace(description);
+ this.UniqueID = this.NormalizeField(uniqueId);
+ this.Name = this.NormalizeField(name, replaceSquareBrackets: true);
+ this.Author = this.NormalizeField(author);
+ this.Description = this.NormalizeField(description);
this.Version = version;
this.MinimumApiVersion = minimumApiVersion;
- this.EntryDll = this.NormalizeWhitespace(entryDll);
+ this.EntryDll = this.NormalizeField(entryDll);
this.ContentPackFor = contentPackFor;
this.Dependencies = dependencies ?? Array.Empty<IManifestDependency>();
this.UpdateKeys = updateKeys ?? Array.Empty<string>();
@@ -113,17 +114,47 @@ namespace StardewModdingAPI.Toolkit.Serialization.Models
/*********
** Private methods
*********/
- /// <summary>Normalize whitespace in a raw string.</summary>
+ /// <summary>Normalize a manifest field to strip newlines, trim whitespace, and optionally strip square brackets.</summary>
/// <param name="input">The input to strip.</param>
+ /// <param name="replaceSquareBrackets">Whether to replace square brackets with round ones. This is used in the mod name to avoid breaking the log format.</param>
#if NET5_0_OR_GREATER
[return: NotNullIfNotNull("input")]
#endif
- private string? NormalizeWhitespace(string? input)
+ private string? NormalizeField(string? input, bool replaceSquareBrackets = false)
{
- return input
- ?.Trim()
- .Replace("\r", "")
- .Replace("\n", "");
+ input = input?.Trim();
+
+ if (!string.IsNullOrEmpty(input))
+ {
+ StringBuilder? builder = null;
+
+ for (int i = 0; i < input.Length; i++)
+ {
+ switch (input[i])
+ {
+ case '\r':
+ case '\n':
+ builder ??= new StringBuilder(input);
+ builder[i] = ' ';
+ break;
+
+ case '[' when replaceSquareBrackets:
+ builder ??= new StringBuilder(input);
+ builder[i] = '(';
+ break;
+
+ case ']' when replaceSquareBrackets:
+ builder ??= new StringBuilder(input);
+ builder[i] = ')';
+ break;
+ }
+ }
+
+ if (builder != null)
+ input = builder.ToString();
+ }
+
+ return input;
}
}
}
diff --git a/src/SMAPI.Web/SMAPI.Web.csproj b/src/SMAPI.Web/SMAPI.Web.csproj
index d26cb6f8..81b187fe 100644
--- a/src/SMAPI.Web/SMAPI.Web.csproj
+++ b/src/SMAPI.Web/SMAPI.Web.csproj
@@ -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" />
diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs
index 3393b22f..0380dd9e 100644
--- a/src/SMAPI/Framework/Content/AssetDataForImage.cs
+++ b/src/SMAPI/Framework/Content/AssetDataForImage.cs
@@ -1,4 +1,5 @@
using System;
+using System.Buffers;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
@@ -32,59 +33,70 @@ namespace StardewModdingAPI.Framework.Content
/// <inheritdoc />
public void PatchImage(IRawTextureData source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace)
{
- this.GetPatchBounds(ref sourceArea, ref targetArea, source.Width, source.Height);
-
- // validate source data
if (source == null)
throw new ArgumentNullException(nameof(source), "Can't patch from null source data.");
- // get the pixels for the source area
- Color[] sourceData;
+ // get normalized bounds
+ this.GetPatchBounds(ref sourceArea, ref targetArea, source.Width, source.Height);
+ if (source.Data.Length < (sourceArea.Value.Bottom - 1) * source.Width + sourceArea.Value.Right)
+ throw new ArgumentException("Can't apply image patch because the source image is smaller than the source area.", nameof(source));
+ int areaX = sourceArea.Value.X;
+ int areaY = sourceArea.Value.Y;
+ int areaWidth = sourceArea.Value.Width;
+ int areaHeight = sourceArea.Value.Height;
+
+ // shortcut: if the area width matches the source image, we can apply the image as-is without needing
+ // to copy the pixels into a smaller subset. It's fine if the source is taller than the area, since we'll
+ // just ignore the extra data at the end of the pixel array.
+ if (areaWidth == source.Width)
+ {
+ this.PatchImageImpl(source.Data, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode, areaY);
+ return;
+ }
+
+ // else copy the pixels within the smaller area & apply that
+ int pixelCount = areaWidth * areaHeight;
+ Color[] sourceData = ArrayPool<Color>.Shared.Rent(pixelCount);
+ try
{
- int areaX = sourceArea.Value.X;
- int areaY = sourceArea.Value.Y;
- int areaWidth = sourceArea.Value.Width;
- int areaHeight = sourceArea.Value.Height;
-
- if (areaX == 0 && areaY == 0 && areaWidth == source.Width && areaHeight == source.Height)
- sourceData = source.Data;
- else
+ for (int y = areaY, maxY = areaY + areaHeight; y < maxY; y++)
{
- sourceData = new Color[areaWidth * areaHeight];
- int i = 0;
- for (int y = areaY, maxY = areaY + areaHeight - 1; y <= maxY; y++)
- {
- for (int x = areaX, maxX = areaX + areaWidth - 1; x <= maxX; x++)
- {
- int targetIndex = (y * source.Width) + x;
- sourceData[i++] = source.Data[targetIndex];
- }
- }
+ int sourceIndex = (y * source.Width) + areaX;
+ int targetIndex = (y - areaY) * areaWidth;
+ Array.Copy(source.Data, sourceIndex, sourceData, targetIndex, areaWidth);
}
- }
- // apply
- this.PatchImageImpl(sourceData, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode);
+ this.PatchImageImpl(sourceData, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode);
+ }
+ finally
+ {
+ ArrayPool<Color>.Shared.Return(sourceData);
+ }
}
/// <inheritdoc />
public void PatchImage(Texture2D source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace)
{
- this.GetPatchBounds(ref sourceArea, ref targetArea, source.Width, source.Height);
-
- // validate source texture
if (source == null)
throw new ArgumentNullException(nameof(source), "Can't patch from a null source texture.");
+
+ // get normalized bounds
+ this.GetPatchBounds(ref sourceArea, ref targetArea, source.Width, source.Height);
if (!source.Bounds.Contains(sourceArea.Value))
throw new ArgumentOutOfRangeException(nameof(sourceArea), "The source area is outside the bounds of the source texture.");
- // get source data
+ // get source data & apply
int pixelCount = sourceArea.Value.Width * sourceArea.Value.Height;
- Color[] sourceData = GC.AllocateUninitializedArray<Color>(pixelCount);
- source.GetData(0, sourceArea, sourceData, 0, pixelCount);
-
- // apply
- this.PatchImageImpl(sourceData, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode);
+ Color[] sourceData = ArrayPool<Color>.Shared.Rent(pixelCount);
+ try
+ {
+ source.GetData(0, sourceArea, sourceData, 0, pixelCount);
+ this.PatchImageImpl(sourceData, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode);
+ }
+ finally
+ {
+ ArrayPool<Color>.Shared.Return(sourceData);
+ }
}
/// <inheritdoc />
@@ -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);
+ }
}
}
}
diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
index cc6f8372..72dcf6e1 100644
--- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
@@ -1,9 +1,11 @@
using System;
+using System.Buffers;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
+using System.Runtime.CompilerServices;
using BmFont;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Content;
@@ -111,7 +113,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 +125,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 +143,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 +192,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;
}
@@ -238,7 +241,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
{
using FileStream stream = File.OpenRead(file.FullName);
Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream);
- texture = this.PremultiplyTransparency(texture);
+ this.PremultiplyTransparency(texture);
return (T)(object)texture;
}
}
@@ -301,7 +304,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 +329,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 +342,21 @@ namespace StardewModdingAPI.Framework.ContentManagers
private void AssertValidType<TAsset>(IAssetName assetName, FileInfo file, params Type[] validTypes)
{
if (!validTypes.Any(validType => validType.IsAssignableFrom(typeof(TAsset))))
- throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(TAsset)}'; must be type '{string.Join("' or '", validTypes.Select(p => p.FullName))}'.");
+ this.ThrowLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(TAsset)}'; must be type '{string.Join("' or '", validTypes.Select(p => p.FullName))}'.");
}
- /// <summary>Get an error which indicates that an asset couldn't be loaded.</summary>
+ /// <summary>Throw an error which indicates that an asset couldn't be loaded.</summary>
/// <param name="errorType">Why loading an asset through the content pipeline failed.</param>
/// <param name="assetName">The asset name that failed to load.</param>
/// <param name="reasonPhrase">The reason the file couldn't be loaded.</param>
/// <param name="exception">The underlying exception, if applicable.</param>
+ /// <exception cref="SContentLoadException" />
+ [DoesNotReturn]
[DebuggerStepThrough, DebuggerHidden]
- private SContentLoadException GetLoadError(IAssetName assetName, ContentLoadErrorType errorType, string reasonPhrase, Exception? exception = null)
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private void ThrowLoadError(IAssetName assetName, ContentLoadErrorType errorType, string reasonPhrase, Exception? exception = null)
{
- return new(errorType, $"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}", exception);
+ throw new SContentLoadException(errorType, $"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}", exception);
}
/// <summary>Get a file from the mod folder.</summary>
@@ -381,26 +388,32 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <param name="texture">The texture to premultiply.</param>
/// <returns>Returns a premultiplied texture.</returns>
/// <remarks>Based on <a href="https://gamedev.stackexchange.com/a/26037">code by David Gouveia</a>.</remarks>
- private Texture2D PremultiplyTransparency(Texture2D texture)
+ private void PremultiplyTransparency(Texture2D texture)
{
- // premultiply pixels
- Color[] data = GC.AllocateUninitializedArray<Color>(texture.Width * texture.Height);
- texture.GetData(data);
- bool changed = false;
- for (int i = 0; i < data.Length; i++)
+ int count = texture.Width * texture.Height;
+ Color[] data = ArrayPool<Color>.Shared.Rent(count);
+ try
{
- Color pixel = data[i];
- if (pixel.A is (byte.MinValue or byte.MaxValue))
- continue; // no need to change fully transparent/opaque pixels
+ texture.GetData(data, 0, count);
- data[i] = new Color(pixel.R * pixel.A / byte.MaxValue, pixel.G * pixel.A / byte.MaxValue, pixel.B * pixel.A / byte.MaxValue, pixel.A); // slower version: Color.FromNonPremultiplied(data[i].ToVector4())
- changed = true;
- }
+ bool changed = false;
+ for (int i = 0; i < count; i++)
+ {
+ ref Color pixel = ref data[i];
+ if (pixel.A is (byte.MinValue or byte.MaxValue))
+ continue; // no need to change fully transparent/opaque pixels
- if (changed)
- texture.SetData(data);
+ data[i] = new Color(pixel.R * pixel.A / byte.MaxValue, pixel.G * pixel.A / byte.MaxValue, pixel.B * pixel.A / byte.MaxValue, pixel.A); // slower version: Color.FromNonPremultiplied(data[i].ToVector4())
+ changed = true;
+ }
- return texture;
+ if (changed)
+ texture.SetData(data, 0, count);
+ }
+ finally
+ {
+ ArrayPool<Color>.Shared.Return(data);
+ }
}
/// <summary>Fix custom map tilesheet paths so they can be found by the content manager.</summary>
diff --git a/src/SMAPI/Framework/Logging/LogOnceCacheKey.cs b/src/SMAPI/Framework/Logging/LogOnceCacheKey.cs
new file mode 100644
index 00000000..4d31ffeb
--- /dev/null
+++ b/src/SMAPI/Framework/Logging/LogOnceCacheKey.cs
@@ -0,0 +1,10 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace StardewModdingAPI.Framework.Logging
+{
+ /// <summary>The cache key for the <see cref="Monitor.LogOnceCache"/>.</summary>
+ /// <param name="Message">The log message.</param>
+ /// <param name="Level">The log level.</param>
+ [SuppressMessage("ReSharper", "NotAccessedPositionalProperty.Local", Justification = "This is only used as a lookup key.")]
+ internal readonly record struct LogOnceCacheKey(string Message, LogLevel Level);
+}
diff --git a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs
index 348ba225..93edd597 100644
--- a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs
@@ -1,5 +1,7 @@
+using System;
using System.Collections.Generic;
using StardewModdingAPI.Framework.Reflection;
+using StardewModdingAPI.Internal;
namespace StardewModdingAPI.Framework.ModHelpers
{
@@ -15,8 +17,8 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <summary>Encapsulates monitoring and logging for the mod.</summary>
private readonly IMonitor Monitor;
- /// <summary>The mod IDs for APIs accessed by this instanced.</summary>
- private readonly HashSet<string> AccessedModApis = new();
+ /// <summary>The APIs accessed by this instance.</summary>
+ private readonly Dictionary<string, object?> AccessedModApis = new();
/// <summary>Generates proxy classes to access mod APIs through an arbitrary interface.</summary>
private readonly IInterfaceProxyFactory ProxyFactory;
@@ -66,11 +68,44 @@ namespace StardewModdingAPI.Framework.ModHelpers
return null;
}
- // get raw API
+ // get the target mod
IModMetadata? mod = this.Registry.Get(uniqueID);
- if (mod?.Api != null && this.AccessedModApis.Add(mod.Manifest.UniqueID))
- this.Monitor.Log($"Accessed mod-provided API for {mod.DisplayName}.");
- return mod?.Api;
+ if (mod == null)
+ return null;
+
+ // fetch API
+ if (!this.AccessedModApis.TryGetValue(mod.Manifest.UniqueID, out object? api))
+ {
+ // if the target has a global API, this is mutually exclusive with per-mod APIs
+ if (mod.Api != null)
+ api = mod.Api;
+
+ // else try to get a per-mod API
+ else
+ {
+ try
+ {
+ api = mod.Mod?.GetApi(this.Mod);
+ if (api != null && !api.GetType().IsPublic)
+ {
+ api = null;
+ this.Monitor.Log($"{mod.DisplayName} provides a per-mod API instance with a non-public type. This isn't currently supported, so the API won't be available to other mods.", LogLevel.Warn);
+ }
+ }
+ catch (Exception ex)
+ {
+ this.Monitor.Log($"Failed loading the per-mod API instance from {mod.DisplayName}. Integrations with other mods may not work. Error: {ex.GetLogSummary()}", LogLevel.Error);
+ api = null;
+ }
+ }
+
+ // cache & log API access
+ this.AccessedModApis[mod.Manifest.UniqueID] = api;
+ if (api != null)
+ this.Monitor.Log($"Accessed mod-provided API ({api.GetType().FullName}) for {mod.DisplayName}.");
+ }
+
+ return api;
}
/// <inheritdoc />
diff --git a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
index 01037870..ae08d972 100644
--- a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
+++ b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
@@ -221,7 +221,7 @@ namespace StardewModdingAPI.Framework.ModLoading
/// </remarks>
public static Assembly? ResolveAssembly(string name)
{
- string shortName = name.Split(new[] { ',' }, 2).First(); // get simple name (without version and culture)
+ string shortName = name.Split(',', 2).First(); // get simple name (without version and culture)
return AppDomain.CurrentDomain
.GetAssemblies()
.FirstOrDefault(p => p.GetName().Name == shortName);
diff --git a/src/SMAPI/Framework/Monitor.cs b/src/SMAPI/Framework/Monitor.cs
index 6b53daff..4ed2c9bb 100644
--- a/src/SMAPI/Framework/Monitor.cs
+++ b/src/SMAPI/Framework/Monitor.cs
@@ -25,10 +25,13 @@ namespace StardewModdingAPI.Framework
private readonly LogFileManager LogFile;
/// <summary>The maximum length of the <see cref="LogLevel"/> values.</summary>
- private static readonly int MaxLevelLength = (from level in Enum.GetValues(typeof(LogLevel)).Cast<LogLevel>() select level.ToString().Length).Max();
+ private static readonly int MaxLevelLength = Enum.GetValues<LogLevel>().Max(level => level.ToString().Length);
+
+ /// <summary>The cached representation for each level when added to a log header.</summary>
+ private static readonly Dictionary<ConsoleLogLevel, string> LogStrings = Enum.GetValues<ConsoleLogLevel>().ToDictionary(level => level, level => level.ToString().ToUpper().PadRight(Monitor.MaxLevelLength));
/// <summary>A cache of messages that should only be logged once.</summary>
- private readonly HashSet<string> LogOnceCache = new();
+ private readonly HashSet<LogOnceCacheKey> LogOnceCache = new();
/// <summary>Get the screen ID that should be logged to distinguish between players in split-screen mode, if any.</summary>
private readonly Func<int?> GetScreenIdForLog;
@@ -84,7 +87,7 @@ namespace StardewModdingAPI.Framework
/// <inheritdoc />
public void LogOnce(string message, LogLevel level = LogLevel.Trace)
{
- if (this.LogOnceCache.Add($"{message}|{level}"))
+ if (this.LogOnceCache.Add(new LogOnceCacheKey(message, level)))
this.LogImpl(this.Source, message, (ConsoleLogLevel)level);
}
@@ -147,7 +150,7 @@ namespace StardewModdingAPI.Framework
/// <param name="level">The log level.</param>
private string GenerateMessagePrefix(string source, ConsoleLogLevel level)
{
- string levelStr = level.ToString().ToUpper().PadRight(Monitor.MaxLevelLength);
+ string levelStr = Monitor.LogStrings[level];
int? playerIndex = this.GetScreenIdForLog();
return $"[{DateTime.Now:HH:mm:ss} {levelStr}{(playerIndex != null ? $" screen_{playerIndex}" : "")} {source}]";
diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs
index 16ff2537..4ba0dd9c 100644
--- a/src/SMAPI/Framework/SCore.cs
+++ b/src/SMAPI/Framework/SCore.cs
@@ -1779,6 +1779,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 && metadata.Mod!.GetType().GetMethod(nameof(Mod.GetApi), new Type[] { typeof(IManifest) })!.DeclaringType != typeof(Mod))
+ metadata.LogAsMod($"Mod implements both {nameof(Mod.GetApi)}() and {nameof(Mod.GetApi)}({nameof(IManifest)}), which isn't allowed. The latter will be ignored.", LogLevel.Error);
+
Context.HeuristicModsRunningCode.TryPop(out _);
}
diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs
index 0b13434a..b24f4178 100644
--- a/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs
+++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs
@@ -63,7 +63,7 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
{
if (this.LastValues.Count > 0)
{
- this.AddedImpl.AddRange(this.LastValues);
+ this.RemovedImpl.AddRange(this.LastValues);
this.LastValues.Clear();
}
return;
diff --git a/src/SMAPI/IMod.cs b/src/SMAPI/IMod.cs
index b81ba0e3..19d01311 100644
--- a/src/SMAPI/IMod.cs
+++ b/src/SMAPI/IMod.cs
@@ -23,7 +23,15 @@ namespace StardewModdingAPI
/// <param name="helper">Provides simplified APIs for writing mods.</param>
void Entry(IModHelper helper);
- /// <summary>Get an API that other mods can access. This is always called after <see cref="Entry"/>.</summary>
+ /// <summary>Get an <a href="https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Integrations">API that other mods can access</a>. This is always called after <see cref="Entry"/>, and is only called once even if multiple mods access it.</summary>
+ /// <remarks>You can implement <see cref="GetApi()"/> to provide one instance to all mods, or <see cref="GetApi(IModInfo)"/> to provide a separate instance per mod. These are mutually exclusive, so you can only implement one of them.</remarks>
+ /// <remarks>Returns the API instance, or <c>null</c> if the mod has no API.</remarks>
object? GetApi();
+
+ /// <summary>Get an <a href="https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Integrations">API that other mods can access</a>. This is always called after <see cref="Entry"/>, and is called once per mod that accesses the API (even if they access it multiple times).</summary>
+ /// <param name="mod">The mod accessing the API.</param>
+ /// <remarks>Returns the API instance, or <c>null</c> if the mod has no API. Note that the manifest is provided for informational purposes only, and that denying API access to specific mods is strongly discouraged and may be considered abusive.</remarks>
+ /// <inheritdoc cref="GetApi()" include="/Remarks" />
+ object? GetApi(IModInfo mod);
}
}
diff --git a/src/SMAPI/Mod.cs b/src/SMAPI/Mod.cs
index f764752b..01157886 100644
--- a/src/SMAPI/Mod.cs
+++ b/src/SMAPI/Mod.cs
@@ -30,6 +30,12 @@ namespace StardewModdingAPI
return null;
}
+ /// <inheritdoc />
+ public virtual object? GetApi(IModInfo mod)
+ {
+ return null;
+ }
+
/// <summary>Release or reset unmanaged resources.</summary>
public void Dispose()
{
diff --git a/src/SMAPI/SMAPI.csproj b/src/SMAPI/SMAPI.csproj
index 36db0545..e5d8937c 100644
--- a/src/SMAPI/SMAPI.csproj
+++ b/src/SMAPI/SMAPI.csproj
@@ -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" />