diff options
Diffstat (limited to 'src')
26 files changed, 449 insertions, 152 deletions
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 |