summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/release-notes.md4
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs2
-rw-r--r--src/SMAPI.Tests.ModApiConsumer/Interfaces/ISimpleApi.cs4
-rw-r--r--src/SMAPI.Tests.ModApiProvider/Framework/SimpleApi.cs15
-rw-r--r--src/SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj4
-rw-r--r--src/SMAPI.Tests/Core/AssetNameTests.cs16
-rw-r--r--src/SMAPI.Tests/Core/InterfaceProxyTests.cs103
-rw-r--r--src/SMAPI.Tests/Core/ModResolverTests.cs60
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs7
-rw-r--r--src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs33
-rw-r--r--src/SMAPI.Toolkit/Utilities/CaseInsensitivePathLookup.cs (renamed from src/SMAPI/Utilities/CaseInsensitivePathCache.cs)37
-rw-r--r--src/SMAPI.sln.DotSettings2
-rw-r--r--src/SMAPI/Framework/Content/AssetName.cs16
-rw-r--r--src/SMAPI/Framework/ContentCoordinator.cs19
-rw-r--r--src/SMAPI/Framework/ContentManagers/ModContentManager.cs6
-rw-r--r--src/SMAPI/Framework/ContentPack.cs6
-rw-r--r--src/SMAPI/Framework/ModHelpers/ModContentHelper.cs6
-rw-r--r--src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs4
-rw-r--r--src/SMAPI/Framework/ModLoading/ModResolver.cs23
-rw-r--r--src/SMAPI/Framework/Models/SConfig.cs10
-rw-r--r--src/SMAPI/Framework/Reflection/IInterfaceProxyFactory.cs17
-rw-r--r--src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs10
-rw-r--r--src/SMAPI/Framework/Reflection/OriginalInterfaceProxyBuilder.cs118
-rw-r--r--src/SMAPI/Framework/Reflection/OriginalInterfaceProxyFactory.cs57
-rw-r--r--src/SMAPI/Framework/SCore.cs17
-rw-r--r--src/SMAPI/Framework/Translator.cs3
-rw-r--r--src/SMAPI/SMAPI.config.json6
27 files changed, 451 insertions, 154 deletions
diff --git a/docs/release-notes.md b/docs/release-notes.md
index 75c1941e..0c14360b 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -44,8 +44,8 @@ the C# mod that loads them is updated.
_These include new features not supported by the old API like load conflict resolution, edit priority, and content pack labels. They also support new cases like easily detecting when an asset has changed, and avoid data corruption issues in some edge cases._
* Added [nullable reference type annotations](https://stardewvalleywiki.com/Modding:Migrate_to_SMAPI_4.0#Nullable_reference_type_annotations) for all APIs.
* Added `helper.GameContent` and `helper.ModContent`, which will replace `helper.Content` in SMAPI 4.0.0.
- * Overhauled [mod-provided API](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Integrations#Mod-provided_APIs) proxying (thanks to Shockah!).
- _This adds support for many previously unsupported cases: proxied interfaces in return values or input arguments, proxied enums if their values match, generic methods, and more. Existing mod APIs should work fine as-is._
+ * Added optional overhaul of [mod-provided API](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Integrations#Mod-provided_APIs) proxying (thanks to Shockah!).
+ _This is experimental (you can enable it via `UsePintail` in `smapi-internal/config.json`). This adds support for many previously unsupported cases: proxied interfaces in return values or input arguments, proxied enums if their values match, generic methods, and more. Existing mod APIs should work fine as-is._
* Mod files loaded through SMAPI APIs (including `helper.Content.Load`) are now case-insensitive, even on Linux.
* Enabled deprecation notices for all deprecated APIs. These will only be shown in `TRACE` logs for at least a month after SMAPI 3.14.0 releases.
* Other improvements:
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs
index f11a59d3..a6fa5633 100644
--- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs
@@ -87,7 +87,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests
[TestCase("SObject obj = null; if (obj.netRefProperty != null);", 24, "obj.netRefProperty", "NetRef", "object")]
[TestCase("Item item = new Item(); object list = item.netList;", 38, "item.netList", "NetList", "object")] // ↓ NetList field converted to a non-interface type
[TestCase("Item item = new Item(); object list = item.netCollection;", 38, "item.netCollection", "NetCollection", "object")]
- [TestCase("Item item = new Item(); int x = (int)item.netIntField;", 32, "item.netIntField", "NetInt", "int")] // ↓ explicit conversion to invalid type
+ [TestCase("Item item = new Item(); int x = (int)item.netIntField;", 32, "item.netIntField", "NetFieldBase", "int")] // ↓ explicit conversion to invalid type
[TestCase("Item item = new Item(); int x = item.netRefField as object;", 32, "item.netRefField", "NetRef", "object")]
public void AvoidImplicitNetFieldComparisons_RaisesDiagnostic(string codeText, int column, string expression, string fromType, string toType)
{
diff --git a/src/SMAPI.Tests.ModApiConsumer/Interfaces/ISimpleApi.cs b/src/SMAPI.Tests.ModApiConsumer/Interfaces/ISimpleApi.cs
index 7f94e137..c99605e4 100644
--- a/src/SMAPI.Tests.ModApiConsumer/Interfaces/ISimpleApi.cs
+++ b/src/SMAPI.Tests.ModApiConsumer/Interfaces/ISimpleApi.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Reflection;
+using StardewModdingAPI.Utilities;
namespace SMAPI.Tests.ModApiConsumer.Interfaces
{
@@ -69,6 +70,9 @@ namespace SMAPI.Tests.ModApiConsumer.Interfaces
/// <summary>A simple method which returns a lambda.</summary>
Func<string, string> GetLambda(Func<string, string> value);
+ /// <summary>A simple method which returns out parameters.</summary>
+ bool TryGetOutParameter(int inputNumber, out int outNumber, out string outString, out PerScreen<int> outReference, out IDictionary<int, PerScreen<int>> outComplexType);
+
/****
** Inherited members
diff --git a/src/SMAPI.Tests.ModApiProvider/Framework/SimpleApi.cs b/src/SMAPI.Tests.ModApiProvider/Framework/SimpleApi.cs
index e7e1ccef..c8781da5 100644
--- a/src/SMAPI.Tests.ModApiProvider/Framework/SimpleApi.cs
+++ b/src/SMAPI.Tests.ModApiProvider/Framework/SimpleApi.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Reflection;
+using StardewModdingAPI.Utilities;
namespace SMAPI.Tests.ModApiProvider.Framework
{
@@ -96,6 +97,20 @@ namespace SMAPI.Tests.ModApiProvider.Framework
return value;
}
+ /// <summary>A simple method which returns out parameters.</summary>
+ public bool TryGetOutParameter(int inputNumber, out int outNumber, out string outString, out PerScreen<int> outReference, out IDictionary<int, PerScreen<int>> outComplexType)
+ {
+ outNumber = inputNumber;
+ outString = inputNumber.ToString();
+ outReference = new PerScreen<int>(() => inputNumber);
+ outComplexType = new Dictionary<int, PerScreen<int>>
+ {
+ [inputNumber] = new PerScreen<int>(() => inputNumber)
+ };
+
+ return true;
+ }
+
/*********
** Helper methods
diff --git a/src/SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj b/src/SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj
index 70d5a0ce..7fef4ebd 100644
--- a/src/SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj
+++ b/src/SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj
@@ -4,4 +4,8 @@
</PropertyGroup>
<Import Project="..\..\build\common.targets" />
+
+ <ItemGroup>
+ <ProjectReference Include="..\SMAPI\SMAPI.csproj" />
+ </ItemGroup>
</Project>
diff --git a/src/SMAPI.Tests/Core/AssetNameTests.cs b/src/SMAPI.Tests/Core/AssetNameTests.cs
index a1712726..655e9bae 100644
--- a/src/SMAPI.Tests/Core/AssetNameTests.cs
+++ b/src/SMAPI.Tests/Core/AssetNameTests.cs
@@ -78,9 +78,9 @@ namespace SMAPI.Tests.Core
[TestCase("DATA\\achievements", "data/ACHIEVEMENTS", ExpectedResult = true)]
[TestCase("DATA\\\\achievements", "data////ACHIEVEMENTS", ExpectedResult = true)]
- // whitespace-sensitive
- [TestCase("Data/Achievements", " Data/Achievements ", ExpectedResult = false)]
- [TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = false)]
+ // whitespace-insensitive
+ [TestCase("Data/Achievements", " Data/Achievements ", ExpectedResult = true)]
+ [TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = true)]
// other is null or whitespace
[TestCase("Data/Achievements", null, ExpectedResult = false)]
@@ -109,7 +109,7 @@ namespace SMAPI.Tests.Core
[TestCase("Data/Achievements", "Data/Achievements", ExpectedResult = true)]
[TestCase("DATA/achievements", "data/ACHIEVEMENTS", ExpectedResult = true)]
[TestCase("DATA\\\\achievements", "data////ACHIEVEMENTS", ExpectedResult = true)]
- [TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = false)]
+ [TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = true)]
[TestCase("Data/Achievements", " ", ExpectedResult = false)]
// with locale codes
@@ -141,13 +141,13 @@ namespace SMAPI.Tests.Core
[TestCase("DATA\\achievements", "data/ACHIEVEMENTS", ExpectedResult = true)]
[TestCase("DATA\\\\achievements", "data////ACHIEVEMENTS", ExpectedResult = true)]
- // leading-whitespace-sensitive
- [TestCase("Data/Achievements", " Data/Achievements", ExpectedResult = false)]
- [TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = false)]
+ // whitespace-insensitive
+ [TestCase("Data/Achievements", " Data/Achievements", ExpectedResult = true)]
+ [TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = true)]
+ [TestCase("Data/Achievements", " ", ExpectedResult = true)]
// invalid prefixes
[TestCase("Data/Achievements", null, ExpectedResult = false)]
- [TestCase("Data/Achievements", " ", ExpectedResult = false)]
// with locale codes
[TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = true)]
diff --git a/src/SMAPI.Tests/Core/InterfaceProxyTests.cs b/src/SMAPI.Tests/Core/InterfaceProxyTests.cs
index 0b4919ed..6be97526 100644
--- a/src/SMAPI.Tests/Core/InterfaceProxyTests.cs
+++ b/src/SMAPI.Tests/Core/InterfaceProxyTests.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
+using System.Linq;
using System.Reflection;
using FluentAssertions;
using NUnit.Framework;
@@ -8,6 +9,7 @@ using SMAPI.Tests.ModApiConsumer;
using SMAPI.Tests.ModApiConsumer.Interfaces;
using SMAPI.Tests.ModApiProvider;
using StardewModdingAPI.Framework.Reflection;
+using StardewModdingAPI.Utilities;
namespace SMAPI.Tests.Core
{
@@ -27,6 +29,12 @@ namespace SMAPI.Tests.Core
/// <summary>The random number generator with which to create sample values.</summary>
private readonly Random Random = new();
+ /// <summary>Sample user inputs for season names.</summary>
+ private static readonly IInterfaceProxyFactory[] ProxyFactories = {
+ new InterfaceProxyFactory(),
+ new OriginalInterfaceProxyFactory()
+ };
+
/*********
** Unit tests
@@ -35,8 +43,9 @@ namespace SMAPI.Tests.Core
** Events
****/
/// <summary>Assert that an event field can be proxied correctly.</summary>
+ /// <param name="proxyFactory">The proxy factory to test.</param>
[Test]
- public void CanProxy_EventField()
+ public void CanProxy_EventField([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory)
{
// arrange
ProviderMod providerMod = new();
@@ -44,7 +53,7 @@ namespace SMAPI.Tests.Core
int expectedValue = this.Random.Next();
// act
- ISimpleApi proxy = this.GetProxy(implementation);
+ ISimpleApi proxy = this.GetProxy(proxyFactory, implementation);
new ApiConsumer().UseEventField(proxy, out Func<(int timesCalled, int lastValue)> getValues);
providerMod.RaiseEvent(expectedValue);
(int timesCalled, int lastValue) = getValues();
@@ -55,8 +64,9 @@ namespace SMAPI.Tests.Core
}
/// <summary>Assert that an event property can be proxied correctly.</summary>
+ /// <param name="proxyFactory">The proxy factory to test.</param>
[Test]
- public void CanProxy_EventProperty()
+ public void CanProxy_EventProperty([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory)
{
// arrange
ProviderMod providerMod = new();
@@ -64,7 +74,7 @@ namespace SMAPI.Tests.Core
int expectedValue = this.Random.Next();
// act
- ISimpleApi proxy = this.GetProxy(implementation);
+ ISimpleApi proxy = this.GetProxy(proxyFactory, implementation);
new ApiConsumer().UseEventProperty(proxy, out Func<(int timesCalled, int lastValue)> getValues);
providerMod.RaiseEvent(expectedValue);
(int timesCalled, int lastValue) = getValues();
@@ -78,10 +88,10 @@ namespace SMAPI.Tests.Core
** Properties
****/
/// <summary>Assert that properties can be proxied correctly.</summary>
+ /// <param name="proxyFactory">The proxy factory to test.</param>
/// <param name="setVia">Whether to set the properties through the <c>provider mod</c> or <c>proxy interface</c>.</param>
- [TestCase("set via provider mod")]
- [TestCase("set via proxy interface")]
- public void CanProxy_Properties(string setVia)
+ [Test]
+ public void CanProxy_Properties([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory, [Values("set via provider mod", "set via proxy interface")] string setVia)
{
// arrange
ProviderMod providerMod = new();
@@ -96,7 +106,7 @@ namespace SMAPI.Tests.Core
BindingFlags expectedEnum = BindingFlags.Instance | BindingFlags.Public;
// act
- ISimpleApi proxy = this.GetProxy(implementation);
+ ISimpleApi proxy = this.GetProxy(proxyFactory, implementation);
switch (setVia)
{
case "set via provider mod":
@@ -196,27 +206,29 @@ namespace SMAPI.Tests.Core
}
/// <summary>Assert that a simple method with no return value can be proxied correctly.</summary>
+ /// <param name="proxyFactory">The proxy factory to test.</param>
[Test]
- public void CanProxy_SimpleMethod_Void()
+ public void CanProxy_SimpleMethod_Void([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory)
{
// arrange
object implementation = new ProviderMod().GetModApi();
// act
- ISimpleApi proxy = this.GetProxy(implementation);
+ ISimpleApi proxy = this.GetProxy(proxyFactory, implementation);
proxy.GetNothing();
}
/// <summary>Assert that a simple int method can be proxied correctly.</summary>
+ /// <param name="proxyFactory">The proxy factory to test.</param>
[Test]
- public void CanProxy_SimpleMethod_Int()
+ public void CanProxy_SimpleMethod_Int([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory)
{
// arrange
object implementation = new ProviderMod().GetModApi();
int expectedValue = this.Random.Next();
// act
- ISimpleApi proxy = this.GetProxy(implementation);
+ ISimpleApi proxy = this.GetProxy(proxyFactory, implementation);
int actualValue = proxy.GetInt(expectedValue);
// assert
@@ -224,15 +236,16 @@ namespace SMAPI.Tests.Core
}
/// <summary>Assert that a simple object method can be proxied correctly.</summary>
+ /// <param name="proxyFactory">The proxy factory to test.</param>
[Test]
- public void CanProxy_SimpleMethod_Object()
+ public void CanProxy_SimpleMethod_Object([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory)
{
// arrange
object implementation = new ProviderMod().GetModApi();
object expectedValue = new();
// act
- ISimpleApi proxy = this.GetProxy(implementation);
+ ISimpleApi proxy = this.GetProxy(proxyFactory, implementation);
object actualValue = proxy.GetObject(expectedValue);
// assert
@@ -240,15 +253,16 @@ namespace SMAPI.Tests.Core
}
/// <summary>Assert that a simple list method can be proxied correctly.</summary>
+ /// <param name="proxyFactory">The proxy factory to test.</param>
[Test]
- public void CanProxy_SimpleMethod_List()
+ public void CanProxy_SimpleMethod_List([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory)
{
// arrange
object implementation = new ProviderMod().GetModApi();
string expectedValue = this.GetRandomString();
// act
- ISimpleApi proxy = this.GetProxy(implementation);
+ ISimpleApi proxy = this.GetProxy(proxyFactory, implementation);
IList<string> actualValue = proxy.GetList(expectedValue);
// assert
@@ -256,15 +270,16 @@ namespace SMAPI.Tests.Core
}
/// <summary>Assert that a simple list with interface method can be proxied correctly.</summary>
+ /// <param name="proxyFactory">The proxy factory to test.</param>
[Test]
- public void CanProxy_SimpleMethod_ListWithInterface()
+ public void CanProxy_SimpleMethod_ListWithInterface([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory)
{
// arrange
object implementation = new ProviderMod().GetModApi();
string expectedValue = this.GetRandomString();
// act
- ISimpleApi proxy = this.GetProxy(implementation);
+ ISimpleApi proxy = this.GetProxy(proxyFactory, implementation);
IList<string> actualValue = proxy.GetListWithInterface(expectedValue);
// assert
@@ -272,8 +287,9 @@ namespace SMAPI.Tests.Core
}
/// <summary>Assert that a simple method which returns generic types can be proxied correctly.</summary>
+ /// <param name="proxyFactory">The proxy factory to test.</param>
[Test]
- public void CanProxy_SimpleMethod_GenericTypes()
+ public void CanProxy_SimpleMethod_GenericTypes([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory)
{
// arrange
object implementation = new ProviderMod().GetModApi();
@@ -281,7 +297,7 @@ namespace SMAPI.Tests.Core
string expectedValue = this.GetRandomString();
// act
- ISimpleApi proxy = this.GetProxy(implementation);
+ ISimpleApi proxy = this.GetProxy(proxyFactory, implementation);
IDictionary<string, IList<string>> actualValue = proxy.GetGenerics(expectedKey, expectedValue);
// assert
@@ -292,22 +308,61 @@ namespace SMAPI.Tests.Core
}
/// <summary>Assert that a simple lambda method can be proxied correctly.</summary>
+ /// <param name="proxyFactory">The proxy factory to test.</param>
[Test]
[SuppressMessage("ReSharper", "ConvertToLocalFunction")]
- public void CanProxy_SimpleMethod_Lambda()
+ public void CanProxy_SimpleMethod_Lambda([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory)
{
// arrange
object implementation = new ProviderMod().GetModApi();
Func<string, string> expectedValue = _ => "test";
// act
- ISimpleApi proxy = this.GetProxy(implementation);
+ ISimpleApi proxy = this.GetProxy(proxyFactory, implementation);
object actualValue = proxy.GetObject(expectedValue);
// assert
actualValue.Should().BeSameAs(expectedValue);
}
+ /// <summary>Assert that a method with out parameters can be proxied correctly.</summary>
+ /// <param name="proxyFactory">The proxy factory to test.</param>
+ [Test]
+ [SuppressMessage("ReSharper", "ConvertToLocalFunction")]
+ public void CanProxy_Method_OutParameters([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory)
+ {
+ // arrange
+ object implementation = new ProviderMod().GetModApi();
+ const int expectedNumber = 42;
+
+ // act
+ ISimpleApi proxy = this.GetProxy(proxyFactory, implementation);
+ bool result = proxy.TryGetOutParameter(
+ inputNumber: expectedNumber,
+
+ out int outNumber,
+ out string outString,
+ out PerScreen<int> outReference,
+ out IDictionary<int, PerScreen<int>> outComplexType
+ );
+
+ // assert
+ result.Should().BeTrue();
+
+ outNumber.Should().Be(expectedNumber);
+
+ outString.Should().Be(expectedNumber.ToString());
+
+ outReference.Should().NotBeNull();
+ outReference.Value.Should().Be(expectedNumber);
+
+ outComplexType.Should().NotBeNull();
+ outComplexType.Count.Should().Be(1);
+ outComplexType.Keys.First().Should().Be(expectedNumber);
+ outComplexType.Values.First().Should().NotBeNull();
+ outComplexType.Values.First().Value.Should().Be(expectedNumber);
+ }
+
/*********
** Private methods
@@ -335,10 +390,10 @@ namespace SMAPI.Tests.Core
}
/// <summary>Get a proxy API instance.</summary>
+ /// <param name="proxyFactory">The proxy factory to use.</param>
/// <param name="implementation">The underlying API instance.</param>
- private ISimpleApi GetProxy(object implementation)
+ private ISimpleApi GetProxy(IInterfaceProxyFactory proxyFactory, object implementation)
{
- var proxyFactory = new InterfaceProxyFactory();
return proxyFactory.CreateProxy<ISimpleApi>(implementation, this.FromModId, this.ToModId);
}
}
diff --git a/src/SMAPI.Tests/Core/ModResolverTests.cs b/src/SMAPI.Tests/Core/ModResolverTests.cs
index bd621bbf..6b2746f5 100644
--- a/src/SMAPI.Tests/Core/ModResolverTests.cs
+++ b/src/SMAPI.Tests/Core/ModResolverTests.cs
@@ -38,6 +38,9 @@ namespace SMAPI.Tests.Core
// assert
Assert.AreEqual(0, mods.Length, 0, $"Expected to find zero manifests, found {mods.Length} instead.");
+
+ // cleanup
+ Directory.Delete(rootFolder, recursive: true);
}
[Test(Description = "Assert that the resolver correctly returns a failed metadata if there's an empty mod folder.")]
@@ -56,6 +59,9 @@ namespace SMAPI.Tests.Core
Assert.AreEqual(1, mods.Length, 0, $"Expected to find one manifest, found {mods.Length} instead.");
Assert.AreEqual(ModMetadataStatus.Failed, mod!.Status, "The mod metadata was not marked failed.");
Assert.IsNotNull(mod.Error, "The mod metadata did not have an error message set.");
+
+ // cleanup
+ Directory.Delete(rootFolder, recursive: true);
}
[Test(Description = "Assert that the resolver correctly reads manifest data from a randomized file.")]
@@ -115,6 +121,9 @@ namespace SMAPI.Tests.Core
Assert.IsNotNull(mod.Manifest.Dependencies, "The dependencies field should not be null.");
Assert.AreEqual(1, mod.Manifest.Dependencies.Length, "The dependencies field should contain one value.");
Assert.AreEqual(originalDependency[nameof(IManifestDependency.UniqueID)], mod.Manifest.Dependencies[0].UniqueID, "The first dependency's unique ID doesn't match.");
+
+ // cleanup
+ Directory.Delete(rootFolder, recursive: true);
}
/****
@@ -123,7 +132,7 @@ namespace SMAPI.Tests.Core
[Test(Description = "Assert that validation doesn't fail if there are no mods installed.")]
public void ValidateManifests_NoMods_DoesNothing()
{
- new ModResolver().ValidateManifests(Array.Empty<ModMetadata>(), apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null);
+ new ModResolver().ValidateManifests(Array.Empty<ModMetadata>(), apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, validateFilesExist: false);
}
[Test(Description = "Assert that validation skips manifests that have already failed without calling any other properties.")]
@@ -134,7 +143,7 @@ namespace SMAPI.Tests.Core
mock.Setup(p => p.Status).Returns(ModMetadataStatus.Failed);
// act
- new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null);
+ new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, validateFilesExist: false);
// assert
mock.VerifyGet(p => p.Status, Times.Once, "The validation did not check the manifest status.");
@@ -145,13 +154,13 @@ namespace SMAPI.Tests.Core
{
// arrange
Mock<IModMetadata> mock = this.GetMetadata("Mod A", Array.Empty<string>(), allowStatusChange: true);
- this.SetupMetadataForValidation(mock, new ModDataRecordVersionedFields(this.GetModDataRecord())
+ mock.Setup(p => p.DataRecord).Returns(() => new ModDataRecordVersionedFields(this.GetModDataRecord())
{
Status = ModStatus.AssumeBroken
});
// act
- new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null);
+ new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, validateFilesExist: false);
// assert
mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "The validation did not fail the metadata.");
@@ -163,10 +172,9 @@ namespace SMAPI.Tests.Core
// arrange
Mock<IModMetadata> mock = this.GetMetadata("Mod A", Array.Empty<string>(), allowStatusChange: true);
mock.Setup(p => p.Manifest).Returns(this.GetManifest(minimumApiVersion: "1.1"));
- this.SetupMetadataForValidation(mock);
// act
- new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null);
+ new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, validateFilesExist: false);
// assert
mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "The validation did not fail the metadata.");
@@ -176,14 +184,18 @@ namespace SMAPI.Tests.Core
public void ValidateManifests_MissingEntryDLL_Fails()
{
// arrange
- Mock<IModMetadata> mock = this.GetMetadata(this.GetManifest(id: "Mod A", version: "1.0", entryDll: "Missing.dll"), allowStatusChange: true);
- this.SetupMetadataForValidation(mock);
+ string directoryPath = this.GetTempFolderPath();
+ Mock<IModMetadata> mock = this.GetMetadata(this.GetManifest(id: "Mod A", version: "1.0", entryDll: "Missing.dll"), allowStatusChange: true, directoryPath: directoryPath);
+ Directory.CreateDirectory(directoryPath);
// act
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null);
// assert
mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "The validation did not fail the metadata.");
+
+ // cleanup
+ Directory.Delete(directoryPath);
}
[Test(Description = "Assert that validation fails when multiple mods have the same unique ID.")]
@@ -192,16 +204,13 @@ namespace SMAPI.Tests.Core
// arrange
Mock<IModMetadata> modA = this.GetMetadata("Mod A", Array.Empty<string>(), allowStatusChange: true);
Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest(id: "Mod A", name: "Mod B", version: "1.0"), allowStatusChange: true);
- Mock<IModMetadata> modC = this.GetMetadata("Mod C", Array.Empty<string>(), allowStatusChange: false);
- foreach (Mock<IModMetadata> mod in new[] { modA, modB, modC })
- this.SetupMetadataForValidation(mod);
// act
- new ModResolver().ValidateManifests(new[] { modA.Object, modB.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null);
+ new ModResolver().ValidateManifests(new[] { modA.Object, modB.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, validateFilesExist: false);
// assert
- modA.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "The validation did not fail the first mod with a unique ID.");
- modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "The validation did not fail the second mod with a unique ID.");
+ modA.Verify(p => p.SetStatus(ModMetadataStatus.Failed, ModFailReason.Duplicate, It.IsAny<string>(), It.IsAny<string>()), Times.AtLeastOnce, "The validation did not fail the first mod with a unique ID.");
+ modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, ModFailReason.Duplicate, It.IsAny<string>(), It.IsAny<string>()), Times.AtLeastOnce, "The validation did not fail the second mod with a unique ID.");
}
[Test(Description = "Assert that validation fails when the manifest references a DLL that does not exist.")]
@@ -227,6 +236,9 @@ namespace SMAPI.Tests.Core
// assert
// if Moq doesn't throw a method-not-setup exception, the validation didn't override the status.
+
+ // cleanup
+ Directory.Delete(modFolder, recursive: true);
}
/****
@@ -514,14 +526,20 @@ namespace SMAPI.Tests.Core
/// <summary>Get a randomized basic manifest.</summary>
/// <param name="manifest">The mod manifest.</param>
/// <param name="allowStatusChange">Whether the code being tested is allowed to change the mod status.</param>
- private Mock<IModMetadata> GetMetadata(IManifest manifest, bool allowStatusChange = false)
+ /// <param name="directoryPath">The directory path the mod metadata should be pointed at, or <c>null</c> to generate a fake path.</param>
+ private Mock<IModMetadata> GetMetadata(IManifest manifest, bool allowStatusChange = false, string? directoryPath = null)
{
+ directoryPath ??= this.GetTempFolderPath();
+
Mock<IModMetadata> mod = new(MockBehavior.Strict);
mod.Setup(p => p.DataRecord).Returns(this.GetModDataRecordVersionedFields());
mod.Setup(p => p.Status).Returns(ModMetadataStatus.Found);
mod.Setup(p => p.DisplayName).Returns(manifest.UniqueID);
+ mod.Setup(p => p.DirectoryPath).Returns(directoryPath);
mod.Setup(p => p.Manifest).Returns(manifest);
mod.Setup(p => p.HasID(It.IsAny<string>())).Returns((string id) => manifest.UniqueID == id);
+ mod.Setup(p => p.GetUpdateKeys(It.IsAny<bool>())).Returns(Enumerable.Empty<UpdateKey>());
+ mod.Setup(p => p.GetRelativePathWithRoot()).Returns(directoryPath);
if (allowStatusChange)
{
mod
@@ -532,18 +550,6 @@ namespace SMAPI.Tests.Core
return mod;
}
- /// <summary>Set up a mock mod metadata for <see cref="ModResolver.ValidateManifests"/>.</summary>
- /// <param name="mod">The mock mod metadata.</param>
- /// <param name="modRecord">The extra metadata about the mod from SMAPI's internal data (if any).</param>
- private void SetupMetadataForValidation(Mock<IModMetadata> mod, ModDataRecordVersionedFields? modRecord = null)
- {
- mod.Setup(p => p.Status).Returns(ModMetadataStatus.Found);
- mod.Setup(p => p.Manifest).Returns(this.GetManifest());
- mod.Setup(p => p.DirectoryPath).Returns(Path.GetTempPath());
- mod.Setup(p => p.DataRecord).Returns(modRecord ?? this.GetModDataRecordVersionedFields());
- mod.Setup(p => p.GetUpdateKeys(It.IsAny<bool>())).Returns(Enumerable.Empty<UpdateKey>());
- }
-
/// <summary>Generate a default mod data record.</summary>
private ModDataRecord GetModDataRecord()
{
diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs
index 8646f1cc..a2497dea 100644
--- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs
+++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs
@@ -55,7 +55,12 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
{
// get list
List<string> values = !string.IsNullOrWhiteSpace(rawField)
- ? new List<string>(rawField.Split(','))
+ ? new List<string>(
+ from field in rawField.Split(',')
+ let value = field.Trim()
+ where value.Length > 0
+ select value
+ )
: new List<string>();
// apply changes
diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs
index 12333c4e..24485620 100644
--- a/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs
+++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs
@@ -5,6 +5,7 @@ using System.Linq;
using System.Text.RegularExpressions;
using StardewModdingAPI.Toolkit.Serialization;
using StardewModdingAPI.Toolkit.Serialization.Models;
+using StardewModdingAPI.Toolkit.Utilities;
namespace StardewModdingAPI.Toolkit.Framework.ModScanning
{
@@ -249,24 +250,24 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning
/// <param name="folder">The folder to search.</param>
private FileInfo? FindManifest(DirectoryInfo folder)
{
- while (true)
- {
- // check for manifest in current folder
- FileInfo file = new(Path.Combine(folder.FullName, "manifest.json"));
- if (file.Exists)
- return file;
-
- // check for single subfolder
- FileSystemInfo[] entries = folder.EnumerateFileSystemInfos().Take(2).ToArray();
- if (entries.Length == 1 && entries[0] is DirectoryInfo subfolder)
- {
- folder = subfolder;
- continue;
- }
+ // check for conventional manifest in current folder
+ const string defaultName = "manifest.json";
+ FileInfo file = new(Path.Combine(folder.FullName, defaultName));
+ if (file.Exists)
+ return file;
- // not found
- return null;
+ // check for manifest with incorrect capitalization
+ {
+ CaseInsensitivePathLookup pathLookup = new(folder.FullName, SearchOption.TopDirectoryOnly); // don't use GetCachedFor, since we only need it temporarily
+ string realName = pathLookup.GetFilePath(defaultName);
+ if (realName != defaultName)
+ file = new(Path.Combine(folder.FullName, realName));
}
+ if (file.Exists)
+ return file;
+
+ // not found
+ return null;
}
/// <summary>Get whether a given folder should be treated as a search folder (i.e. look for subfolders containing mods).</summary>
diff --git a/src/SMAPI/Utilities/CaseInsensitivePathCache.cs b/src/SMAPI.Toolkit/Utilities/CaseInsensitivePathLookup.cs
index 2ac1b9f9..12fad008 100644
--- a/src/SMAPI/Utilities/CaseInsensitivePathCache.cs
+++ b/src/SMAPI.Toolkit/Utilities/CaseInsensitivePathLookup.cs
@@ -2,10 +2,10 @@ using System;
using System.Collections.Generic;
using System.IO;
-namespace StardewModdingAPI.Utilities
+namespace StardewModdingAPI.Toolkit.Utilities
{
/// <summary>Provides an API for case-insensitive relative path lookups within a root directory.</summary>
- internal class CaseInsensitivePathCache
+ internal class CaseInsensitivePathLookup
{
/*********
** Fields
@@ -16,16 +16,20 @@ namespace StardewModdingAPI.Utilities
/// <summary>A case-insensitive lookup of file paths within the <see cref="RootPath"/>. Each path is listed in both file path and asset name format, so it's usable in both contexts without needing to re-parse paths.</summary>
private readonly Lazy<Dictionary<string, string>> RelativePathCache;
+ /// <summary>The case-insensitive path caches by root path.</summary>
+ private static readonly Dictionary<string, CaseInsensitivePathLookup> CachedRoots = new(StringComparer.OrdinalIgnoreCase);
+
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="rootPath">The root directory path for relative paths.</param>
- public CaseInsensitivePathCache(string rootPath)
+ /// <param name="searchOption">Which directories to scan from the root.</param>
+ public CaseInsensitivePathLookup(string rootPath, SearchOption searchOption = SearchOption.AllDirectories)
{
this.RootPath = rootPath;
- this.RelativePathCache = new(this.GetRelativePathCache);
+ this.RelativePathCache = new(() => this.GetRelativePathCache(searchOption));
}
/// <summary>Get the exact capitalization for a given relative file path.</summary>
@@ -65,6 +69,18 @@ namespace StardewModdingAPI.Utilities
this.CacheRawPath(this.RelativePathCache.Value, relativePath);
}
+ /// <summary>Get a cached dictionary of relative paths within a root path, for case-insensitive file lookups.</summary>
+ /// <param name="rootPath">The root path to scan.</param>
+ public static CaseInsensitivePathLookup GetCachedFor(string rootPath)
+ {
+ rootPath = PathUtilities.NormalizePath(rootPath);
+
+ if (!CaseInsensitivePathLookup.CachedRoots.TryGetValue(rootPath, out CaseInsensitivePathLookup? cache))
+ CaseInsensitivePathLookup.CachedRoots[rootPath] = cache = new CaseInsensitivePathLookup(rootPath);
+
+ return cache;
+ }
+
/*********
** Private methods
@@ -82,24 +98,23 @@ namespace StardewModdingAPI.Utilities
if (this.RelativePathCache.Value.TryGetValue(relativePath, out string? resolved))
return resolved;
- // file exists but isn't cached for some reason
- // cache it now so any later references to it are case-insensitive
+ // keep capitalization as-is
if (File.Exists(Path.Combine(this.RootPath, relativePath)))
{
+ // file exists but isn't cached for some reason
+ // cache it now so any later references to it are case-insensitive
this.CacheRawPath(this.RelativePathCache.Value, relativePath);
- return relativePath;
}
-
- // no such file, keep capitalization as-is
return relativePath;
}
/// <summary>Get a case-insensitive lookup of file paths (see <see cref="RelativePathCache"/>).</summary>
- private Dictionary<string, string> GetRelativePathCache()
+ /// <param name="searchOption">Which directories to scan from the root.</param>
+ private Dictionary<string, string> GetRelativePathCache(SearchOption searchOption)
{
Dictionary<string, string> cache = new(StringComparer.OrdinalIgnoreCase);
- foreach (string path in Directory.EnumerateFiles(this.RootPath, "*", SearchOption.AllDirectories))
+ foreach (string path in Directory.EnumerateFiles(this.RootPath, "*", searchOption))
{
string relativePath = path.Substring(this.RootPath.Length + 1);
diff --git a/src/SMAPI.sln.DotSettings b/src/SMAPI.sln.DotSettings
index ad546665..c8dcdb55 100644
--- a/src/SMAPI.sln.DotSettings
+++ b/src/SMAPI.sln.DotSettings
@@ -61,11 +61,13 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=Pastebin/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=pathfinding/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Pathoschild/@EntryIndexedValue">True</s:Boolean>
+ <s:Boolean x:Key="/Default/UserDictionary/Words/=Pintail/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=premultiplied/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=premultiply/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Prenormalize/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Preprocesses/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=prerelease/@EntryIndexedValue">True</s:Boolean>
+ <s:Boolean x:Key="/Default/UserDictionary/Words/=proxying/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=pufferchick/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=rasterizer/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=reimplements/@EntryIndexedValue">True</s:Boolean>
diff --git a/src/SMAPI/Framework/Content/AssetName.cs b/src/SMAPI/Framework/Content/AssetName.cs
index 4c691b9a..148354a1 100644
--- a/src/SMAPI/Framework/Content/AssetName.cs
+++ b/src/SMAPI/Framework/Content/AssetName.cs
@@ -119,25 +119,27 @@ namespace StardewModdingAPI.Framework.Content
if (prefix is null)
return false;
+ string rawTrimmed = prefix.Trim();
+
// asset keys can't have a leading slash, but NormalizeAssetName will trim them
- {
- string trimmed = prefix.TrimStart();
- if (trimmed.StartsWith('/') || trimmed.StartsWith('\\'))
- return false;
- }
+ if (rawTrimmed.StartsWith('/') || rawTrimmed.StartsWith('\\'))
+ return false;
// normalize prefix
{
string normalized = PathUtilities.NormalizeAssetName(prefix);
- string trimmed = prefix.TrimEnd();
- if (trimmed.EndsWith('/') || trimmed.EndsWith('\\'))
+ // keep trailing slash
+ if (rawTrimmed.EndsWith('/') || rawTrimmed.EndsWith('\\'))
normalized += PathUtilities.PreferredAssetSeparator;
prefix = normalized;
}
// compare
+ if (prefix.Length == 0)
+ return true;
+
return
this.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
&& (
diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs
index 92452224..aade7b36 100644
--- a/src/SMAPI/Framework/ContentCoordinator.cs
+++ b/src/SMAPI/Framework/ContentCoordinator.cs
@@ -15,7 +15,7 @@ using StardewModdingAPI.Framework.Utilities;
using StardewModdingAPI.Internal;
using StardewModdingAPI.Metadata;
using StardewModdingAPI.Toolkit.Serialization;
-using StardewModdingAPI.Utilities;
+using StardewModdingAPI.Toolkit.Utilities;
using StardewValley;
using StardewValley.GameData;
using xTile;
@@ -80,9 +80,6 @@ namespace StardewModdingAPI.Framework
/// <summary>The cached asset load/edit operations to apply, indexed by asset name.</summary>
private readonly TickCacheDictionary<IAssetName, AssetOperationGroup[]> AssetOperationsByKey = new();
- /// <summary>The previously created case-insensitive path caches by root path.</summary>
- private readonly Dictionary<string, CaseInsensitivePathCache> CaseInsensitivePathCaches = new(StringComparer.OrdinalIgnoreCase);
-
/*********
** Accessors
@@ -211,7 +208,7 @@ namespace StardewModdingAPI.Framework
jsonHelper: this.JsonHelper,
onDisposing: this.OnDisposing,
aggressiveMemoryOptimizations: this.AggressiveMemoryOptimizations,
- relativePathCache: this.GetCaseInsensitivePathCache(rootDirectory)
+ relativePathCache: CaseInsensitivePathLookup.GetCachedFor(rootDirectory)
);
this.ContentManagers.Add(manager);
return manager;
@@ -486,18 +483,6 @@ namespace StardewModdingAPI.Framework
});
}
- /// <summary>Get a dictionary of relative paths within a root path, for case-insensitive file lookups.</summary>
- /// <param name="rootPath">The root path to scan.</param>
- public CaseInsensitivePathCache GetCaseInsensitivePathCache(string rootPath)
- {
- rootPath = PathUtilities.NormalizePath(rootPath);
-
- if (!this.CaseInsensitivePathCaches.TryGetValue(rootPath, out CaseInsensitivePathCache? cache))
- this.CaseInsensitivePathCaches[rootPath] = cache = new CaseInsensitivePathCache(rootPath);
-
- return cache;
- }
-
/// <summary>Get the tilesheet ID order used by the unmodified version of a map asset.</summary>
/// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
public TilesheetReference[] GetVanillaTilesheetIds(string assetName)
diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
index f0f4bce9..8f64c5a8 100644
--- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
@@ -9,7 +9,7 @@ using Microsoft.Xna.Framework.Graphics;
using StardewModdingAPI.Framework.Exceptions;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Toolkit.Serialization;
-using StardewModdingAPI.Utilities;
+using StardewModdingAPI.Toolkit.Utilities;
using StardewValley;
using xTile;
using xTile.Format;
@@ -33,7 +33,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
private readonly IContentManager GameContentManager;
/// <summary>A case-insensitive lookup of relative paths within the <see cref="ContentManager.RootDirectory"/>.</summary>
- private readonly CaseInsensitivePathCache RelativePathCache;
+ private readonly CaseInsensitivePathLookup RelativePathCache;
/// <summary>If a map tilesheet's image source has no file extensions, the file extensions to check for in the local mod folder.</summary>
private readonly string[] LocalTilesheetExtensions = { ".png", ".xnb" };
@@ -56,7 +56,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param>
/// <param name="aggressiveMemoryOptimizations">Whether to enable more aggressive memory optimizations.</param>
/// <param name="relativePathCache">A case-insensitive lookup of relative paths within the <paramref name="rootDirectory"/>.</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, bool aggressiveMemoryOptimizations, CaseInsensitivePathCache relativePathCache)
+ 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, bool aggressiveMemoryOptimizations, CaseInsensitivePathLookup relativePathCache)
: base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: true, aggressiveMemoryOptimizations: aggressiveMemoryOptimizations)
{
this.GameContentManager = gameContentManager;
diff --git a/src/SMAPI/Framework/ContentPack.cs b/src/SMAPI/Framework/ContentPack.cs
index 2cfd5cce..dde33c95 100644
--- a/src/SMAPI/Framework/ContentPack.cs
+++ b/src/SMAPI/Framework/ContentPack.cs
@@ -2,7 +2,7 @@ using System;
using System.IO;
using StardewModdingAPI.Framework.ModHelpers;
using StardewModdingAPI.Toolkit.Serialization;
-using StardewModdingAPI.Utilities;
+using StardewModdingAPI.Toolkit.Utilities;
namespace StardewModdingAPI.Framework
{
@@ -16,7 +16,7 @@ namespace StardewModdingAPI.Framework
private readonly JsonHelper JsonHelper;
/// <summary>A case-insensitive lookup of relative paths within the <see cref="DirectoryPath"/>.</summary>
- private readonly CaseInsensitivePathCache RelativePathCache;
+ private readonly CaseInsensitivePathLookup RelativePathCache;
/*********
@@ -48,7 +48,7 @@ namespace StardewModdingAPI.Framework
/// <param name="translation">Provides translations stored in the content pack's <c>i18n</c> folder.</param>
/// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param>
/// <param name="relativePathCache">A case-insensitive lookup of relative paths within the <paramref name="directoryPath"/>.</param>
- public ContentPack(string directoryPath, IManifest manifest, IModContentHelper content, TranslationHelper translation, JsonHelper jsonHelper, CaseInsensitivePathCache relativePathCache)
+ public ContentPack(string directoryPath, IManifest manifest, IModContentHelper content, TranslationHelper translation, JsonHelper jsonHelper, CaseInsensitivePathLookup relativePathCache)
{
this.DirectoryPath = directoryPath;
this.Manifest = manifest;
diff --git a/src/SMAPI/Framework/ModHelpers/ModContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ModContentHelper.cs
index 4a058a48..def0b728 100644
--- a/src/SMAPI/Framework/ModHelpers/ModContentHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/ModContentHelper.cs
@@ -4,7 +4,7 @@ using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.ContentManagers;
using StardewModdingAPI.Framework.Exceptions;
using StardewModdingAPI.Framework.Reflection;
-using StardewModdingAPI.Utilities;
+using StardewModdingAPI.Toolkit.Utilities;
namespace StardewModdingAPI.Framework.ModHelpers
{
@@ -24,7 +24,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
private readonly string ModName;
/// <summary>A case-insensitive lookup of relative paths within the <see cref="ContentManager.RootDirectory"/>.</summary>
- private readonly CaseInsensitivePathCache RelativePathCache;
+ private readonly CaseInsensitivePathLookup RelativePathCache;
/// <summary>Simplifies access to private code.</summary>
private readonly Reflector Reflection;
@@ -41,7 +41,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <param name="gameContentManager">The game content manager used for map tilesheets not provided by the mod.</param>
/// <param name="relativePathCache">A case-insensitive lookup of relative paths within the <paramref name="relativePathCache"/>.</param>
/// <param name="reflection">Simplifies access to private code.</param>
- public ModContentHelper(ContentCoordinator contentCore, string modFolderPath, IModMetadata mod, string modName, IContentManager gameContentManager, CaseInsensitivePathCache relativePathCache, Reflector reflection)
+ public ModContentHelper(ContentCoordinator contentCore, string modFolderPath, IModMetadata mod, string modName, IContentManager gameContentManager, CaseInsensitivePathLookup relativePathCache, Reflector reflection)
: base(mod)
{
string managedAssetPrefix = contentCore.GetManagedAssetPrefix(mod.Manifest.UniqueID);
diff --git a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs
index 39cef758..348ba225 100644
--- a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs
@@ -19,7 +19,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
private readonly HashSet<string> AccessedModApis = new();
/// <summary>Generates proxy classes to access mod APIs through an arbitrary interface.</summary>
- private readonly InterfaceProxyFactory ProxyFactory;
+ private readonly IInterfaceProxyFactory ProxyFactory;
/*********
@@ -30,7 +30,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <param name="registry">The underlying mod registry.</param>
/// <param name="proxyFactory">Generates proxy classes to access mod APIs through an arbitrary interface.</param>
/// <param name="monitor">Encapsulates monitoring and logging for the mod.</param>
- public ModRegistryHelper(IModMetadata mod, ModRegistry registry, InterfaceProxyFactory proxyFactory, IMonitor monitor)
+ public ModRegistryHelper(IModMetadata mod, ModRegistry registry, IInterfaceProxyFactory proxyFactory, IMonitor monitor)
: base(mod)
{
this.Registry = registry;
diff --git a/src/SMAPI/Framework/ModLoading/ModResolver.cs b/src/SMAPI/Framework/ModLoading/ModResolver.cs
index afb388d0..74e7cb32 100644
--- a/src/SMAPI/Framework/ModLoading/ModResolver.cs
+++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs
@@ -56,9 +56,10 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <param name="mods">The mod manifests to validate.</param>
/// <param name="apiVersion">The current SMAPI version.</param>
/// <param name="getUpdateUrl">Get an update URL for an update key (if valid).</param>
+ /// <param name="validateFilesExist">Whether to validate that files referenced in the manifest (like <see cref="IManifest.EntryDll"/>) exist on disk. This can be disabled to only validate the manifest itself.</param>
[SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "Manifest values may be null before they're validated.")]
[SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalse", Justification = "Manifest values may be null before they're validated.")]
- public void ValidateManifests(IEnumerable<IModMetadata> mods, ISemanticVersion apiVersion, Func<string, string?> getUpdateUrl)
+ public void ValidateManifests(IEnumerable<IModMetadata> mods, ISemanticVersion apiVersion, Func<string, string?> getUpdateUrl, bool validateFilesExist = true)
{
mods = mods.ToArray();
@@ -140,19 +141,15 @@ namespace StardewModdingAPI.Framework.ModLoading
continue;
}
- // invalid path
- if (!File.Exists(Path.Combine(mod.DirectoryPath, mod.Manifest.EntryDll!)))
+ // file doesn't exist
+ if (validateFilesExist)
{
- mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist.");
- continue;
- }
-
- // invalid capitalization
- string? actualFilename = new DirectoryInfo(mod.DirectoryPath).GetFiles(mod.Manifest.EntryDll!).FirstOrDefault()?.Name;
- if (actualFilename != mod.Manifest.EntryDll)
- {
- mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its {nameof(IManifest.EntryDll)} value '{mod.Manifest.EntryDll}' doesn't match the actual file capitalization '{actualFilename}'. The capitalization must match for crossplatform compatibility.");
- continue;
+ string fileName = CaseInsensitivePathLookup.GetCachedFor(mod.DirectoryPath).GetFilePath(mod.Manifest.EntryDll!);
+ if (!File.Exists(Path.Combine(mod.DirectoryPath, fileName)))
+ {
+ mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist.");
+ continue;
+ }
}
}
diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs
index d626ab4d..af2e1035 100644
--- a/src/SMAPI/Framework/Models/SConfig.cs
+++ b/src/SMAPI/Framework/Models/SConfig.cs
@@ -22,7 +22,8 @@ namespace StardewModdingAPI.Framework.Models
[nameof(VerboseLogging)] = false,
[nameof(LogNetworkTraffic)] = false,
[nameof(RewriteMods)] = true,
- [nameof(AggressiveMemoryOptimizations)] = false
+ [nameof(AggressiveMemoryOptimizations)] = false,
+ [nameof(UsePintail)] = false
};
/// <summary>The default values for <see cref="SuppressUpdateChecks"/>, to log changes if different.</summary>
@@ -64,6 +65,9 @@ namespace StardewModdingAPI.Framework.Models
/// <summary>Whether to enable more aggressive memory optimizations.</summary>
public bool AggressiveMemoryOptimizations { get; }
+ /// <summary>Whether to use the experimental Pintail API proxying library, instead of the original proxying built into SMAPI itself.</summary>
+ public bool UsePintail { get; }
+
/// <summary>Whether SMAPI should log network traffic. Best combined with <see cref="VerboseLogging"/>, which includes network metadata.</summary>
public bool LogNetworkTraffic { get; }
@@ -87,10 +91,11 @@ namespace StardewModdingAPI.Framework.Models
/// <param name="verboseLogging">Whether SMAPI should log more information about the game context.</param>
/// <param name="rewriteMods">Whether SMAPI should rewrite mods for compatibility.</param>
/// <param name="aggressiveMemoryOptimizations">Whether to enable more aggressive memory optimizations.</param>
+ /// <param name="usePintail">Whether to use the experimental Pintail API proxying library, instead of the original proxying built into SMAPI itself.</param>
/// <param name="logNetworkTraffic">Whether SMAPI should log network traffic.</param>
/// <param name="consoleColors">The colors to use for text written to the SMAPI console.</param>
/// <param name="suppressUpdateChecks">The mod IDs SMAPI should ignore when performing update checks or validating update keys.</param>
- public SConfig(bool developerMode, bool checkForUpdates, bool? paranoidWarnings, bool? useBetaChannel, string gitHubProjectName, string webApiBaseUrl, bool verboseLogging, bool? rewriteMods, bool? aggressiveMemoryOptimizations, bool logNetworkTraffic, ColorSchemeConfig consoleColors, string[]? suppressUpdateChecks)
+ public SConfig(bool developerMode, bool checkForUpdates, bool? paranoidWarnings, bool? useBetaChannel, string gitHubProjectName, string webApiBaseUrl, bool verboseLogging, bool? rewriteMods, bool? aggressiveMemoryOptimizations, bool usePintail, bool logNetworkTraffic, ColorSchemeConfig consoleColors, string[]? suppressUpdateChecks)
{
this.DeveloperMode = developerMode;
this.CheckForUpdates = checkForUpdates;
@@ -101,6 +106,7 @@ namespace StardewModdingAPI.Framework.Models
this.VerboseLogging = verboseLogging;
this.RewriteMods = rewriteMods ?? (bool)SConfig.DefaultValues[nameof(SConfig.RewriteMods)];
this.AggressiveMemoryOptimizations = aggressiveMemoryOptimizations ?? (bool)SConfig.DefaultValues[nameof(SConfig.AggressiveMemoryOptimizations)];
+ this.UsePintail = usePintail;
this.LogNetworkTraffic = logNetworkTraffic;
this.ConsoleColors = consoleColors;
this.SuppressUpdateChecks = suppressUpdateChecks ?? Array.Empty<string>();
diff --git a/src/SMAPI/Framework/Reflection/IInterfaceProxyFactory.cs b/src/SMAPI/Framework/Reflection/IInterfaceProxyFactory.cs
new file mode 100644
index 00000000..6429db58
--- /dev/null
+++ b/src/SMAPI/Framework/Reflection/IInterfaceProxyFactory.cs
@@ -0,0 +1,17 @@
+namespace StardewModdingAPI.Framework.Reflection
+{
+ /// <summary>Generates proxy classes to access mod APIs through an arbitrary interface.</summary>
+ internal interface IInterfaceProxyFactory
+ {
+ /*********
+ ** Methods
+ *********/
+ /// <summary>Create an API proxy.</summary>
+ /// <typeparam name="TInterface">The interface through which to access the API.</typeparam>
+ /// <param name="instance">The API instance to access.</param>
+ /// <param name="sourceModID">The unique ID of the mod consuming the API.</param>
+ /// <param name="targetModID">The unique ID of the mod providing the API.</param>
+ TInterface CreateProxy<TInterface>(object instance, string sourceModID, string targetModID)
+ where TInterface : class;
+ }
+}
diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs
index 40adde8e..694c563d 100644
--- a/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs
+++ b/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs
@@ -4,8 +4,8 @@ using Nanoray.Pintail;
namespace StardewModdingAPI.Framework.Reflection
{
- /// <summary>Generates proxy classes to access mod APIs through an arbitrary interface.</summary>
- internal class InterfaceProxyFactory
+ /// <inheritdoc />
+ internal class InterfaceProxyFactory : IInterfaceProxyFactory
{
/*********
** Fields
@@ -28,11 +28,7 @@ namespace StardewModdingAPI.Framework.Reflection
));
}
- /// <summary>Create an API proxy.</summary>
- /// <typeparam name="TInterface">The interface through which to access the API.</typeparam>
- /// <param name="instance">The API instance to access.</param>
- /// <param name="sourceModID">The unique ID of the mod consuming the API.</param>
- /// <param name="targetModID">The unique ID of the mod providing the API.</param>
+ /// <inheritdoc />
public TInterface CreateProxy<TInterface>(object instance, string sourceModID, string targetModID)
where TInterface : class
{
diff --git a/src/SMAPI/Framework/Reflection/OriginalInterfaceProxyBuilder.cs b/src/SMAPI/Framework/Reflection/OriginalInterfaceProxyBuilder.cs
new file mode 100644
index 00000000..9576f768
--- /dev/null
+++ b/src/SMAPI/Framework/Reflection/OriginalInterfaceProxyBuilder.cs
@@ -0,0 +1,118 @@
+using System;
+using System.Linq;
+using System.Reflection;
+using System.Reflection.Emit;
+
+namespace StardewModdingAPI.Framework.Reflection
+{
+ /// <summary>Generates a proxy class to access a mod API through an arbitrary interface.</summary>
+ internal class OriginalInterfaceProxyBuilder
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The target class type.</summary>
+ private readonly Type TargetType;
+
+ /// <summary>The generated proxy type.</summary>
+ private readonly Type ProxyType;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="name">The type name to generate.</param>
+ /// <param name="moduleBuilder">The CLR module in which to create proxy classes.</param>
+ /// <param name="interfaceType">The interface type to implement.</param>
+ /// <param name="targetType">The target type.</param>
+ public OriginalInterfaceProxyBuilder(string name, ModuleBuilder moduleBuilder, Type interfaceType, Type targetType)
+ {
+ // validate
+ if (name == null)
+ throw new ArgumentNullException(nameof(name));
+ if (targetType == null)
+ throw new ArgumentNullException(nameof(targetType));
+
+ // define proxy type
+ TypeBuilder proxyBuilder = moduleBuilder.DefineType(name, TypeAttributes.Public | TypeAttributes.Class);
+ proxyBuilder.AddInterfaceImplementation(interfaceType);
+
+ // create field to store target instance
+ FieldBuilder targetField = proxyBuilder.DefineField("__Target", targetType, FieldAttributes.Private);
+
+ // create constructor which accepts target instance and sets field
+ {
+ ConstructorBuilder constructor = proxyBuilder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard | CallingConventions.HasThis, new[] { targetType });
+ ILGenerator il = constructor.GetILGenerator();
+
+ il.Emit(OpCodes.Ldarg_0); // this
+ // ReSharper disable once AssignNullToNotNullAttribute -- never null
+ il.Emit(OpCodes.Call, typeof(object).GetConstructor(Type.EmptyTypes)!); // call base constructor
+ il.Emit(OpCodes.Ldarg_0); // this
+ il.Emit(OpCodes.Ldarg_1); // load argument
+ il.Emit(OpCodes.Stfld, targetField); // set field to loaded argument
+ il.Emit(OpCodes.Ret);
+ }
+
+ // proxy methods
+ foreach (MethodInfo proxyMethod in interfaceType.GetMethods())
+ {
+ var targetMethod = targetType.GetMethod(proxyMethod.Name, proxyMethod.GetParameters().Select(a => a.ParameterType).ToArray());
+ if (targetMethod == null)
+ throw new InvalidOperationException($"The {interfaceType.FullName} interface defines method {proxyMethod.Name} which doesn't exist in the API.");
+
+ this.ProxyMethod(proxyBuilder, targetMethod, targetField);
+ }
+
+ // save info
+ this.TargetType = targetType;
+ this.ProxyType = proxyBuilder.CreateType()!;
+ }
+
+ /// <summary>Create an instance of the proxy for a target instance.</summary>
+ /// <param name="targetInstance">The target instance.</param>
+ public object CreateInstance(object targetInstance)
+ {
+ ConstructorInfo? constructor = this.ProxyType.GetConstructor(new[] { this.TargetType });
+ if (constructor == null)
+ throw new InvalidOperationException($"Couldn't find the constructor for generated proxy type '{this.ProxyType.Name}'."); // should never happen
+ return constructor.Invoke(new[] { targetInstance });
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Define a method which proxies access to a method on the target.</summary>
+ /// <param name="proxyBuilder">The proxy type being generated.</param>
+ /// <param name="target">The target method.</param>
+ /// <param name="instanceField">The proxy field containing the API instance.</param>
+ private void ProxyMethod(TypeBuilder proxyBuilder, MethodInfo target, FieldBuilder instanceField)
+ {
+ Type[] argTypes = target.GetParameters().Select(a => a.ParameterType).ToArray();
+
+ // create method
+ MethodBuilder methodBuilder = proxyBuilder.DefineMethod(target.Name, MethodAttributes.Public | MethodAttributes.Final | MethodAttributes.Virtual);
+ methodBuilder.SetParameters(argTypes);
+ methodBuilder.SetReturnType(target.ReturnType);
+
+ // create method body
+ {
+ ILGenerator il = methodBuilder.GetILGenerator();
+
+ // load target instance
+ il.Emit(OpCodes.Ldarg_0);
+ il.Emit(OpCodes.Ldfld, instanceField);
+
+ // invoke target method on instance
+ for (int i = 0; i < argTypes.Length; i++)
+ il.Emit(OpCodes.Ldarg, i + 1);
+ il.Emit(OpCodes.Call, target);
+
+ // return result
+ il.Emit(OpCodes.Ret);
+ }
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Reflection/OriginalInterfaceProxyFactory.cs b/src/SMAPI/Framework/Reflection/OriginalInterfaceProxyFactory.cs
new file mode 100644
index 00000000..d6966978
--- /dev/null
+++ b/src/SMAPI/Framework/Reflection/OriginalInterfaceProxyFactory.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using System.Reflection.Emit;
+
+namespace StardewModdingAPI.Framework.Reflection
+{
+ /// <inheritdoc />
+ internal class OriginalInterfaceProxyFactory : IInterfaceProxyFactory
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The CLR module in which to create proxy classes.</summary>
+ private readonly ModuleBuilder ModuleBuilder;
+
+ /// <summary>The generated proxy types.</summary>
+ private readonly IDictionary<string, OriginalInterfaceProxyBuilder> Builders = new Dictionary<string, OriginalInterfaceProxyBuilder>();
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ public OriginalInterfaceProxyFactory()
+ {
+ AssemblyBuilder assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName($"StardewModdingAPI.Proxies, Version={this.GetType().Assembly.GetName().Version}, Culture=neutral"), AssemblyBuilderAccess.Run);
+ this.ModuleBuilder = assemblyBuilder.DefineDynamicModule("StardewModdingAPI.Proxies");
+ }
+
+ /// <inheritdoc />
+ public TInterface CreateProxy<TInterface>(object instance, string sourceModID, string targetModID)
+ where TInterface : class
+ {
+ lock (this.Builders)
+ {
+ // validate
+ if (instance == null)
+ throw new InvalidOperationException("Can't proxy access to a null API.");
+ if (!typeof(TInterface).IsInterface)
+ throw new InvalidOperationException("The proxy type must be an interface, not a class.");
+
+ // get proxy type
+ Type targetType = instance.GetType();
+ string proxyTypeName = $"StardewModdingAPI.Proxies.From<{sourceModID}_{typeof(TInterface).FullName}>_To<{targetModID}_{targetType.FullName}>";
+ if (!this.Builders.TryGetValue(proxyTypeName, out OriginalInterfaceProxyBuilder? builder))
+ {
+ builder = new OriginalInterfaceProxyBuilder(proxyTypeName, this.ModuleBuilder, typeof(TInterface), targetType);
+ this.Builders[proxyTypeName] = builder;
+ }
+
+ // create instance
+ return (TInterface)builder.CreateInstance(instance);
+ }
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs
index 990fe5ea..44853627 100644
--- a/src/SMAPI/Framework/SCore.cs
+++ b/src/SMAPI/Framework/SCore.cs
@@ -1560,7 +1560,9 @@ namespace StardewModdingAPI.Framework
{
// init
HashSet<string> suppressUpdateChecks = new HashSet<string>(this.Settings.SuppressUpdateChecks, StringComparer.OrdinalIgnoreCase);
- InterfaceProxyFactory proxyFactory = new();
+ IInterfaceProxyFactory proxyFactory = this.Settings.UsePintail
+ ? new InterfaceProxyFactory()
+ : new OriginalInterfaceProxyFactory();
// load mods
foreach (IModMetadata mod in mods)
@@ -1699,7 +1701,7 @@ namespace StardewModdingAPI.Framework
/// <param name="errorReasonPhrase">The user-facing reason phrase explaining why the mod couldn't be loaded (if applicable).</param>
/// <param name="errorDetails">More detailed details about the error intended for developers (if any).</param>
/// <returns>Returns whether the mod was successfully loaded.</returns>
- private bool TryLoadMod(IModMetadata mod, IModMetadata[] mods, AssemblyLoader assemblyLoader, InterfaceProxyFactory proxyFactory, JsonHelper jsonHelper, ContentCoordinator contentCore, ModDatabase modDatabase, HashSet<string> suppressUpdateChecks, [NotNullWhen(false)] out ModFailReason? failReason, out string? errorReasonPhrase, out string? errorDetails)
+ private bool TryLoadMod(IModMetadata mod, IModMetadata[] mods, AssemblyLoader assemblyLoader, IInterfaceProxyFactory proxyFactory, JsonHelper jsonHelper, ContentCoordinator contentCore, ModDatabase modDatabase, HashSet<string> suppressUpdateChecks, [NotNullWhen(false)] out ModFailReason? failReason, out string? errorReasonPhrase, out string? errorDetails)
{
errorDetails = null;
@@ -1748,7 +1750,7 @@ namespace StardewModdingAPI.Framework
if (mod.IsContentPack)
{
IMonitor monitor = this.LogManager.GetMonitor(mod.DisplayName);
- CaseInsensitivePathCache relativePathCache = this.ContentCore.GetCaseInsensitivePathCache(mod.DirectoryPath);
+ CaseInsensitivePathLookup relativePathCache = CaseInsensitivePathLookup.GetCachedFor(mod.DirectoryPath);
GameContentHelper gameContentHelper = new(this.ContentCore, mod, mod.DisplayName, monitor, this.Reflection);
IModContentHelper modContentHelper = new ModContentHelper(this.ContentCore, mod.DirectoryPath, mod, mod.DisplayName, gameContentHelper.GetUnderlyingContentManager(), relativePathCache, this.Reflection);
TranslationHelper translationHelper = new(mod, contentCore.GetLocale(), contentCore.Language);
@@ -1765,7 +1767,10 @@ namespace StardewModdingAPI.Framework
else
{
// get mod info
- string assemblyPath = Path.Combine(mod.DirectoryPath, manifest.EntryDll!);
+ string assemblyPath = Path.Combine(
+ mod.DirectoryPath,
+ CaseInsensitivePathLookup.GetCachedFor(mod.DirectoryPath).GetFilePath(manifest.EntryDll!)
+ );
// load mod
Assembly modAssembly;
@@ -1830,7 +1835,7 @@ namespace StardewModdingAPI.Framework
{
IMonitor packMonitor = this.LogManager.GetMonitor(packManifest.Name);
- CaseInsensitivePathCache relativePathCache = this.ContentCore.GetCaseInsensitivePathCache(packDirPath);
+ CaseInsensitivePathLookup relativePathCache = CaseInsensitivePathLookup.GetCachedFor(packDirPath);
GameContentHelper gameContentHelper = new(contentCore, mod, packManifest.Name, packMonitor, this.Reflection);
IModContentHelper packContentHelper = new ModContentHelper(contentCore, packDirPath, mod, packManifest.Name, gameContentHelper.GetUnderlyingContentManager(), relativePathCache, this.Reflection);
@@ -1844,7 +1849,7 @@ namespace StardewModdingAPI.Framework
IModEvents events = new ModEvents(mod, this.EventManager);
ICommandHelper commandHelper = new CommandHelper(mod, this.CommandManager);
- CaseInsensitivePathCache relativePathCache = this.ContentCore.GetCaseInsensitivePathCache(mod.DirectoryPath);
+ CaseInsensitivePathLookup relativePathCache = CaseInsensitivePathLookup.GetCachedFor(mod.DirectoryPath);
#pragma warning disable CS0612 // deprecated code
ContentHelper contentHelper = new(contentCore, mod.DirectoryPath, mod, monitor, this.Reflection);
#pragma warning restore CS0612
diff --git a/src/SMAPI/Framework/Translator.cs b/src/SMAPI/Framework/Translator.cs
index b230a727..3beee250 100644
--- a/src/SMAPI/Framework/Translator.cs
+++ b/src/SMAPI/Framework/Translator.cs
@@ -51,7 +51,8 @@ namespace StardewModdingAPI.Framework
foreach (string key in this.GetAllKeysRaw())
{
string? text = this.GetRaw(key, locale, withFallback: true);
- this.ForLocale.Add(key, new Translation(this.Locale, key, text));
+ if (text != null)
+ this.ForLocale.Add(key, new Translation(this.Locale, key, text));
}
}
diff --git a/src/SMAPI/SMAPI.config.json b/src/SMAPI/SMAPI.config.json
index 49056e83..a317e6b8 100644
--- a/src/SMAPI/SMAPI.config.json
+++ b/src/SMAPI/SMAPI.config.json
@@ -47,6 +47,12 @@ copy all the settings, or you may cause bugs due to overridden changes in future
"AggressiveMemoryOptimizations": false,
/**
+ * Whether to use the experimental Pintail API proxying library, instead of the original
+ * proxying built into SMAPI itself.
+ */
+ "UsePintail": false,
+
+ /**
* Whether to add a section to the 'mod issues' list for mods which directly use potentially
* sensitive .NET APIs like file or shell access. Note that many mods do this legitimately as
* part of their normal functionality, so these warnings are meaningless without further