summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/release-notes.md21
-rw-r--r--src/SMAPI.Internal/ExceptionHelper.cs2
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowGameFilesCommand.cs6
-rw-r--r--src/SMAPI.Mods.ErrorHandler/ModEntry.cs2
-rw-r--r--src/SMAPI.Mods.SaveBackup/ModEntry.cs2
-rw-r--r--src/SMAPI.Tests.ModApiConsumer/ApiConsumer.cs46
-rw-r--r--src/SMAPI.Tests.ModApiConsumer/Interfaces/ISimpleApi.cs79
-rw-r--r--src/SMAPI.Tests.ModApiConsumer/README.md3
-rw-r--r--src/SMAPI.Tests.ModApiConsumer/SMAPI.Tests.ModApiConsumer.csproj11
-rw-r--r--src/SMAPI.Tests.ModApiProvider/Framework/BaseApi.cs12
-rw-r--r--src/SMAPI.Tests.ModApiProvider/Framework/SimpleApi.cs108
-rw-r--r--src/SMAPI.Tests.ModApiProvider/ProviderMod.cs38
-rw-r--r--src/SMAPI.Tests.ModApiProvider/README.md3
-rw-r--r--src/SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj7
-rw-r--r--src/SMAPI.Tests/Core/AssetNameTests.cs295
-rw-r--r--src/SMAPI.Tests/Core/InterfaceProxyTests.cs345
-rw-r--r--src/SMAPI.Tests/Core/ModResolverTests.cs16
-rw-r--r--src/SMAPI.Tests/SMAPI.Tests.csproj7
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs4
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs3
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs4
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs2
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs2
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs4
-rw-r--r--src/SMAPI.Toolkit/Framework/ModData/ModDatabase.cs2
-rw-r--r--src/SMAPI.Toolkit/Serialization/Models/Manifest.cs7
-rw-r--r--src/SMAPI.Web/Controllers/IndexController.cs2
-rw-r--r--src/SMAPI.Web/Controllers/ModsApiController.cs2
-rw-r--r--src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs2
-rw-r--r--src/SMAPI.Web/Framework/Clients/GenericModPage.cs3
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/Models/LogModInfo.cs3
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs2
-rw-r--r--src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs2
-rw-r--r--src/SMAPI.Web/ViewModels/LogParserModel.cs2
-rw-r--r--src/SMAPI.Web/Views/LogParser/Index.cshtml53
-rw-r--r--src/SMAPI.Web/Views/Mods/Index.cshtml2
-rw-r--r--src/SMAPI.sln14
-rw-r--r--src/SMAPI/Constants.cs58
-rw-r--r--src/SMAPI/Events/ButtonsChangedEventArgs.cs2
-rw-r--r--src/SMAPI/Framework/Content/AssetData.cs4
-rw-r--r--src/SMAPI/Framework/Content/AssetDataForDictionary.cs4
-rw-r--r--src/SMAPI/Framework/Content/AssetDataForImage.cs30
-rw-r--r--src/SMAPI/Framework/Content/AssetDataForMap.cs4
-rw-r--r--src/SMAPI/Framework/Content/AssetDataForObject.cs12
-rw-r--r--src/SMAPI/Framework/Content/AssetInfo.cs16
-rw-r--r--src/SMAPI/Framework/Content/AssetInterceptorChange.cs4
-rw-r--r--src/SMAPI/Framework/Content/AssetName.cs173
-rw-r--r--src/SMAPI/Framework/ContentCoordinator.cs94
-rw-r--r--src/SMAPI/Framework/ContentManagers/BaseContentManager.cs53
-rw-r--r--src/SMAPI/Framework/ContentManagers/GameContentManager.cs94
-rw-r--r--src/SMAPI/Framework/ContentManagers/IContentManager.cs3
-rw-r--r--src/SMAPI/Framework/ContentManagers/ModContentManager.cs36
-rw-r--r--src/SMAPI/Framework/Events/ManagedEvent.cs2
-rw-r--r--src/SMAPI/Framework/Input/GamePadStateBuilder.cs8
-rw-r--r--src/SMAPI/Framework/ModHelpers/ContentHelper.cs5
-rw-r--r--src/SMAPI/Framework/Models/SConfig.cs4
-rw-r--r--src/SMAPI/Framework/SCore.cs17
-rw-r--r--src/SMAPI/Framework/StateTracking/FieldWatchers/ImmutableCollectionWatcher.cs5
-rw-r--r--src/SMAPI/Framework/StateTracking/LocationTracker.cs3
-rw-r--r--src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs2
-rw-r--r--src/SMAPI/Framework/TemporaryHacks/MiniMonoModHotfix.cs2
-rw-r--r--src/SMAPI/IAssetInfo.cs5
-rw-r--r--src/SMAPI/IAssetName.cs44
-rw-r--r--src/SMAPI/Metadata/CoreAssetPropagator.cs458
-rw-r--r--src/SMAPI/Program.cs10
-rw-r--r--src/SMAPI/Utilities/Keybind.cs4
-rw-r--r--src/SMAPI/Utilities/KeybindList.cs4
67 files changed, 1744 insertions, 534 deletions
diff --git a/docs/release-notes.md b/docs/release-notes.md
index 585644ef..2448995e 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -4,15 +4,28 @@
## Upcoming release
* For players:
* Improved translations. Thanks to ChulkyBow (updated Ukrainian)!
+ * Fixed `player_add` console command's handling of Journal Scraps and Secret Notes.
* For mod authors:
- * The `SDate` constructor is no longer case-sensitive for season names.
+ * Added `Constants.ContentPath`.
+ * Added `IAssetName Name` field to the info received by `IAssetEditor` and `IAssetLoader` methods.
+ _This adds methods for working with asset names, parsed locales, etc._
+ * Fixed the `SDate` constructor being case-sensitive.
+ * Fixed support for using locale codes from custom languages in asset names (e.g. `Data/Achievements.eo-EU`).
+ * Fixed issue where suppressing `[Left|Right]Thumbstick[Down|Left]` keys would suppress the opposite direction instead.
-* For console commands:
- * Fixed `player_add` with Journal Scraps and Secret Notes.
+* **Deprecation warning for mod authors:**
+ These APIs are now deprecated and will be removed in the upcoming SMAPI 4.0.0.
+
+ API | how to update code
+ :-- | :-----------------
+ `Constants.ExecutionPath` | Use `Constants.GamePath` instead.
+ `IAssetInfo.AssetName`<br />`IAssetData.AssetName` | Use `Name` instead, which changes the type from `string` to the new `AssetName`.
+ `IAssetInfo.AssetNameEquals`<br />`IAssetData.AssetNameEquals` | Use `Name.IsEquivalentTo` instead.
* For the web UI:
- * Fixed JSON validator warning for update keys without a subkey.
+ * Added `data-*` attributes to log parser page for external tools.
+ * Fixed JSON validator warning shown for update keys without a subkey.
## 3.13.4
Released 16 January 2022 for Stardew Valley 1.5.6 or later.
diff --git a/src/SMAPI.Internal/ExceptionHelper.cs b/src/SMAPI.Internal/ExceptionHelper.cs
index 05b96c2e..03d48911 100644
--- a/src/SMAPI.Internal/ExceptionHelper.cs
+++ b/src/SMAPI.Internal/ExceptionHelper.cs
@@ -25,7 +25,7 @@ namespace StardewModdingAPI.Internal
case ReflectionTypeLoadException ex:
string summary = ex.ToString();
- foreach (Exception childEx in ex.LoaderExceptions ?? new Exception[0])
+ foreach (Exception childEx in ex.LoaderExceptions ?? Array.Empty<Exception>())
summary += $"\n\n{childEx?.GetLogSummary()}";
message = summary;
break;
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowGameFilesCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowGameFilesCommand.cs
index 71093184..b97cb3e6 100644
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowGameFilesCommand.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowGameFilesCommand.cs
@@ -1,4 +1,4 @@
-using System.Diagnostics;
+using System.Diagnostics;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other
{
@@ -18,8 +18,8 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other
/// <param name="args">The command arguments.</param>
public override void Handle(IMonitor monitor, string command, ArgumentParser args)
{
- Process.Start(Constants.ExecutionPath);
- monitor.Log($"OK, opening {Constants.ExecutionPath}.", LogLevel.Info);
+ Process.Start(Constants.GamePath);
+ monitor.Log($"OK, opening {Constants.GamePath}.", LogLevel.Info);
}
}
}
diff --git a/src/SMAPI.Mods.ErrorHandler/ModEntry.cs b/src/SMAPI.Mods.ErrorHandler/ModEntry.cs
index 7286e316..2d6242cf 100644
--- a/src/SMAPI.Mods.ErrorHandler/ModEntry.cs
+++ b/src/SMAPI.Mods.ErrorHandler/ModEntry.cs
@@ -80,7 +80,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler
MethodInfo getMonitorForGame = coreType.GetMethod("GetMonitorForGame")
?? throw new InvalidOperationException("Can't access the SMAPI's 'GetMonitorForGame' method. This mod may not work correctly.");
- return (IMonitor)getMonitorForGame.Invoke(core, new object[0]) ?? this.Monitor;
+ return (IMonitor)getMonitorForGame.Invoke(core, Array.Empty<object>()) ?? this.Monitor;
}
}
}
diff --git a/src/SMAPI.Mods.SaveBackup/ModEntry.cs b/src/SMAPI.Mods.SaveBackup/ModEntry.cs
index d6414e9c..b89bb9c3 100644
--- a/src/SMAPI.Mods.SaveBackup/ModEntry.cs
+++ b/src/SMAPI.Mods.SaveBackup/ModEntry.cs
@@ -19,7 +19,7 @@ namespace StardewModdingAPI.Mods.SaveBackup
private readonly int BackupsToKeep = 10;
/// <summary>The absolute path to the folder in which to store save backups.</summary>
- private readonly string BackupFolder = Path.Combine(Constants.ExecutionPath, "save-backups");
+ private readonly string BackupFolder = Path.Combine(Constants.GamePath, "save-backups");
/// <summary>A unique label for the save backup to create.</summary>
private readonly string BackupLabel = $"{DateTime.UtcNow:yyyy-MM-dd} - SMAPI {Constants.ApiVersion} with Stardew Valley {Game1.version}";
diff --git a/src/SMAPI.Tests.ModApiConsumer/ApiConsumer.cs b/src/SMAPI.Tests.ModApiConsumer/ApiConsumer.cs
new file mode 100644
index 00000000..2c7f9952
--- /dev/null
+++ b/src/SMAPI.Tests.ModApiConsumer/ApiConsumer.cs
@@ -0,0 +1,46 @@
+using System;
+using SMAPI.Tests.ModApiConsumer.Interfaces;
+
+namespace SMAPI.Tests.ModApiConsumer
+{
+ /// <summary>A simulated API consumer.</summary>
+ public class ApiConsumer
+ {
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Call the event field on the given API.</summary>
+ /// <param name="api">The API to call.</param>
+ /// <param name="getValues">Get the number of times the event was called and the last value received.</param>
+ public void UseEventField(ISimpleApi api, out Func<(int timesCalled, int actualValue)> getValues)
+ {
+ // act
+ int calls = 0;
+ int lastValue = -1;
+ api.OnEventRaised += (sender, value) =>
+ {
+ calls++;
+ lastValue = value;
+ };
+
+ getValues = () => (timesCalled: calls, actualValue: lastValue);
+ }
+
+ /// <summary>Call the event property on the given API.</summary>
+ /// <param name="api">The API to call.</param>
+ /// <param name="getValues">Get the number of times the event was called and the last value received.</param>
+ public void UseEventProperty(ISimpleApi api, out Func<(int timesCalled, int actualValue)> getValues)
+ {
+ // act
+ int calls = 0;
+ int lastValue = -1;
+ api.OnEventRaisedProperty += (sender, value) =>
+ {
+ calls++;
+ lastValue = value;
+ };
+
+ getValues = () => (timesCalled: calls, actualValue: lastValue);
+ }
+ }
+}
diff --git a/src/SMAPI.Tests.ModApiConsumer/Interfaces/ISimpleApi.cs b/src/SMAPI.Tests.ModApiConsumer/Interfaces/ISimpleApi.cs
new file mode 100644
index 00000000..7f94e137
--- /dev/null
+++ b/src/SMAPI.Tests.ModApiConsumer/Interfaces/ISimpleApi.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+
+namespace SMAPI.Tests.ModApiConsumer.Interfaces
+{
+ /// <summary>A mod-provided API which provides basic events, properties, and methods.</summary>
+ public interface ISimpleApi
+ {
+ /*********
+ ** Test interface
+ *********/
+ /****
+ ** Events
+ ****/
+ /// <summary>A simple event field.</summary>
+ event EventHandler<int> OnEventRaised;
+
+ /// <summary>A simple event property with custom add/remove logic.</summary>
+ event EventHandler<int> OnEventRaisedProperty;
+
+
+ /****
+ ** Properties
+ ****/
+ /// <summary>A simple numeric property.</summary>
+ int NumberProperty { get; set; }
+
+ /// <summary>A simple object property.</summary>
+ object ObjectProperty { get; set; }
+
+ /// <summary>A simple list property.</summary>
+ List<string> ListProperty { get; set; }
+
+ /// <summary>A simple list property with an interface.</summary>
+ IList<string> ListPropertyWithInterface { get; set; }
+
+ /// <summary>A property with nested generics.</summary>
+ IDictionary<string, IList<string>> GenericsProperty { get; set; }
+
+ /// <summary>A property using an enum available to both mods.</summary>
+ BindingFlags EnumProperty { get; set; }
+
+ /// <summary>A read-only property.</summary>
+ int GetterProperty { get; }
+
+
+ /****
+ ** Methods
+ ****/
+ /// <summary>A simple method with no return value.</summary>
+ void GetNothing();
+
+ /// <summary>A simple method which returns a number.</summary>
+ int GetInt(int value);
+
+ /// <summary>A simple method which returns an object.</summary>
+ object GetObject(object value);
+
+ /// <summary>A simple method which returns a list.</summary>
+ List<string> GetList(string value);
+
+ /// <summary>A simple method which returns a list with an interface.</summary>
+ IList<string> GetListWithInterface(string value);
+
+ /// <summary>A simple method which returns nested generics.</summary>
+ IDictionary<string, IList<string>> GetGenerics(string key, string value);
+
+ /// <summary>A simple method which returns a lambda.</summary>
+ Func<string, string> GetLambda(Func<string, string> value);
+
+
+ /****
+ ** Inherited members
+ ****/
+ /// <summary>A property inherited from a base class.</summary>
+ public string InheritedProperty { get; set; }
+ }
+}
diff --git a/src/SMAPI.Tests.ModApiConsumer/README.md b/src/SMAPI.Tests.ModApiConsumer/README.md
new file mode 100644
index 00000000..ed0c6e3f
--- /dev/null
+++ b/src/SMAPI.Tests.ModApiConsumer/README.md
@@ -0,0 +1,3 @@
+This project contains a simulated [mod-provided API] consumer used in the API proxying unit tests.
+
+[mod-provided API]: https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Integrations
diff --git a/src/SMAPI.Tests.ModApiConsumer/SMAPI.Tests.ModApiConsumer.csproj b/src/SMAPI.Tests.ModApiConsumer/SMAPI.Tests.ModApiConsumer.csproj
new file mode 100644
index 00000000..7fef4ebd
--- /dev/null
+++ b/src/SMAPI.Tests.ModApiConsumer/SMAPI.Tests.ModApiConsumer.csproj
@@ -0,0 +1,11 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <TargetFramework>net5.0</TargetFramework>
+ </PropertyGroup>
+
+ <Import Project="..\..\build\common.targets" />
+
+ <ItemGroup>
+ <ProjectReference Include="..\SMAPI\SMAPI.csproj" />
+ </ItemGroup>
+</Project>
diff --git a/src/SMAPI.Tests.ModApiProvider/Framework/BaseApi.cs b/src/SMAPI.Tests.ModApiProvider/Framework/BaseApi.cs
new file mode 100644
index 00000000..8092e3e7
--- /dev/null
+++ b/src/SMAPI.Tests.ModApiProvider/Framework/BaseApi.cs
@@ -0,0 +1,12 @@
+namespace SMAPI.Tests.ModApiProvider.Framework
+{
+ /// <summary>The base class for <see cref="SimpleApi"/>.</summary>
+ public class BaseApi
+ {
+ /*********
+ ** Test interface
+ *********/
+ /// <summary>A property inherited from a base class.</summary>
+ public string InheritedProperty { get; set; }
+ }
+}
diff --git a/src/SMAPI.Tests.ModApiProvider/Framework/SimpleApi.cs b/src/SMAPI.Tests.ModApiProvider/Framework/SimpleApi.cs
new file mode 100644
index 00000000..1100af36
--- /dev/null
+++ b/src/SMAPI.Tests.ModApiProvider/Framework/SimpleApi.cs
@@ -0,0 +1,108 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+
+namespace SMAPI.Tests.ModApiProvider.Framework
+{
+ /// <summary>A mod-provided API which provides basic events, properties, and methods.</summary>
+ public class SimpleApi : BaseApi
+ {
+ /*********
+ ** Test interface
+ *********/
+ /****
+ ** Events
+ ****/
+ /// <summary>A simple event field.</summary>
+ public event EventHandler<int> OnEventRaised;
+
+ /// <summary>A simple event property with custom add/remove logic.</summary>
+ public event EventHandler<int> OnEventRaisedProperty
+ {
+ add => this.OnEventRaised += value;
+ remove => this.OnEventRaised -= value;
+ }
+
+
+ /****
+ ** Properties
+ ****/
+ /// <summary>A simple numeric property.</summary>
+ public int NumberProperty { get; set; }
+
+ /// <summary>A simple object property.</summary>
+ public object ObjectProperty { get; set; }
+
+ /// <summary>A simple list property.</summary>
+ public List<string> ListProperty { get; set; }
+
+ /// <summary>A simple list property with an interface.</summary>
+ public IList<string> ListPropertyWithInterface { get; set; }
+
+ /// <summary>A property with nested generics.</summary>
+ public IDictionary<string, IList<string>> GenericsProperty { get; set; }
+
+ /// <summary>A property using an enum available to both mods.</summary>
+ public BindingFlags EnumProperty { get; set; }
+
+ /// <summary>A read-only property.</summary>
+ public int GetterProperty => 42;
+
+
+ /****
+ ** Methods
+ ****/
+ /// <summary>A simple method with no return value.</summary>
+ public void GetNothing() { }
+
+ /// <summary>A simple method which returns a number.</summary>
+ public int GetInt(int value)
+ {
+ return value;
+ }
+
+ /// <summary>A simple method which returns an object.</summary>
+ public object GetObject(object value)
+ {
+ return value;
+ }
+
+ /// <summary>A simple method which returns a list.</summary>
+ public List<string> GetList(string value)
+ {
+ return new() { value };
+ }
+
+ /// <summary>A simple method which returns a list with an interface.</summary>
+ public IList<string> GetListWithInterface(string value)
+ {
+ return new List<string> { value };
+ }
+
+ /// <summary>A simple method which returns nested generics.</summary>
+ public IDictionary<string, IList<string>> GetGenerics(string key, string value)
+ {
+ return new Dictionary<string, IList<string>>
+ {
+ [key] = new List<string> { value }
+ };
+ }
+
+ /// <summary>A simple method which returns a lambda.</summary>
+ public Func<string, string> GetLambda(Func<string, string> value)
+ {
+ return value;
+ }
+
+
+ /*********
+ ** Helper methods
+ *********/
+ /// <summary>Raise the <see cref="OnEventRaised"/> event.</summary>
+ /// <param name="value">The value to pass to the event.</param>
+ public void RaiseEventField(int value)
+ {
+ this.OnEventRaised?.Invoke(null, value);
+ }
+ }
+}
diff --git a/src/SMAPI.Tests.ModApiProvider/ProviderMod.cs b/src/SMAPI.Tests.ModApiProvider/ProviderMod.cs
new file mode 100644
index 00000000..c36e1c6d
--- /dev/null
+++ b/src/SMAPI.Tests.ModApiProvider/ProviderMod.cs
@@ -0,0 +1,38 @@
+using System.Collections.Generic;
+using System.Reflection;
+using SMAPI.Tests.ModApiProvider.Framework;
+
+namespace SMAPI.Tests.ModApiProvider
+{
+ /// <summary>A simulated mod instance.</summary>
+ public class ProviderMod
+ {
+ /// <summary>The underlying API instance.</summary>
+ private readonly SimpleApi Api = new();
+
+ /// <summary>Get the mod API instance.</summary>
+ public object GetModApi()
+ {
+ return this.Api;
+ }
+
+ /// <summary>Raise the <see cref="SimpleApi.OnEventRaised"/> event.</summary>
+ /// <param name="value">The value to send as an event argument.</param>
+ public void RaiseEvent(int value)
+ {
+ this.Api.RaiseEventField(value);
+ }
+
+ /// <summary>Set the values for the API property.</summary>
+ public void SetPropertyValues(int number, object obj, string listValue, string listWithInterfaceValue, string dictionaryKey, string dictionaryListValue, BindingFlags enumValue, string inheritedValue)
+ {
+ this.Api.NumberProperty = number;
+ this.Api.ObjectProperty = obj;
+ this.Api.ListProperty = new List<string> { listValue };
+ this.Api.ListPropertyWithInterface = new List<string> { listWithInterfaceValue };
+ this.Api.GenericsProperty = new Dictionary<string, IList<string>> { [dictionaryKey] = new List<string> { dictionaryListValue } };
+ this.Api.EnumProperty = enumValue;
+ this.Api.InheritedProperty = inheritedValue;
+ }
+ }
+}
diff --git a/src/SMAPI.Tests.ModApiProvider/README.md b/src/SMAPI.Tests.ModApiProvider/README.md
new file mode 100644
index 00000000..c79838e0
--- /dev/null
+++ b/src/SMAPI.Tests.ModApiProvider/README.md
@@ -0,0 +1,3 @@
+This project contains simulated [mod-provided APIs] used in the API proxying unit tests.
+
+[mod-provided APIs]: https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Integrations
diff --git a/src/SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj b/src/SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj
new file mode 100644
index 00000000..70d5a0ce
--- /dev/null
+++ b/src/SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj
@@ -0,0 +1,7 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <TargetFramework>net5.0</TargetFramework>
+ </PropertyGroup>
+
+ <Import Project="..\..\build\common.targets" />
+</Project>
diff --git a/src/SMAPI.Tests/Core/AssetNameTests.cs b/src/SMAPI.Tests/Core/AssetNameTests.cs
new file mode 100644
index 00000000..8785aab8
--- /dev/null
+++ b/src/SMAPI.Tests/Core/AssetNameTests.cs
@@ -0,0 +1,295 @@
+using System;
+using System.Collections.Generic;
+using FluentAssertions;
+using NUnit.Framework;
+using StardewModdingAPI;
+using StardewModdingAPI.Framework.Content;
+using StardewModdingAPI.Toolkit.Utilities;
+using StardewValley;
+
+namespace SMAPI.Tests.Core
+{
+ /// <summary>Unit tests for <see cref="AssetName"/>.</summary>
+ [TestFixture]
+ internal class AssetNameTests
+ {
+ /*********
+ ** Unit tests
+ *********/
+ /****
+ ** Constructor
+ ****/
+ [Test(Description = $"Assert that the {nameof(AssetName)} constructor creates an instance with the expected values.")]
+ [TestCase("SimpleName", "SimpleName", null, null)]
+ [TestCase("Data/Achievements", "Data/Achievements", null, null)]
+ [TestCase("Characters/Dialogue/Abigail", "Characters/Dialogue/Abigail", null, null)]
+ [TestCase("Characters/Dialogue/Abigail.fr-FR", "Characters/Dialogue/Abigail", "fr-FR", LocalizedContentManager.LanguageCode.fr)]
+ [TestCase("Characters/Dialogue\\Abigail.fr-FR", "Characters/Dialogue/Abigail.fr-FR", null, null)]
+ [TestCase("Characters/Dialogue/Abigail.fr-FR", "Characters/Dialogue/Abigail", "fr-FR", LocalizedContentManager.LanguageCode.fr)]
+ public void Constructor_Valid(string name, string expectedBaseName, string expectedLocale, LocalizedContentManager.LanguageCode? expectedLanguageCode)
+ {
+ // arrange
+ name = PathUtilities.NormalizeAssetName(name);
+
+ // act
+ string calledWithLocale = null;
+ IAssetName assetName = AssetName.Parse(name, parseLocale: locale => expectedLanguageCode);
+
+ // assert
+ assetName.Name.Should()
+ .NotBeNull()
+ .And.Be(name.Replace("\\", "/"));
+ assetName.BaseName.Should()
+ .NotBeNull()
+ .And.Be(expectedBaseName);
+ assetName.LocaleCode.Should()
+ .Be(expectedLocale);
+ assetName.LanguageCode.Should()
+ .Be(expectedLanguageCode);
+ }
+
+ [Test(Description = $"Assert that the {nameof(AssetName)} constructor throws an exception if the value is invalid.")]
+ [TestCase(null)]
+ [TestCase("")]
+ [TestCase(" ")]
+ [TestCase("\t")]
+ [TestCase(" \t ")]
+ public void Constructor_NullOrWhitespace(string name)
+ {
+ // act
+ ArgumentException exception = Assert.Throws<ArgumentException>(() => _ = AssetName.Parse(name, null));
+
+ // assert
+ exception!.ParamName.Should().Be("rawName");
+ exception.Message.Should().Be("The asset name can't be null or empty. (Parameter 'rawName')");
+ }
+
+
+ /****
+ ** IsEquivalentTo
+ ****/
+ [Test(Description = $"Assert that {nameof(AssetName.IsEquivalentTo)} compares names as expected when the locale is included.")]
+
+ // exact match (ignore case)
+ [TestCase("Data/Achievements", "Data/Achievements", ExpectedResult = true)]
+ [TestCase("DATA/achievements", "data/ACHIEVEMENTS", ExpectedResult = true)]
+
+ // exact match (ignore formatting)
+ [TestCase("Data/Achievements", "Data\\Achievements", ExpectedResult = true)]
+ [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)]
+
+ // other is null or whitespace
+ [TestCase("Data/Achievements", null, ExpectedResult = false)]
+ [TestCase("Data/Achievements", "", ExpectedResult = false)]
+ [TestCase("Data/Achievements", " ", ExpectedResult = false)]
+
+ // with locale codes
+ [TestCase("Data/Achievements", "Data/Achievements.fr-FR", ExpectedResult = false)]
+ [TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = false)]
+ [TestCase("Data/Achievements.fr-FR", "Data/Achievements.fr-FR", ExpectedResult = true)]
+ public bool IsEquivalentTo_Name(string mainAssetName, string otherAssetName)
+ {
+ // arrange
+ mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName);
+
+ // act
+ AssetName name = AssetName.Parse(mainAssetName, _ => LocalizedContentManager.LanguageCode.fr);
+
+ // assert
+ return name.IsEquivalentTo(otherAssetName);
+ }
+
+ [Test(Description = $"Assert that {nameof(AssetName.IsEquivalentTo)} compares names as expected when the locale is excluded.")]
+
+ // a few samples from previous test to make sure
+ [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", " ", ExpectedResult = false)]
+
+ // with locale codes
+ [TestCase("Data/Achievements", "Data/Achievements.fr-FR", ExpectedResult = false)]
+ [TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = true)]
+ [TestCase("Data/Achievements.fr-FR", "Data/Achievements.fr-FR", ExpectedResult = false)]
+ public bool IsEquivalentTo_BaseName(string mainAssetName, string otherAssetName)
+ {
+ // arrange
+ mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName);
+
+ // act
+ AssetName name = AssetName.Parse(mainAssetName, _ => LocalizedContentManager.LanguageCode.fr);
+
+ // assert
+ return name.IsEquivalentTo(otherAssetName, useBaseName: true);
+ }
+
+
+ /****
+ ** StartsWith
+ ****/
+ [Test(Description = $"Assert that {nameof(AssetName.StartsWith)} compares names as expected for inputs that aren't affected by the input options.")]
+
+ // exact match (ignore case and formatting)
+ [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 = 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)]
+
+ // 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)]
+ public bool StartsWith_SimpleCases(string mainAssetName, string prefix)
+ {
+ // arrange
+ mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName);
+
+ // act
+ AssetName name = AssetName.Parse(mainAssetName, _ => null);
+
+ // assert value is the same for any combination of options
+ bool result = name.StartsWith(prefix, true, true);
+ foreach (bool allowPartialWord in new[] { true, false })
+ {
+ foreach (bool allowSubfolder in new[] { true, true })
+ {
+ if (allowPartialWord && allowSubfolder)
+ continue;
+
+ name.StartsWith(prefix, allowPartialWord, allowSubfolder)
+ .Should().Be(result, $"the value returned for options ({nameof(allowPartialWord)}: {allowPartialWord}, {nameof(allowSubfolder)}: {allowSubfolder}) should match the base case");
+ }
+ }
+
+ // assert value
+ return result;
+ }
+
+ [Test(Description = $"Assert that {nameof(AssetName.StartsWith)} compares names as expected for the 'allowPartialWord' option.")]
+ [TestCase("Data/AchievementsToIgnore", "Data/Achievements", true, ExpectedResult = true)]
+ [TestCase("Data/AchievementsToIgnore", "Data/Achievements", false, ExpectedResult = false)]
+ [TestCase("Data/Achievements X", "Data/Achievements", true, ExpectedResult = true)]
+ [TestCase("Data/Achievements X", "Data/Achievements", false, ExpectedResult = true)]
+ [TestCase("Data/Achievements.X", "Data/Achievements", true, ExpectedResult = true)]
+ [TestCase("Data/Achievements.X", "Data/Achievements", false, ExpectedResult = true)]
+
+ // with locale codes
+ [TestCase("Data/Achievements.fr-FR", "Data/Achievements", true, ExpectedResult = true)]
+ [TestCase("Data/Achievements.fr-FR", "Data/Achievements", false, ExpectedResult = true)]
+ public bool StartsWith_PartialWord(string mainAssetName, string prefix, bool allowPartialWord)
+ {
+ // arrange
+ mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName);
+
+ // act
+ AssetName name = AssetName.Parse(mainAssetName, _ => null);
+
+ // assert value is the same for any combination of options
+ bool result = name.StartsWith(prefix, allowPartialWord: allowPartialWord, allowSubfolder: true);
+ name.StartsWith(prefix, allowPartialWord, allowSubfolder: false)
+ .Should().Be(result, "specifying allowSubfolder should have no effect for these inputs");
+
+ // assert value
+ return result;
+ }
+
+ [Test(Description = $"Assert that {nameof(AssetName.StartsWith)} compares names as expected for the 'allowSubfolder' option.")]
+
+ // simple cases
+ [TestCase("Data/Achievements/Path", "Data/Achievements", true, ExpectedResult = true)]
+ [TestCase("Data/Achievements/Path", "Data/Achievements", false, ExpectedResult = false)]
+ [TestCase("Data/Achievements/Path", "Data\\Achievements", true, ExpectedResult = true)]
+ [TestCase("Data/Achievements/Path", "Data\\Achievements", false, ExpectedResult = false)]
+
+ // trailing slash
+ [TestCase("Data/Achievements/Path", "Data/", true, ExpectedResult = true)]
+ [TestCase("Data/Achievements/Path", "Data/", false, ExpectedResult = false)]
+
+ // normalize slash style
+ [TestCase("Data/Achievements/Path", "Data\\", true, ExpectedResult = true)]
+ [TestCase("Data/Achievements/Path", "Data\\", false, ExpectedResult = false)]
+ [TestCase("Data/Achievements/Path", "Data/\\/", true, ExpectedResult = true)]
+ [TestCase("Data/Achievements/Path", "Data/\\/", false, ExpectedResult = false)]
+
+ // with locale code
+ [TestCase("Data/Achievements/Path.fr-FR", "Data/Achievements", true, ExpectedResult = true)]
+ [TestCase("Data/Achievements/Path.fr-FR", "Data/Achievements", false, ExpectedResult = false)]
+ public bool StartsWith_Subfolder(string mainAssetName, string otherAssetName, bool allowSubfolder)
+ {
+ // arrange
+ mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName);
+
+ // act
+ AssetName name = AssetName.Parse(mainAssetName, _ => null);
+
+ // assert value is the same for any combination of options
+ bool result = name.StartsWith(otherAssetName, allowPartialWord: true, allowSubfolder: allowSubfolder);
+ name.StartsWith(otherAssetName, allowPartialWord: false, allowSubfolder: allowSubfolder)
+ .Should().Be(result, "specifying allowPartialWord should have no effect for these inputs");
+
+ // assert value
+ return result;
+ }
+
+
+ /****
+ ** GetHashCode
+ ****/
+ [Test(Description = $"Assert that {nameof(AssetName.GetHashCode)} generates the same hash code for two asset names which differ only by capitalization.")]
+ public void GetHashCode_IsCaseInsensitive()
+ {
+ // arrange
+ string left = "data/ACHIEVEMENTS";
+ string right = "DATA/achievements";
+
+ // act
+ int leftHash = AssetName.Parse(left, _ => null).GetHashCode();
+ int rightHash = AssetName.Parse(right, _ => null).GetHashCode();
+
+ // assert
+ leftHash.Should().Be(rightHash, "two asset names which differ only by capitalization should produce the same hash code");
+ }
+
+ [Test(Description = $"Assert that {nameof(AssetName.GetHashCode)} generates few hash code collisions for an arbitrary set of asset names.")]
+ public void GetHashCode_HasFewCollisions()
+ {
+ // generate list of names
+ List<string> names = new();
+ {
+ Random random = new();
+ string characters = "abcdefghijklmnopqrstuvwxyz1234567890/";
+
+ while (names.Count < 1000)
+ {
+ char[] name = new char[random.Next(5, 20)];
+ for (int i = 0; i < name.Length; i++)
+ name[i] = characters[random.Next(0, characters.Length)];
+
+ names.Add(new string(name));
+ }
+ }
+
+ // get distinct hash codes
+ HashSet<int> hashCodes = new();
+ foreach (string name in names)
+ hashCodes.Add(AssetName.Parse(name, _ => null).GetHashCode());
+
+ // assert a collision frequency under 0.1%
+ float collisionFrequency = 1 - (hashCodes.Count / (names.Count * 1f));
+ collisionFrequency.Should().BeLessOrEqualTo(0.001f, "hash codes should be relatively distinct with a collision rate under 0.1% for a small sample set");
+ }
+ }
+}
diff --git a/src/SMAPI.Tests/Core/InterfaceProxyTests.cs b/src/SMAPI.Tests/Core/InterfaceProxyTests.cs
new file mode 100644
index 00000000..99c1298f
--- /dev/null
+++ b/src/SMAPI.Tests/Core/InterfaceProxyTests.cs
@@ -0,0 +1,345 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+using FluentAssertions;
+using NUnit.Framework;
+using SMAPI.Tests.ModApiConsumer;
+using SMAPI.Tests.ModApiConsumer.Interfaces;
+using SMAPI.Tests.ModApiProvider;
+using StardewModdingAPI.Framework.Reflection;
+
+namespace SMAPI.Tests.Core
+{
+ /// <summary>Unit tests for <see cref="InterfaceProxyFactory"/>.</summary>
+ [TestFixture]
+ internal class InterfaceProxyTests
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The mod ID providing an API.</summary>
+ private readonly string FromModId = "From.ModId";
+
+ /// <summary>The mod ID consuming an API.</summary>
+ private readonly string ToModId = "From.ModId";
+
+ /// <summary>The random number generator with which to create sample values.</summary>
+ private readonly Random Random = new();
+
+
+ /*********
+ ** Unit tests
+ *********/
+ /****
+ ** Events
+ ****/
+ /// <summary>Assert that an event field can be proxied correctly.</summary>
+ [Test]
+ public void CanProxy_EventField()
+ {
+ // arrange
+ var providerMod = new ProviderMod();
+ object implementation = providerMod.GetModApi();
+ int expectedValue = this.Random.Next();
+
+ // act
+ ISimpleApi proxy = this.GetProxy(implementation);
+ new ApiConsumer().UseEventField(proxy, out Func<(int timesCalled, int lastValue)> getValues);
+ providerMod.RaiseEvent(expectedValue);
+ (int timesCalled, int lastValue) = getValues();
+
+ // assert
+ timesCalled.Should().Be(1, "Expected the proxied event to be raised once.");
+ lastValue.Should().Be(expectedValue, "The proxy received a different event argument than the implementation raised.");
+ }
+
+ /// <summary>Assert that an event property can be proxied correctly.</summary>
+ [Test]
+ public void CanProxy_EventProperty()
+ {
+ // arrange
+ var providerMod = new ProviderMod();
+ object implementation = providerMod.GetModApi();
+ int expectedValue = this.Random.Next();
+
+ // act
+ ISimpleApi proxy = this.GetProxy(implementation);
+ new ApiConsumer().UseEventProperty(proxy, out Func<(int timesCalled, int lastValue)> getValues);
+ providerMod.RaiseEvent(expectedValue);
+ (int timesCalled, int lastValue) = getValues();
+
+ // assert
+ timesCalled.Should().Be(1, "Expected the proxied event to be raised once.");
+ lastValue.Should().Be(expectedValue, "The proxy received a different event argument than the implementation raised.");
+ }
+
+ /****
+ ** Properties
+ ****/
+ /// <summary>Assert that properties can be proxied correctly.</summary>
+ /// <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)
+ {
+ // arrange
+ var providerMod = new ProviderMod();
+ object implementation = providerMod.GetModApi();
+ int expectedNumber = this.Random.Next();
+ int expectedObject = this.Random.Next();
+ string expectedListValue = this.GetRandomString();
+ string expectedListWithInterfaceValue = this.GetRandomString();
+ string expectedDictionaryKey = this.GetRandomString();
+ string expectedDictionaryListValue = this.GetRandomString();
+ string expectedInheritedString = this.GetRandomString();
+ BindingFlags expectedEnum = BindingFlags.Instance | BindingFlags.Public;
+
+ // act
+ ISimpleApi proxy = this.GetProxy(implementation);
+ switch (setVia)
+ {
+ case "set via provider mod":
+ providerMod.SetPropertyValues(
+ number: expectedNumber,
+ obj: expectedObject,
+ listValue: expectedListValue,
+ listWithInterfaceValue: expectedListWithInterfaceValue,
+ dictionaryKey: expectedDictionaryKey,
+ dictionaryListValue: expectedDictionaryListValue,
+ enumValue: expectedEnum,
+ inheritedValue: expectedInheritedString
+ );
+ break;
+
+ case "set via proxy interface":
+ proxy.NumberProperty = expectedNumber;
+ proxy.ObjectProperty = expectedObject;
+ proxy.ListProperty = new() { expectedListValue };
+ proxy.ListPropertyWithInterface = new List<string> { expectedListWithInterfaceValue };
+ proxy.GenericsProperty = new Dictionary<string, IList<string>>
+ {
+ [expectedDictionaryKey] = new List<string> { expectedDictionaryListValue }
+ };
+ proxy.EnumProperty = expectedEnum;
+ proxy.InheritedProperty = expectedInheritedString;
+ break;
+
+ default:
+ throw new InvalidOperationException($"Invalid 'set via' option '{setVia}.");
+ }
+
+ // assert number
+ this
+ .GetPropertyValue(implementation, nameof(proxy.NumberProperty))
+ .Should().Be(expectedNumber);
+ proxy.NumberProperty
+ .Should().Be(expectedNumber);
+
+ // assert object
+ this
+ .GetPropertyValue(implementation, nameof(proxy.ObjectProperty))
+ .Should().Be(expectedObject);
+ proxy.ObjectProperty
+ .Should().Be(expectedObject);
+
+ // assert list
+ (this.GetPropertyValue(implementation, nameof(proxy.ListProperty)) as IList<string>)
+ .Should().NotBeNull()
+ .And.HaveCount(1)
+ .And.BeEquivalentTo(expectedListValue);
+ proxy.ListProperty
+ .Should().NotBeNull()
+ .And.HaveCount(1)
+ .And.BeEquivalentTo(expectedListValue);
+
+ // assert list with interface
+ (this.GetPropertyValue(implementation, nameof(proxy.ListPropertyWithInterface)) as IList<string>)
+ .Should().NotBeNull()
+ .And.HaveCount(1)
+ .And.BeEquivalentTo(expectedListWithInterfaceValue);
+ proxy.ListPropertyWithInterface
+ .Should().NotBeNull()
+ .And.HaveCount(1)
+ .And.BeEquivalentTo(expectedListWithInterfaceValue);
+
+ // assert generics
+ (this.GetPropertyValue(implementation, nameof(proxy.GenericsProperty)) as IDictionary<string, IList<string>>)
+ .Should().NotBeNull()
+ .And.HaveCount(1)
+ .And.ContainKey(expectedDictionaryKey).WhoseValue.Should().BeEquivalentTo(expectedDictionaryListValue);
+ proxy.GenericsProperty
+ .Should().NotBeNull()
+ .And.HaveCount(1)
+ .And.ContainKey(expectedDictionaryKey).WhoseValue.Should().BeEquivalentTo(expectedDictionaryListValue);
+
+ // assert enum
+ this
+ .GetPropertyValue(implementation, nameof(proxy.EnumProperty))
+ .Should().Be(expectedEnum);
+ proxy.EnumProperty
+ .Should().Be(expectedEnum);
+
+ // assert getter
+ this
+ .GetPropertyValue(implementation, nameof(proxy.GetterProperty))
+ .Should().Be(42);
+ proxy.GetterProperty
+ .Should().Be(42);
+
+ // assert inherited methods
+ this
+ .GetPropertyValue(implementation, nameof(proxy.InheritedProperty))
+ .Should().Be(expectedInheritedString);
+ proxy.InheritedProperty
+ .Should().Be(expectedInheritedString);
+ }
+
+ /// <summary>Assert that a simple method with no return value can be proxied correctly.</summary>
+ [Test]
+ public void CanProxy_SimpleMethod_Void()
+ {
+ // arrange
+ object implementation = new ProviderMod().GetModApi();
+
+ // act
+ ISimpleApi proxy = this.GetProxy(implementation);
+ proxy.GetNothing();
+ }
+
+ /// <summary>Assert that a simple int method can be proxied correctly.</summary>
+ [Test]
+ public void CanProxy_SimpleMethod_Int()
+ {
+ // arrange
+ object implementation = new ProviderMod().GetModApi();
+ int expectedValue = this.Random.Next();
+
+ // act
+ ISimpleApi proxy = this.GetProxy(implementation);
+ int actualValue = proxy.GetInt(expectedValue);
+
+ // assert
+ actualValue.Should().Be(expectedValue);
+ }
+
+ /// <summary>Assert that a simple object method can be proxied correctly.</summary>
+ [Test]
+ public void CanProxy_SimpleMethod_Object()
+ {
+ // arrange
+ object implementation = new ProviderMod().GetModApi();
+ object expectedValue = new();
+
+ // act
+ ISimpleApi proxy = this.GetProxy(implementation);
+ object actualValue = proxy.GetObject(expectedValue);
+
+ // assert
+ actualValue.Should().BeSameAs(expectedValue);
+ }
+
+ /// <summary>Assert that a simple list method can be proxied correctly.</summary>
+ [Test]
+ public void CanProxy_SimpleMethod_List()
+ {
+ // arrange
+ object implementation = new ProviderMod().GetModApi();
+ string expectedValue = this.GetRandomString();
+
+ // act
+ ISimpleApi proxy = this.GetProxy(implementation);
+ IList<string> actualValue = proxy.GetList(expectedValue);
+
+ // assert
+ actualValue.Should().BeEquivalentTo(expectedValue);
+ }
+
+ /// <summary>Assert that a simple list with interface method can be proxied correctly.</summary>
+ [Test]
+ public void CanProxy_SimpleMethod_ListWithInterface()
+ {
+ // arrange
+ object implementation = new ProviderMod().GetModApi();
+ string expectedValue = this.GetRandomString();
+
+ // act
+ ISimpleApi proxy = this.GetProxy(implementation);
+ IList<string> actualValue = proxy.GetListWithInterface(expectedValue);
+
+ // assert
+ actualValue.Should().BeEquivalentTo(expectedValue);
+ }
+
+ /// <summary>Assert that a simple method which returns generic types can be proxied correctly.</summary>
+ [Test]
+ public void CanProxy_SimpleMethod_GenericTypes()
+ {
+ // arrange
+ object implementation = new ProviderMod().GetModApi();
+ string expectedKey = this.GetRandomString();
+ string expectedValue = this.GetRandomString();
+
+ // act
+ ISimpleApi proxy = this.GetProxy(implementation);
+ IDictionary<string, IList<string>> actualValue = proxy.GetGenerics(expectedKey, expectedValue);
+
+ // assert
+ actualValue
+ .Should().NotBeNull()
+ .And.HaveCount(1)
+ .And.ContainKey(expectedKey).WhoseValue.Should().BeEquivalentTo(expectedValue);
+ }
+
+ /// <summary>Assert that a simple lambda method can be proxied correctly.</summary>
+ [Test]
+ [SuppressMessage("ReSharper", "ConvertToLocalFunction")]
+ public void CanProxy_SimpleMethod_Lambda()
+ {
+ // arrange
+ object implementation = new ProviderMod().GetModApi();
+ Func<string, string> expectedValue = _ => "test";
+
+ // act
+ ISimpleApi proxy = this.GetProxy(implementation);
+ object actualValue = proxy.GetObject(expectedValue);
+
+ // assert
+ actualValue.Should().BeSameAs(expectedValue);
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get a property value from an instance.</summary>
+ /// <param name="parent">The instance whose property to read.</param>
+ /// <param name="name">The property name.</param>
+ private object GetPropertyValue(object parent, string name)
+ {
+ if (parent is null)
+ throw new ArgumentNullException(nameof(parent));
+
+ Type type = parent.GetType();
+ PropertyInfo property = type.GetProperty(name);
+ if (property is null)
+ throw new InvalidOperationException($"The '{type.FullName}' type has no public property named '{name}'.");
+
+ return property.GetValue(parent);
+ }
+
+ /// <summary>Get a random test string.</summary>
+ private string GetRandomString()
+ {
+ return this.Random.Next().ToString();
+ }
+
+ /// <summary>Get a proxy API instance.</summary>
+ /// <param name="implementation">The underlying API instance.</param>
+ private ISimpleApi GetProxy(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 da3446bb..1755f644 100644
--- a/src/SMAPI.Tests/Core/ModResolverTests.cs
+++ b/src/SMAPI.Tests/Core/ModResolverTests.cs
@@ -123,7 +123,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(new ModMetadata[0], apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null);
+ new ModResolver().ValidateManifests(Array.Empty<ModMetadata>(), apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null);
}
[Test(Description = "Assert that validation skips manifests that have already failed without calling any other properties.")]
@@ -144,7 +144,7 @@ namespace SMAPI.Tests.Core
public void ValidateManifests_ModStatus_AssumeBroken_Fails()
{
// arrange
- Mock<IModMetadata> mock = this.GetMetadata("Mod A", new string[0], allowStatusChange: true);
+ Mock<IModMetadata> mock = this.GetMetadata("Mod A", Array.Empty<string>(), allowStatusChange: true);
this.SetupMetadataForValidation(mock, new ModDataRecordVersionedFields
{
Status = ModStatus.AssumeBroken
@@ -161,7 +161,7 @@ namespace SMAPI.Tests.Core
public void ValidateManifests_MinimumApiVersion_Fails()
{
// arrange
- Mock<IModMetadata> mock = this.GetMetadata("Mod A", new string[0], allowStatusChange: true);
+ 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);
@@ -190,9 +190,9 @@ namespace SMAPI.Tests.Core
public void ValidateManifests_DuplicateUniqueID_Fails()
{
// arrange
- Mock<IModMetadata> modA = this.GetMetadata("Mod A", new string[0], allowStatusChange: true);
+ 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", new string[0], allowStatusChange: false);
+ Mock<IModMetadata> modC = this.GetMetadata("Mod C", Array.Empty<string>(), allowStatusChange: false);
foreach (Mock<IModMetadata> mod in new[] { modA, modB, modC })
this.SetupMetadataForValidation(mod);
@@ -236,7 +236,7 @@ namespace SMAPI.Tests.Core
public void ProcessDependencies_NoMods_DoesNothing()
{
// act
- IModMetadata[] mods = new ModResolver().ProcessDependencies(new IModMetadata[0], new ModDatabase()).ToArray();
+ IModMetadata[] mods = new ModResolver().ProcessDependencies(Array.Empty<IModMetadata>(), new ModDatabase()).ToArray();
// assert
Assert.AreEqual(0, mods.Length, 0, "Expected to get an empty list of mods.");
@@ -490,8 +490,8 @@ namespace SMAPI.Tests.Core
EntryDll = entryDll ?? $"{Sample.String()}.dll",
ContentPackFor = contentPackForID != null ? new ManifestContentPackFor { UniqueID = contentPackForID } : null,
MinimumApiVersion = minimumApiVersion != null ? new SemanticVersion(minimumApiVersion) : null,
- Dependencies = dependencies ?? new IManifestDependency[0],
- UpdateKeys = new string[0]
+ Dependencies = dependencies ?? Array.Empty<IManifestDependency>(),
+ UpdateKeys = Array.Empty<string>()
};
}
diff --git a/src/SMAPI.Tests/SMAPI.Tests.csproj b/src/SMAPI.Tests/SMAPI.Tests.csproj
index 8329b2e1..67997b30 100644
--- a/src/SMAPI.Tests/SMAPI.Tests.csproj
+++ b/src/SMAPI.Tests/SMAPI.Tests.csproj
@@ -1,21 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
- <AssemblyName>SMAPI.Tests</AssemblyName>
- <RootNamespace>SMAPI.Tests</RootNamespace>
<TargetFramework>net5.0</TargetFramework>
- <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
- <LangVersion>latest</LangVersion>
</PropertyGroup>
<Import Project="..\..\build\common.targets" />
<ItemGroup>
+ <ProjectReference Include="..\SMAPI.Tests.ModApiConsumer\SMAPI.Tests.ModApiConsumer.csproj" />
+ <ProjectReference Include="..\SMAPI.Tests.ModApiProvider\SMAPI.Tests.ModApiProvider.csproj" />
<ProjectReference Include="..\SMAPI.Toolkit.CoreInterfaces\SMAPI.Toolkit.CoreInterfaces.csproj" />
<ProjectReference Include="..\SMAPI.Toolkit\SMAPI.Toolkit.csproj" />
<ProjectReference Include="..\SMAPI\SMAPI.csproj" />
</ItemGroup>
<ItemGroup>
+ <PackageReference Include="FluentAssertions" Version="6.5.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs
index 2f58a3f1..0115fbf3 100644
--- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs
+++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs
@@ -1,3 +1,5 @@
+using System;
+
namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
{
/// <summary>Metadata about a mod.</summary>
@@ -16,6 +18,6 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
public ModExtendedMetadataModel Metadata { get; set; }
/// <summary>The errors that occurred while fetching update data.</summary>
- public string[] Errors { get; set; } = new string[0];
+ public string[] Errors { get; set; } = Array.Empty<string>();
}
}
diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs
index 5c2ce366..0fa4a74d 100644
--- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs
+++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
@@ -17,7 +18,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
** Mod info
****/
/// <summary>The mod's unique ID. A mod may have multiple current IDs in rare cases (e.g. due to parallel releases or unofficial updates).</summary>
- public string[] ID { get; set; } = new string[0];
+ public string[] ID { get; set; } = Array.Empty<string>();
/// <summary>The mod's display name.</summary>
public string Name { get; set; }
diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs
index bf81e102..404d4618 100644
--- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs
+++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs
@@ -1,3 +1,5 @@
+using System;
+
namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
{
/// <summary>Specifies the identifiers for a mod to match.</summary>
@@ -37,7 +39,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
{
this.ID = id;
this.InstalledVersion = installedVersion;
- this.UpdateKeys = updateKeys ?? new string[0];
+ this.UpdateKeys = updateKeys ?? Array.Empty<string>();
}
}
}
diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs
index f1feb44b..2ed255c8 100644
--- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs
+++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs
@@ -179,7 +179,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
errors = rawErrors.ToArray();
}
else
- errors = new string[0];
+ errors = Array.Empty<string>();
// build model
return new ChangeDescriptor(
diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs
index f85e82e1..0f5a0ec3 100644
--- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs
+++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs
@@ -239,7 +239,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
string raw = this.GetAttribute(element, name);
return !string.IsNullOrWhiteSpace(raw)
? raw.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim()).ToArray()
- : new string[0];
+ : Array.Empty<string>();
}
/// <summary>Get an attribute value and parse it as an enum value.</summary>
diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs
index 0587e09d..03c0d214 100644
--- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs
+++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs
@@ -1,3 +1,5 @@
+using System;
+
#nullable enable
namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
@@ -9,7 +11,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
** Accessors
*********/
/// <summary>The unique mod IDs for the mods to override.</summary>
- public string[] Ids { get; set; } = new string[0];
+ public string[] Ids { get; set; } = Array.Empty<string>();
/// <summary>Maps local versions to a semantic version for update checks.</summary>
public ChangeDescriptor? ChangeLocalVersions { get; set; }
diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDatabase.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDatabase.cs
index a9da884a..5b7e2a02 100644
--- a/src/SMAPI.Toolkit/Framework/ModData/ModDatabase.cs
+++ b/src/SMAPI.Toolkit/Framework/ModData/ModDatabase.cs
@@ -22,7 +22,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData
*********/
/// <summary>Construct an empty instance.</summary>
public ModDatabase()
- : this(new ModDataRecord[0], key => null) { }
+ : this(Array.Empty<ModDataRecord>(), key => null) { }
/// <summary>Construct an instance.</summary>
/// <param name="records">The underlying mod data records indexed by default display name.</param>
diff --git a/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs b/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs
index 46b654a5..4ad97b6d 100644
--- a/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs
+++ b/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.Runtime.Serialization;
using Newtonsoft.Json;
@@ -68,7 +69,7 @@ namespace StardewModdingAPI.Toolkit.Serialization.Models
this.Description = description;
this.Version = version;
this.UniqueID = uniqueID;
- this.UpdateKeys = new string[0];
+ this.UpdateKeys = Array.Empty<string>();
this.ContentPackFor = new ManifestContentPackFor { UniqueID = contentPackFor };
}
@@ -77,8 +78,8 @@ namespace StardewModdingAPI.Toolkit.Serialization.Models
[OnDeserialized]
public void OnDeserialized(StreamingContext context)
{
- this.Dependencies ??= new IManifestDependency[0];
- this.UpdateKeys ??= new string[0];
+ this.Dependencies ??= Array.Empty<IManifestDependency>();
+ this.UpdateKeys ??= Array.Empty<string>();
}
}
}
diff --git a/src/SMAPI.Web/Controllers/IndexController.cs b/src/SMAPI.Web/Controllers/IndexController.cs
index f2f4c342..5097997c 100644
--- a/src/SMAPI.Web/Controllers/IndexController.cs
+++ b/src/SMAPI.Web/Controllers/IndexController.cs
@@ -96,7 +96,7 @@ namespace StardewModdingAPI.Web.Controllers
{
HtmlDocument doc = new HtmlDocument();
doc.LoadHtml(release.Body);
- foreach (HtmlNode node in doc.DocumentNode.SelectNodes("//*[@class='noinclude']")?.ToArray() ?? new HtmlNode[0])
+ foreach (HtmlNode node in doc.DocumentNode.SelectNodes("//*[@class='noinclude']")?.ToArray() ?? Array.Empty<HtmlNode>())
node.Remove();
release.Body = doc.DocumentNode.InnerHtml.Trim();
}
diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs
index 37d763cc..dfe2504b 100644
--- a/src/SMAPI.Web/Controllers/ModsApiController.cs
+++ b/src/SMAPI.Web/Controllers/ModsApiController.cs
@@ -79,7 +79,7 @@ namespace StardewModdingAPI.Web.Controllers
public async Task<IEnumerable<ModEntryModel>> PostAsync([FromBody] ModSearchModel model, [FromRoute] string version)
{
if (model?.Mods == null)
- return new ModEntryModel[0];
+ return Array.Empty<ModEntryModel>();
ModUpdateCheckConfig config = this.Config.Value;
diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs
index 064a7c3c..d037a123 100644
--- a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs
+++ b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs
@@ -15,7 +15,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki
private Cached<WikiMetadata> Metadata;
/// <summary>The cached wiki data.</summary>
- private Cached<WikiModEntry>[] Mods = new Cached<WikiModEntry>[0];
+ private Cached<WikiModEntry>[] Mods = Array.Empty<Cached<WikiModEntry>>();
/*********
diff --git a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs
index 622e6c56..a5f7c9b9 100644
--- a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs
+++ b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.Linq;
using StardewModdingAPI.Toolkit.Framework.UpdateData;
@@ -26,7 +27,7 @@ namespace StardewModdingAPI.Web.Framework.Clients
public string Url { get; set; }
/// <summary>The mod downloads.</summary>
- public IModDownload[] Downloads { get; set; } = new IModDownload[0];
+ public IModDownload[] Downloads { get; set; } = Array.Empty<IModDownload>();
/// <summary>The mod availability status on the remote site.</summary>
public RemoteModStatus Status { get; set; } = RemoteModStatus.Ok;
diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/LogModInfo.cs b/src/SMAPI.Web/Framework/LogParsing/Models/LogModInfo.cs
index 067e4df4..92bfe5c7 100644
--- a/src/SMAPI.Web/Framework/LogParsing/Models/LogModInfo.cs
+++ b/src/SMAPI.Web/Framework/LogParsing/Models/LogModInfo.cs
@@ -35,5 +35,8 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models
/// <summary>Whether the mod has an update available.</summary>
public bool HasUpdate => this.UpdateVersion != null && this.Version != this.UpdateVersion;
+
+ /// <summary>Whether the mod is a content pack for another mod.</summary>
+ public bool IsContentPack => !string.IsNullOrWhiteSpace(this.ContentPackFor);
}
}
diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs
index 87b20eb0..693a16ec 100644
--- a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs
+++ b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs
@@ -42,7 +42,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models
public DateTimeOffset Timestamp { get; set; }
/// <summary>Metadata about installed mods and content packs.</summary>
- public LogModInfo[] Mods { get; set; } = new LogModInfo[0];
+ public LogModInfo[] Mods { get; set; } = Array.Empty<LogModInfo>();
/// <summary>The log messages.</summary>
public LogMessage[] Messages { get; set; }
diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs
index 0ea69911..e659b389 100644
--- a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs
+++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs
@@ -26,7 +26,7 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator
public string Content { get; set; }
/// <summary>The schema validation errors, if any.</summary>
- public JsonValidatorErrorModel[] Errors { get; set; } = new JsonValidatorErrorModel[0];
+ public JsonValidatorErrorModel[] Errors { get; set; } = Array.Empty<JsonValidatorErrorModel>();
/// <summary>A non-blocking warning while uploading the file.</summary>
public string UploadWarning { get; set; }
diff --git a/src/SMAPI.Web/ViewModels/LogParserModel.cs b/src/SMAPI.Web/ViewModels/LogParserModel.cs
index bea79eae..0b6d7722 100644
--- a/src/SMAPI.Web/ViewModels/LogParserModel.cs
+++ b/src/SMAPI.Web/ViewModels/LogParserModel.cs
@@ -83,7 +83,7 @@ namespace StardewModdingAPI.Web.ViewModels
// group by mod
return mods
- .Where(mod => mod.ContentPackFor != null)
+ .Where(mod => mod.IsContentPack)
.GroupBy(mod => mod.ContentPackFor)
.ToDictionary(group => group.Key, group => group.ToArray());
}
diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml
index 91fc3535..b54867b1 100644
--- a/src/SMAPI.Web/Views/LogParser/Index.cshtml
+++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml
@@ -7,6 +7,9 @@
@{
ViewData["Title"] = "SMAPI log parser";
+
+ ParsedLog log = Model!.ParsedLog;
+
IDictionary<string, LogModInfo[]> contentPacks = Model.GetContentPacksByMod();
IDictionary<string, bool> defaultFilters = Enum
.GetValues(typeof(LogLevel))
@@ -15,7 +18,7 @@
string curPageUrl = this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID }, absoluteUrl: true);
- ISet<int> screenIds = new HashSet<int>(Model.ParsedLog?.Messages?.Select(p => p.ScreenId) ?? new int[0]);
+ ISet<int> screenIds = new HashSet<int>(log?.Messages?.Select(p => p.ScreenId) ?? Array.Empty<int>());
}
@section Head {
@@ -35,9 +38,9 @@
<script>
$(function() {
smapi.logParser({
- logStarted: new Date(@this.ForJson(Model.ParsedLog?.Timestamp)),
- showPopup: @this.ForJson(Model.ParsedLog == null),
- showMods: @this.ForJson(Model.ParsedLog?.Mods?.Select(p => Model.GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, slug => true)),
+ logStarted: new Date(@this.ForJson(log?.Timestamp)),
+ showPopup: @this.ForJson(log == null),
+ showMods: @this.ForJson(log?.Mods?.Select(p => Model.GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, slug => true)),
showSections: @this.ForJson(Enum.GetNames(typeof(LogSection)).ToDictionary(section => section, section => false)),
showLevels: @this.ForJson(defaultFilters),
enableFilters: @this.ForJson(!Model.ShowRaw),
@@ -67,7 +70,7 @@ else if (Model.ParseError != null)
<small v-pre>Error details: @Model.ParseError</small>
</div>
}
-else if (Model.ParsedLog?.IsValid == true)
+else if (log?.IsValid == true)
{
<div class="banner success" v-pre>
<strong>Share this link to let someone else see the log:</strong> <code>@curPageUrl</code><br />
@@ -92,7 +95,7 @@ else if (Model.ParsedLog?.IsValid == true)
}
@* upload new log *@
-@if (Model.ParsedLog == null)
+@if (log == null)
{
<h2>Where do I find my SMAPI log?</h2>
<div id="os-instructions">
@@ -157,7 +160,7 @@ else if (Model.ParsedLog?.IsValid == true)
</div>
<h2>How do I share my log?</h2>
- <form action="@this.Url.PlainAction("PostAsync", "LogParser")" method="post">
+ <form action="@this.Url.PlainAction("Post", "LogParser")" method="post">
<input id="inputFile" type="file" />
<ol>
<li>
@@ -174,10 +177,10 @@ else if (Model.ParsedLog?.IsValid == true)
}
@* parsed log *@
-@if (Model.ParsedLog?.IsValid == true)
+@if (log?.IsValid == true)
{
<div id="output">
- @if (Model.ParsedLog.Mods.Any(mod => mod.HasUpdate))
+ @if (log.Mods.Any(mod => mod.HasUpdate))
{
<h2>Suggested fixes</h2>
<ul id="fix-list">
@@ -185,7 +188,7 @@ else if (Model.ParsedLog?.IsValid == true)
Consider updating these mods to fix problems:
<table id="updates" class="table">
- @foreach (LogModInfo mod in Model.ParsedLog.Mods.Where(mod => (mod.HasUpdate && mod.ContentPackFor == null) || (contentPacks != null && contentPacks.TryGetValue(mod.Name, out LogModInfo[] contentPackList) && contentPackList.Any(pack => pack.HasUpdate))))
+ @foreach (LogModInfo mod in log.Mods.Where(mod => (mod.HasUpdate && !mod.IsContentPack) || (contentPacks.TryGetValue(mod.Name, out LogModInfo[] contentPackList) && contentPackList.Any(pack => pack.HasUpdate))))
{
<tr class="mod-entry">
<td>
@@ -230,23 +233,33 @@ else if (Model.ParsedLog?.IsValid == true)
}
<h2>Log info</h2>
- <table id="metadata" class="table">
+ <table
+ id="metadata"
+ class="table"
+ data-code-mods="@log.Mods.Count(p => !p.IsContentPack)"
+ data-content-packs="@log.Mods.Count(p => p.IsContentPack)"
+ data-os="@log.OperatingSystem"
+ data-game-version="@log.GameVersion"
+ data-game-path="@log.GamePath"
+ data-smapi-version="@log.ApiVersion"
+ data-log-started="@log.Timestamp.UtcDateTime.ToString("O")"
+ >
<caption>Game info:</caption>
<tr>
<th>Stardew Valley:</th>
- <td v-pre>@Model.ParsedLog.GameVersion on @Model.ParsedLog.OperatingSystem</td>
+ <td v-pre>@log.GameVersion on @log.OperatingSystem</td>
</tr>
<tr>
<th>SMAPI:</th>
- <td v-pre>@Model.ParsedLog.ApiVersion</td>
+ <td v-pre>@log.ApiVersion</td>
</tr>
<tr>
<th>Folder:</th>
- <td v-pre>@Model.ParsedLog.GamePath</td>
+ <td v-pre>@log.GamePath</td>
</tr>
<tr>
<th>Log started:</th>
- <td>@Model.ParsedLog.Timestamp.UtcDateTime.ToString("yyyy-MM-dd HH:mm") UTC ({{localTimeStarted}} your time)</td>
+ <td>@log.Timestamp.UtcDateTime.ToString("yyyy-MM-dd HH:mm") UTC ({{localTimeStarted}} your time)</td>
</tr>
</table>
<br />
@@ -260,7 +273,7 @@ else if (Model.ParsedLog?.IsValid == true)
<span class="notice btn txt" v-on:click="hideAllMods" v-bind:class="{ invisible: !anyModsShown || !anyModsHidden }">hide all</span>
}
</caption>
- @foreach (var mod in Model.ParsedLog.Mods.Where(p => p.Loaded && p.ContentPackFor == null))
+ @foreach (var mod in log.Mods.Where(p => p.Loaded && !p.IsContentPack))
{
<tr v-on:click="toggleMod('@Model.GetSlug(mod.Name)')" class="mod-entry" v-bind:class="{ hidden: !showMods['@Model.GetSlug(mod.Name)'] }">
<td><input type="checkbox" v-bind:checked="showMods['@Model.GetSlug(mod.Name)']" v-bind:class="{ invisible: !anyModsHidden }" /></td>
@@ -317,7 +330,7 @@ else if (Model.ParsedLog?.IsValid == true)
</div>
<table id="log">
- @foreach (var message in Model.ParsedLog.Messages)
+ @foreach (var message in log.Messages)
{
string levelStr = message.Level.ToString().ToLower();
string sectionStartClass = message.IsStartOfSection ? "section-start" : null;
@@ -360,7 +373,7 @@ else if (Model.ParsedLog?.IsValid == true)
}
else
{
- <pre v-pre>@Model.ParsedLog.RawText</pre>
+ <pre v-pre>@log.RawText</pre>
}
<small>
@@ -377,8 +390,8 @@ else if (Model.ParsedLog?.IsValid == true)
</small>
</div>
}
-else if (Model.ParsedLog?.IsValid == false)
+else if (log?.IsValid == false)
{
<h3>Raw log</h3>
- <pre v-pre>@Model.ParsedLog.RawText</pre>
+ <pre v-pre>@log.RawText</pre>
}
diff --git a/src/SMAPI.Web/Views/Mods/Index.cshtml b/src/SMAPI.Web/Views/Mods/Index.cshtml
index 8a764803..416468e4 100644
--- a/src/SMAPI.Web/Views/Mods/Index.cshtml
+++ b/src/SMAPI.Web/Views/Mods/Index.cshtml
@@ -19,7 +19,7 @@
<script src="~/Content/js/mods.js?r=20210929"></script>
<script>
$(function() {
- var data = @this.ForJson(Model.Mods ?? new ModModel[0]);
+ var data = @(this.ForJson(Model.Mods ?? Array.Empty<ModModel>()));
var enableBeta = @this.ForJson(hasBeta);
smapi.modList(data, enableBeta);
});
diff --git a/src/SMAPI.sln b/src/SMAPI.sln
index be5326f7..d9f60a5c 100644
--- a/src/SMAPI.sln
+++ b/src/SMAPI.sln
@@ -101,6 +101,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "lib", "lib", "{3B5BF14D-F61
..\build\windows\lib\in-place-regex.ps1 = ..\build\windows\lib\in-place-regex.ps1
EndProjectSection
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Tests.ModApiProvider", "SMAPI.Tests.ModApiProvider\SMAPI.Tests.ModApiProvider.csproj", "{239AEEAC-07D1-4A3F-AA99-8C74F5038F50}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SMAPI.Tests.ModApiConsumer", "SMAPI.Tests.ModApiConsumer\SMAPI.Tests.ModApiConsumer.csproj", "{2A4DF030-E8B1-4BBD-AA93-D4DE68CB9D85}"
+EndProject
Global
GlobalSection(SharedMSBuildProjectFiles) = preSolution
SMAPI.Internal\SMAPI.Internal.projitems*{0634ea4c-3b8f-42db-aea6-ca9e4ef6e92f}*SharedItemsImports = 5
@@ -167,6 +171,14 @@ Global
{80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Debug|Any CPU.Build.0 = Debug|Any CPU
{80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Release|Any CPU.ActiveCfg = Release|Any CPU
{80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Release|Any CPU.Build.0 = Release|Any CPU
+ {239AEEAC-07D1-4A3F-AA99-8C74F5038F50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {239AEEAC-07D1-4A3F-AA99-8C74F5038F50}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {239AEEAC-07D1-4A3F-AA99-8C74F5038F50}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {239AEEAC-07D1-4A3F-AA99-8C74F5038F50}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2A4DF030-E8B1-4BBD-AA93-D4DE68CB9D85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2A4DF030-E8B1-4BBD-AA93-D4DE68CB9D85}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2A4DF030-E8B1-4BBD-AA93-D4DE68CB9D85}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2A4DF030-E8B1-4BBD-AA93-D4DE68CB9D85}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -187,6 +199,8 @@ Global
{4D661178-38FB-43E4-AA5F-9B0406919344} = {09CF91E5-5BAB-4650-A200-E5EA9A633046}
{CAA1488E-842B-433D-994D-1D3D0B5DD125} = {09CF91E5-5BAB-4650-A200-E5EA9A633046}
{3B5BF14D-F612-4C83-9EF6-E3EBFCD08766} = {4D661178-38FB-43E4-AA5F-9B0406919344}
+ {239AEEAC-07D1-4A3F-AA99-8C74F5038F50} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11}
+ {2A4DF030-E8B1-4BBD-AA93-D4DE68CB9D85} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {70143042-A862-47A8-A677-7C819DDC90DC}
diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs
index 667491d6..b736ca59 100644
--- a/src/SMAPI/Constants.cs
+++ b/src/SMAPI/Constants.cs
@@ -31,10 +31,10 @@ namespace StardewModdingAPI
** Accessors
*********/
/// <summary>The path to the game folder.</summary>
- public static string ExecutionPath { get; } = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
+ public static string GamePath { get; } = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
/// <summary>The absolute path to the folder containing SMAPI's internal files.</summary>
- public static readonly string InternalFilesPath = Path.Combine(EarlyConstants.ExecutionPath, "smapi-internal");
+ public static readonly string InternalFilesPath = Path.Combine(EarlyConstants.GamePath, "smapi-internal");
/// <summary>The target game platform.</summary>
internal static GamePlatform Platform { get; } = (GamePlatform)Enum.Parse(typeof(GamePlatform), LowLevelEnvironmentUtility.DetectPlatform());
@@ -77,7 +77,14 @@ namespace StardewModdingAPI
public static GameFramework GameFramework { get; } = EarlyConstants.GameFramework;
/// <summary>The path to the game folder.</summary>
- public static string ExecutionPath { get; } = EarlyConstants.ExecutionPath;
+ [Obsolete($"Use {nameof(GamePath)} instead.")]
+ public static string ExecutionPath => Constants.GamePath;
+
+ /// <summary>The path to the game folder.</summary>
+ public static string GamePath { get; } = EarlyConstants.GamePath;
+
+ /// <summary>The path to the game's <c>Content</c> folder.</summary>
+ public static string ContentPath { get; } = Constants.GetContentFolderPath();
/// <summary>The directory path containing Stardew Valley's app data.</summary>
public static string DataPath { get; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley");
@@ -139,7 +146,7 @@ namespace StardewModdingAPI
internal static string UpdateMarker => Path.Combine(Constants.InternalFilesPath, "StardewModdingAPI.update.marker");
/// <summary>The default full path to search for mods.</summary>
- internal static string DefaultModsPath { get; } = Path.Combine(Constants.ExecutionPath, "Mods");
+ internal static string DefaultModsPath { get; } = Path.Combine(Constants.GamePath, "Mods");
/// <summary>The actual full path to search for mods.</summary>
internal static string ModsPath { get; set; }
@@ -222,7 +229,7 @@ namespace StardewModdingAPI
internal static void ConfigureAssemblyResolver(AssemblyDefinitionResolver resolver)
{
// add search paths
- resolver.AddSearchDirectory(Constants.ExecutionPath);
+ resolver.AddSearchDirectory(Constants.GamePath);
resolver.AddSearchDirectory(Constants.InternalFilesPath);
// add SMAPI explicitly
@@ -293,13 +300,52 @@ namespace StardewModdingAPI
/*********
** Private methods
*********/
+ /// <summary>Get the absolute path to the game's <c>Content</c> folder.</summary>
+ private static string GetContentFolderPath()
+ {
+ //
+ // We can't use Path.Combine(Constants.GamePath, Game1.content.RootDirectory) here,
+ // since Game1.content isn't initialized until later in the game startup.
+ //
+
+ string gamePath = EarlyConstants.GamePath;
+
+ // most platforms
+ if (EarlyConstants.Platform != GamePlatform.Mac)
+ return Path.Combine(gamePath, "Content");
+
+ // macOS
+ string[] paths = new[]
+ {
+ // GOG
+ // - game: Stardew Valley.app/Contents/MacOS
+ // - content: Stardew Valley.app/Resources/Content
+ "../../Resources/Content",
+
+ // Steam
+ // - game: StardewValley/Contents/MacOS
+ // - content: StardewValley/Contents/Resources/Content
+ "../Resources/Content"
+ }
+ .Select(path => Path.GetFullPath(Path.Combine(gamePath, path)))
+ .ToArray();
+
+ foreach (string path in paths)
+ {
+ if (Directory.Exists(path))
+ return path;
+ }
+
+ return paths.Last();
+ }
+
/// <summary>Get the name of the save folder, if any.</summary>
private static string GetSaveFolderName()
{
return Constants.GetSaveFolder()?.Name;
}
- /// <summary>Get the path to the current save folder, if any.</summary>
+ /// <summary>Get the absolute path to the current save folder, if any.</summary>
private static string GetSaveFolderPathIfExists()
{
DirectoryInfo saveFolder = Constants.GetSaveFolder();
diff --git a/src/SMAPI/Events/ButtonsChangedEventArgs.cs b/src/SMAPI/Events/ButtonsChangedEventArgs.cs
index dda41692..a5e87735 100644
--- a/src/SMAPI/Events/ButtonsChangedEventArgs.cs
+++ b/src/SMAPI/Events/ButtonsChangedEventArgs.cs
@@ -58,7 +58,7 @@ namespace StardewModdingAPI.Events
foreach (var state in new[] { SButtonState.Pressed, SButtonState.Held, SButtonState.Released })
{
if (!lookup.ContainsKey(state))
- lookup[state] = new SButton[0];
+ lookup[state] = Array.Empty<SButton>();
}
return lookup;
diff --git a/src/SMAPI/Framework/Content/AssetData.cs b/src/SMAPI/Framework/Content/AssetData.cs
index 5c90d83b..05be8a3b 100644
--- a/src/SMAPI/Framework/Content/AssetData.cs
+++ b/src/SMAPI/Framework/Content/AssetData.cs
@@ -25,11 +25,11 @@ namespace StardewModdingAPI.Framework.Content
*********/
/// <summary>Construct an instance.</summary>
/// <param name="locale">The content's locale code, if the content is localized.</param>
- /// <param name="assetName">The normalized asset name being read.</param>
+ /// <param name="assetName">The asset name being read.</param>
/// <param name="data">The content data being read.</param>
/// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param>
/// <param name="onDataReplaced">A callback to invoke when the data is replaced (if any).</param>
- public AssetData(string locale, string assetName, TValue data, Func<string, string> getNormalizedPath, Action<TValue> onDataReplaced)
+ public AssetData(string locale, IAssetName assetName, TValue data, Func<string, string> getNormalizedPath, Action<TValue> onDataReplaced)
: base(locale, assetName, data.GetType(), getNormalizedPath)
{
this.Data = data;
diff --git a/src/SMAPI/Framework/Content/AssetDataForDictionary.cs b/src/SMAPI/Framework/Content/AssetDataForDictionary.cs
index 26cbff5a..735b651c 100644
--- a/src/SMAPI/Framework/Content/AssetDataForDictionary.cs
+++ b/src/SMAPI/Framework/Content/AssetDataForDictionary.cs
@@ -11,11 +11,11 @@ namespace StardewModdingAPI.Framework.Content
*********/
/// <summary>Construct an instance.</summary>
/// <param name="locale">The content's locale code, if the content is localized.</param>
- /// <param name="assetName">The normalized asset name being read.</param>
+ /// <param name="assetName">The asset name being read.</param>
/// <param name="data">The content data being read.</param>
/// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param>
/// <param name="onDataReplaced">A callback to invoke when the data is replaced (if any).</param>
- public AssetDataForDictionary(string locale, string assetName, IDictionary<TKey, TValue> data, Func<string, string> getNormalizedPath, Action<IDictionary<TKey, TValue>> onDataReplaced)
+ public AssetDataForDictionary(string locale, IAssetName assetName, IDictionary<TKey, TValue> data, Func<string, string> getNormalizedPath, Action<IDictionary<TKey, TValue>> onDataReplaced)
: base(locale, assetName, data, getNormalizedPath, onDataReplaced) { }
}
}
diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs
index 529fb93a..b0f1b5c7 100644
--- a/src/SMAPI/Framework/Content/AssetDataForImage.cs
+++ b/src/SMAPI/Framework/Content/AssetDataForImage.cs
@@ -21,11 +21,11 @@ namespace StardewModdingAPI.Framework.Content
*********/
/// <summary>Construct an instance.</summary>
/// <param name="locale">The content's locale code, if the content is localized.</param>
- /// <param name="assetName">The normalized asset name being read.</param>
+ /// <param name="assetName">The asset name being read.</param>
/// <param name="data">The content data being read.</param>
/// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param>
/// <param name="onDataReplaced">A callback to invoke when the data is replaced (if any).</param>
- public AssetDataForImage(string locale, string assetName, Texture2D data, Func<string, string> getNormalizedPath, Action<Texture2D> onDataReplaced)
+ public AssetDataForImage(string locale, IAssetName assetName, Texture2D data, Func<string, string> getNormalizedPath, Action<Texture2D> onDataReplaced)
: base(locale, assetName, data, getNormalizedPath, onDataReplaced) { }
/// <inheritdoc />
@@ -41,39 +41,40 @@ namespace StardewModdingAPI.Framework.Content
targetArea ??= new Rectangle(0, 0, Math.Min(sourceArea.Value.Width, target.Width), Math.Min(sourceArea.Value.Height, target.Height));
// validate
- if (sourceArea.Value.X < 0 || sourceArea.Value.Y < 0 || sourceArea.Value.Right > source.Width || sourceArea.Value.Bottom > source.Height)
+ if (!source.Bounds.Contains(sourceArea.Value))
throw new ArgumentOutOfRangeException(nameof(sourceArea), "The source area is outside the bounds of the source texture.");
- if (targetArea.Value.X < 0 || targetArea.Value.Y < 0 || targetArea.Value.Right > target.Width || targetArea.Value.Bottom > target.Height)
+ if (!target.Bounds.Contains(targetArea.Value))
throw new ArgumentOutOfRangeException(nameof(targetArea), "The target area is outside the bounds of the target texture.");
- if (sourceArea.Value.Width != targetArea.Value.Width || sourceArea.Value.Height != targetArea.Value.Height)
+ if (sourceArea.Value.Size != targetArea.Value.Size)
throw new InvalidOperationException("The source and target areas must be the same size.");
// get source data
int pixelCount = sourceArea.Value.Width * sourceArea.Value.Height;
- Color[] sourceData = new Color[pixelCount];
+ Color[] sourceData = GC.AllocateUninitializedArray<Color>(pixelCount);
source.GetData(0, sourceArea, sourceData, 0, pixelCount);
// merge data in overlay mode
if (patchMode == PatchMode.Overlay)
{
// get target data
- Color[] targetData = new Color[pixelCount];
+ Color[] targetData = GC.AllocateUninitializedArray<Color>(pixelCount);
target.GetData(0, targetArea, targetData, 0, pixelCount);
// merge pixels
- Color[] newData = new Color[targetArea.Value.Width * targetArea.Value.Height];
- target.GetData(0, targetArea, newData, 0, newData.Length);
for (int i = 0; i < sourceData.Length; i++)
{
Color above = sourceData[i];
Color below = targetData[i];
// shortcut transparency
- if (above.A < AssetDataForImage.MinOpacity)
+ if (above.A < MinOpacity)
+ {
+ sourceData[i] = below;
continue;
- if (below.A < AssetDataForImage.MinOpacity)
+ }
+ if (below.A < MinOpacity)
{
- newData[i] = above;
+ sourceData[i] = above;
continue;
}
@@ -84,14 +85,13 @@ namespace StardewModdingAPI.Framework.Content
// Note: don't use named arguments here since they're different between
// Linux/macOS and Windows.
float alphaBelow = 1 - (above.A / 255f);
- newData[i] = new Color(
+ sourceData[i] = new Color(
(int)(above.R + (below.R * alphaBelow)), // r
(int)(above.G + (below.G * alphaBelow)), // g
(int)(above.B + (below.B * alphaBelow)), // b
Math.Max(above.A, below.A) // a
);
}
- sourceData = newData;
}
// patch target texture
@@ -105,7 +105,7 @@ namespace StardewModdingAPI.Framework.Content
return false;
Texture2D original = this.Data;
- Texture2D texture = new Texture2D(Game1.graphics.GraphicsDevice, Math.Max(original.Width, minWidth), Math.Max(original.Height, minHeight));
+ Texture2D texture = new(Game1.graphics.GraphicsDevice, Math.Max(original.Width, minWidth), Math.Max(original.Height, minHeight));
this.ReplaceWith(texture);
this.PatchImage(original);
return true;
diff --git a/src/SMAPI/Framework/Content/AssetDataForMap.cs b/src/SMAPI/Framework/Content/AssetDataForMap.cs
index 0a5fa7e7..26e4986e 100644
--- a/src/SMAPI/Framework/Content/AssetDataForMap.cs
+++ b/src/SMAPI/Framework/Content/AssetDataForMap.cs
@@ -18,11 +18,11 @@ namespace StardewModdingAPI.Framework.Content
*********/
/// <summary>Construct an instance.</summary>
/// <param name="locale">The content's locale code, if the content is localized.</param>
- /// <param name="assetName">The normalized asset name being read.</param>
+ /// <param name="assetName">The asset name being read.</param>
/// <param name="data">The content data being read.</param>
/// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param>
/// <param name="onDataReplaced">A callback to invoke when the data is replaced (if any).</param>
- public AssetDataForMap(string locale, string assetName, Map data, Func<string, string> getNormalizedPath, Action<Map> onDataReplaced)
+ public AssetDataForMap(string locale, IAssetName assetName, Map data, Func<string, string> getNormalizedPath, Action<Map> onDataReplaced)
: base(locale, assetName, data, getNormalizedPath, onDataReplaced) { }
/// <inheritdoc />
diff --git a/src/SMAPI/Framework/Content/AssetDataForObject.cs b/src/SMAPI/Framework/Content/AssetDataForObject.cs
index b7e8dfeb..d91873ae 100644
--- a/src/SMAPI/Framework/Content/AssetDataForObject.cs
+++ b/src/SMAPI/Framework/Content/AssetDataForObject.cs
@@ -13,10 +13,10 @@ namespace StardewModdingAPI.Framework.Content
*********/
/// <summary>Construct an instance.</summary>
/// <param name="locale">The content's locale code, if the content is localized.</param>
- /// <param name="assetName">The normalized asset name being read.</param>
+ /// <param name="assetName">The asset name being read.</param>
/// <param name="data">The content data being read.</param>
/// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param>
- public AssetDataForObject(string locale, string assetName, object data, Func<string, string> getNormalizedPath)
+ public AssetDataForObject(string locale, IAssetName assetName, object data, Func<string, string> getNormalizedPath)
: base(locale, assetName, data, getNormalizedPath, onDataReplaced: null) { }
/// <summary>Construct an instance.</summary>
@@ -24,24 +24,24 @@ namespace StardewModdingAPI.Framework.Content
/// <param name="data">The content data being read.</param>
/// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param>
public AssetDataForObject(IAssetInfo info, object data, Func<string, string> getNormalizedPath)
- : this(info.Locale, info.AssetName, data, getNormalizedPath) { }
+ : this(info.Locale, info.Name, data, getNormalizedPath) { }
/// <inheritdoc />
public IAssetDataForDictionary<TKey, TValue> AsDictionary<TKey, TValue>()
{
- return new AssetDataForDictionary<TKey, TValue>(this.Locale, this.AssetName, this.GetData<IDictionary<TKey, TValue>>(), this.GetNormalizedPath, this.ReplaceWith);
+ return new AssetDataForDictionary<TKey, TValue>(this.Locale, this.Name, this.GetData<IDictionary<TKey, TValue>>(), this.GetNormalizedPath, this.ReplaceWith);
}
/// <inheritdoc />
public IAssetDataForImage AsImage()
{
- return new AssetDataForImage(this.Locale, this.AssetName, this.GetData<Texture2D>(), this.GetNormalizedPath, this.ReplaceWith);
+ return new AssetDataForImage(this.Locale, this.Name, this.GetData<Texture2D>(), this.GetNormalizedPath, this.ReplaceWith);
}
/// <inheritdoc />
public IAssetDataForMap AsMap()
{
- return new AssetDataForMap(this.Locale, this.AssetName, this.GetData<Map>(), this.GetNormalizedPath, this.ReplaceWith);
+ return new AssetDataForMap(this.Locale, this.Name, this.GetData<Map>(), this.GetNormalizedPath, this.ReplaceWith);
}
/// <inheritdoc />
diff --git a/src/SMAPI/Framework/Content/AssetInfo.cs b/src/SMAPI/Framework/Content/AssetInfo.cs
index d8106439..6a5b4f31 100644
--- a/src/SMAPI/Framework/Content/AssetInfo.cs
+++ b/src/SMAPI/Framework/Content/AssetInfo.cs
@@ -20,7 +20,11 @@ namespace StardewModdingAPI.Framework.Content
public string Locale { get; }
/// <inheritdoc />
- public string AssetName { get; }
+ public IAssetName Name { get; }
+
+ /// <inheritdoc />
+ [Obsolete($"Use {nameof(Name)} instead.")]
+ public string AssetName => this.Name.Name;
/// <inheritdoc />
public Type DataType { get; }
@@ -31,22 +35,22 @@ namespace StardewModdingAPI.Framework.Content
*********/
/// <summary>Construct an instance.</summary>
/// <param name="locale">The content's locale code, if the content is localized.</param>
- /// <param name="assetName">The normalized asset name being read.</param>
+ /// <param name="assetName">The asset name being read.</param>
/// <param name="type">The content type being read.</param>
/// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param>
- public AssetInfo(string locale, string assetName, Type type, Func<string, string> getNormalizedPath)
+ public AssetInfo(string locale, IAssetName assetName, Type type, Func<string, string> getNormalizedPath)
{
this.Locale = locale;
- this.AssetName = assetName;
+ this.Name = assetName;
this.DataType = type;
this.GetNormalizedPath = getNormalizedPath;
}
/// <inheritdoc />
+ [Obsolete($"Use {nameof(Name)}.{nameof(IAssetName.IsEquivalentTo)} instead.")]
public bool AssetNameEquals(string path)
{
- path = this.GetNormalizedPath(path);
- return this.AssetName.Equals(path, StringComparison.OrdinalIgnoreCase);
+ return this.Name.IsEquivalentTo(path);
}
diff --git a/src/SMAPI/Framework/Content/AssetInterceptorChange.cs b/src/SMAPI/Framework/Content/AssetInterceptorChange.cs
index 10488b84..981eed40 100644
--- a/src/SMAPI/Framework/Content/AssetInterceptorChange.cs
+++ b/src/SMAPI/Framework/Content/AssetInterceptorChange.cs
@@ -70,7 +70,7 @@ namespace StardewModdingAPI.Framework.Content
}
catch (Exception ex)
{
- this.Mod.LogAsMod($"Mod failed when checking whether it could edit asset '{asset.AssetName}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
+ this.Mod.LogAsMod($"Mod failed when checking whether it could edit asset '{asset.Name}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
}
}
@@ -84,7 +84,7 @@ namespace StardewModdingAPI.Framework.Content
}
catch (Exception ex)
{
- this.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{asset.AssetName}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
+ this.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{asset.Name}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
}
}
diff --git a/src/SMAPI/Framework/Content/AssetName.cs b/src/SMAPI/Framework/Content/AssetName.cs
new file mode 100644
index 00000000..992647f8
--- /dev/null
+++ b/src/SMAPI/Framework/Content/AssetName.cs
@@ -0,0 +1,173 @@
+using System;
+using StardewModdingAPI.Toolkit.Utilities;
+using StardewValley;
+
+namespace StardewModdingAPI.Framework.Content
+{
+ /// <summary>An asset name that can be loaded through the content pipeline.</summary>
+ internal class AssetName : IAssetName
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>A lowercase version of <see cref="Name"/> used for consistent hash codes and equality checks.</summary>
+ private readonly string ComparableName;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <inheritdoc />
+ public string Name { get; }
+
+ /// <inheritdoc />
+ public string BaseName { get; }
+
+ /// <inheritdoc />
+ public string LocaleCode { get; }
+
+ /// <inheritdoc />
+ public LocalizedContentManager.LanguageCode? LanguageCode { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="baseName">The base asset name without the locale code.</param>
+ /// <param name="localeCode">The locale code specified in the <see cref="Name"/>, if it's a valid code recognized by the game content.</param>
+ /// <param name="languageCode">The language code matching the <see cref="LocaleCode"/>, if applicable.</param>
+ public AssetName(string baseName, string localeCode, LocalizedContentManager.LanguageCode? languageCode)
+ {
+ // validate
+ if (string.IsNullOrWhiteSpace(baseName))
+ throw new ArgumentException("The asset name can't be null or empty.", nameof(baseName));
+ if (string.IsNullOrWhiteSpace(localeCode))
+ localeCode = null;
+
+ // set base values
+ this.BaseName = PathUtilities.NormalizeAssetName(baseName);
+ this.LocaleCode = localeCode;
+ this.LanguageCode = languageCode;
+
+ // set derived values
+ this.Name = localeCode != null
+ ? string.Concat(this.BaseName, '.', this.LocaleCode)
+ : this.BaseName;
+ this.ComparableName = this.Name.ToLowerInvariant();
+ }
+
+ /// <summary>Parse a raw asset name into an instance.</summary>
+ /// <param name="rawName">The raw asset name to parse.</param>
+ /// <param name="parseLocale">Get the language code for a given locale, if it's valid.</param>
+ /// <exception cref="ArgumentException">The <paramref name="rawName"/> is null or empty.</exception>
+ public static AssetName Parse(string rawName, Func<string, LocalizedContentManager.LanguageCode?> parseLocale)
+ {
+ if (string.IsNullOrWhiteSpace(rawName))
+ throw new ArgumentException("The asset name can't be null or empty.", nameof(rawName));
+
+ string baseName = rawName;
+ string localeCode = null;
+ LocalizedContentManager.LanguageCode? languageCode = null;
+
+ int lastPeriodIndex = rawName.LastIndexOf('.');
+ if (lastPeriodIndex > 0 && rawName.Length > lastPeriodIndex + 1)
+ {
+ string possibleLocaleCode = rawName[(lastPeriodIndex + 1)..];
+ LocalizedContentManager.LanguageCode? possibleLanguageCode = parseLocale(possibleLocaleCode);
+
+ if (possibleLanguageCode != null)
+ {
+ baseName = rawName[..lastPeriodIndex];
+ localeCode = possibleLocaleCode;
+ languageCode = possibleLanguageCode;
+ }
+ }
+
+ return new AssetName(baseName, localeCode, languageCode);
+ }
+
+ /// <inheritdoc />
+ public bool IsEquivalentTo(string assetName, bool useBaseName = false)
+ {
+ // empty asset key is never equivalent
+ if (string.IsNullOrWhiteSpace(assetName))
+ return false;
+
+ assetName = PathUtilities.NormalizeAssetName(assetName);
+
+ string compareTo = useBaseName ? this.BaseName : this.Name;
+ return compareTo.Equals(assetName, StringComparison.OrdinalIgnoreCase);
+ }
+
+ /// <inheritdoc />
+ public bool StartsWith(string prefix, bool allowPartialWord = true, bool allowSubfolder = true)
+ {
+ // asset keys never start with null
+ if (prefix is null)
+ return false;
+
+ // asset keys can't have a leading slash, but NormalizeAssetName will trim them
+ {
+ string trimmed = prefix.TrimStart();
+ if (trimmed.StartsWith('/') || trimmed.StartsWith('\\'))
+ return false;
+ }
+
+ // normalize prefix
+ {
+ string normalized = PathUtilities.NormalizeAssetName(prefix);
+
+ string trimmed = prefix.TrimEnd();
+ if (trimmed.EndsWith('/') || trimmed.EndsWith('\\'))
+ normalized += PathUtilities.PreferredAssetSeparator;
+
+ prefix = normalized;
+ }
+
+ // compare
+ return
+ this.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
+ && (
+ allowPartialWord
+ || this.Name.Length == prefix.Length
+ || !char.IsLetterOrDigit(prefix[^1]) // last character in suffix is word separator
+ || !char.IsLetterOrDigit(this.Name[prefix.Length]) // or first character after it is
+ )
+ && (
+ allowSubfolder
+ || this.Name.Length == prefix.Length
+ || !this.Name[prefix.Length..].Contains(PathUtilities.PreferredAssetSeparator)
+ );
+ }
+
+
+ public bool IsDirectlyUnderPath(string assetFolder)
+ {
+ return this.StartsWith(assetFolder + "/", allowPartialWord: false, allowSubfolder: false);
+ }
+
+ /// <inheritdoc />
+ public bool Equals(IAssetName other)
+ {
+ return other switch
+ {
+ null => false,
+ AssetName otherImpl => this.ComparableName == otherImpl.ComparableName,
+ _ => StringComparer.OrdinalIgnoreCase.Equals(this.Name, other.Name)
+ };
+ }
+
+ /// <inheritdoc />
+ public override int GetHashCode()
+ {
+ return this.ComparableName.GetHashCode();
+ }
+
+ /// <inheritdoc />
+ public override string ToString()
+ {
+ return this.Name;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs
index 99091f3e..00f9439c 100644
--- a/src/SMAPI/Framework/ContentCoordinator.cs
+++ b/src/SMAPI/Framework/ContentCoordinator.cs
@@ -14,6 +14,7 @@ using StardewModdingAPI.Metadata;
using StardewModdingAPI.Toolkit.Serialization;
using StardewModdingAPI.Toolkit.Utilities;
using StardewValley;
+using StardewValley.GameData;
using xTile;
namespace StardewModdingAPI.Framework
@@ -46,7 +47,7 @@ namespace StardewModdingAPI.Framework
private readonly Action OnLoadingFirstAsset;
/// <summary>The loaded content managers (including the <see cref="MainContentManager"/>).</summary>
- private readonly IList<IContentManager> ContentManagers = new List<IContentManager>();
+ private readonly List<IContentManager> ContentManagers = new();
/// <summary>The language code for language-agnostic mod assets.</summary>
private readonly LocalizedContentManager.LanguageCode DefaultLanguage = Constants.DefaultLanguage;
@@ -56,16 +57,16 @@ namespace StardewModdingAPI.Framework
/// <summary>A lock used to prevent asynchronous changes to the content manager list.</summary>
/// <remarks>The game may add content managers in asynchronous threads (e.g. when populating the load screen).</remarks>
- private readonly ReaderWriterLockSlim ContentManagerLock = new ReaderWriterLockSlim();
+ private readonly ReaderWriterLockSlim ContentManagerLock = new();
/// <summary>A cache of ordered tilesheet IDs used by vanilla maps.</summary>
- private readonly IDictionary<string, TilesheetReference[]> VanillaTilesheets = new Dictionary<string, TilesheetReference[]>(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary<string, TilesheetReference[]> VanillaTilesheets = new(StringComparer.OrdinalIgnoreCase);
/// <summary>An unmodified content manager which doesn't intercept assets, used to compare asset data.</summary>
private readonly LocalizedContentManager VanillaContentManager;
/// <summary>The language enum values indexed by locale code.</summary>
- private Lazy<IDictionary<string, LocalizedContentManager.LanguageCode>> LocaleCodes;
+ private Lazy<Dictionary<string, LocalizedContentManager.LanguageCode>> LocaleCodes;
/*********
@@ -106,7 +107,7 @@ namespace StardewModdingAPI.Framework
this.Reflection = reflection;
this.JsonHelper = jsonHelper;
this.OnLoadingFirstAsset = onLoadingFirstAsset;
- this.FullRootDirectory = Path.Combine(Constants.ExecutionPath, rootDirectory);
+ this.FullRootDirectory = Path.Combine(Constants.GamePath, rootDirectory);
this.ContentManagers.Add(
this.MainContentManager = new GameContentManager(
name: "Game1.content",
@@ -136,7 +137,7 @@ namespace StardewModdingAPI.Framework
this.ContentManagers.Add(contentManagerForAssetPropagation);
this.VanillaContentManager = new LocalizedContentManager(serviceProvider, rootDirectory);
this.CoreAssets = new CoreAssetPropagator(this.MainContentManager, contentManagerForAssetPropagation, this.Monitor, reflection, aggressiveMemoryOptimizations);
- this.LocaleCodes = new Lazy<IDictionary<string, LocalizedContentManager.LanguageCode>>(this.GetLocaleCodes);
+ this.LocaleCodes = new Lazy<Dictionary<string, LocalizedContentManager.LanguageCode>>(() => this.GetLocaleCodes(includeCustomLanguages: false));
}
/// <summary>Get a new content manager which handles reading files from the game content folder with support for interception.</summary>
@@ -145,7 +146,7 @@ namespace StardewModdingAPI.Framework
{
return this.ContentManagerLock.InWriteLock(() =>
{
- GameContentManager manager = new GameContentManager(
+ GameContentManager manager = new(
name: name,
serviceProvider: this.MainContentManager.ServiceProvider,
rootDirectory: this.MainContentManager.RootDirectory,
@@ -171,7 +172,7 @@ namespace StardewModdingAPI.Framework
{
return this.ContentManagerLock.InWriteLock(() =>
{
- ModContentManager manager = new ModContentManager(
+ ModContentManager manager = new(
name: name,
gameContentManager: gameContentManager,
serviceProvider: this.MainContentManager.ServiceProvider,
@@ -196,12 +197,17 @@ namespace StardewModdingAPI.Framework
return this.MainContentManager.GetLocale(LocalizedContentManager.CurrentLanguageCode);
}
- /// <summary>Perform any cleanup needed when the locale changes.</summary>
- public void OnLocaleChanged()
+ /// <summary>Perform any updates needed when the game loads custom languages from <c>Data/AdditionalLanguages</c>.</summary>
+ public void OnAdditionalLanguagesInitialized()
{
- // rebuild locale cache (which may change due to custom mod languages)
- this.LocaleCodes = new Lazy<IDictionary<string, LocalizedContentManager.LanguageCode>>(this.GetLocaleCodes);
+ // update locale cache for custom languages, and load it now (since languages added later won't work)
+ this.LocaleCodes = new Lazy<Dictionary<string, LocalizedContentManager.LanguageCode>>(() => this.GetLocaleCodes(includeCustomLanguages: true));
+ _ = this.LocaleCodes.Value;
+ }
+ /// <summary>Perform any updates needed when the locale changes.</summary>
+ public void OnLocaleChanged()
+ {
// reload affected content
this.ContentManagerLock.InReadLock(() =>
{
@@ -242,6 +248,16 @@ namespace StardewModdingAPI.Framework
this.InvalidateCache((contentManager, key, type) => contentManager is GameContentManager);
}
+ /// <summary>Parse a raw asset name.</summary>
+ /// <param name="rawName">The raw asset name to parse.</param>
+ /// <exception cref="ArgumentException">The <paramref name="rawName"/> is null or empty.</exception>
+ public AssetName ParseAssetName(string rawName)
+ {
+ return !string.IsNullOrWhiteSpace(rawName)
+ ? AssetName.Parse(rawName, parseLocale: locale => this.LocaleCodes.Value.TryGetValue(locale, out LocalizedContentManager.LanguageCode langCode) ? langCode : null)
+ : throw new ArgumentException("The asset name can't be null or empty.", nameof(rawName));
+ }
+
/// <summary>Get whether this asset is mapped to a mod folder.</summary>
/// <param name="key">The asset key.</param>
public bool IsManagedAssetKey(string key)
@@ -300,11 +316,12 @@ namespace StardewModdingAPI.Framework
/// <param name="predicate">Matches the asset keys to invalidate.</param>
/// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
/// <returns>Returns the invalidated asset keys.</returns>
- public IEnumerable<string> InvalidateCache(Func<IAssetInfo, bool> predicate, bool dispose = false)
+ public IEnumerable<IAssetName> InvalidateCache(Func<IAssetInfo, bool> predicate, bool dispose = false)
{
string locale = this.GetLocale();
- return this.InvalidateCache((contentManager, assetName, type) =>
+ return this.InvalidateCache((contentManager, rawName, type) =>
{
+ IAssetName assetName = this.ParseAssetName(rawName);
IAssetInfo info = new AssetInfo(locale, assetName, type, this.MainContentManager.AssertAndNormalizeAssetName);
return predicate(info);
}, dispose);
@@ -314,10 +331,10 @@ namespace StardewModdingAPI.Framework
/// <param name="predicate">Matches the asset keys to invalidate.</param>
/// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
/// <returns>Returns the invalidated asset names.</returns>
- public IEnumerable<string> InvalidateCache(Func<IContentManager, string, Type, bool> predicate, bool dispose = false)
+ public IEnumerable<IAssetName> InvalidateCache(Func<IContentManager, string, Type, bool> predicate, bool dispose = false)
{
// invalidate cache & track removed assets
- IDictionary<string, Type> removedAssets = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase);
+ IDictionary<IAssetName, Type> removedAssets = new Dictionary<IAssetName, Type>();
this.ContentManagerLock.InReadLock(() =>
{
// cached assets
@@ -325,8 +342,9 @@ namespace StardewModdingAPI.Framework
{
foreach (var entry in contentManager.InvalidateCache((key, type) => predicate(contentManager, key, type), dispose))
{
- if (!removedAssets.ContainsKey(entry.Key))
- removedAssets[entry.Key] = entry.Value.GetType();
+ AssetName assetName = this.ParseAssetName(entry.Key);
+ if (!removedAssets.ContainsKey(assetName))
+ removedAssets[assetName] = entry.Value.GetType();
}
}
@@ -340,8 +358,8 @@ namespace StardewModdingAPI.Framework
continue;
// get map path
- string mapPath = this.MainContentManager.AssertAndNormalizeAssetName(location.mapPath.Value);
- if (!removedAssets.ContainsKey(mapPath) && predicate(this.MainContentManager, mapPath, typeof(Map)))
+ AssetName mapPath = this.ParseAssetName(this.MainContentManager.AssertAndNormalizeAssetName(location.mapPath.Value));
+ if (!removedAssets.ContainsKey(mapPath) && predicate(this.MainContentManager, mapPath.Name, typeof(Map)))
removedAssets[mapPath] = typeof(Map);
}
}
@@ -354,17 +372,17 @@ namespace StardewModdingAPI.Framework
this.CoreAssets.Propagate(
assets: removedAssets.ToDictionary(p => p.Key, p => p.Value),
ignoreWorld: Context.IsWorldFullyUnloaded,
- out IDictionary<string, bool> propagated,
+ out IDictionary<IAssetName, bool> propagated,
out bool updatedNpcWarps
);
// log summary
- StringBuilder report = new StringBuilder();
+ StringBuilder report = new();
{
- string[] invalidatedKeys = removedAssets.Keys.ToArray();
- string[] propagatedKeys = propagated.Where(p => p.Value).Select(p => p.Key).ToArray();
+ IAssetName[] invalidatedKeys = removedAssets.Keys.ToArray();
+ IAssetName[] propagatedKeys = propagated.Where(p => p.Value).Select(p => p.Key).ToArray();
- string FormatKeyList(IEnumerable<string> keys) => string.Join(", ", keys.OrderBy(p => p, StringComparer.OrdinalIgnoreCase));
+ string FormatKeyList(IEnumerable<IAssetName> keys) => string.Join(", ", keys.Select(p => p.Name).OrderBy(p => p, StringComparer.OrdinalIgnoreCase));
report.AppendLine($"Invalidated {invalidatedKeys.Length} asset names ({FormatKeyList(invalidatedKeys)}).");
report.AppendLine(propagated.Count > 0
@@ -416,15 +434,6 @@ namespace StardewModdingAPI.Framework
return tilesheets ?? Array.Empty<TilesheetReference>();
}
- /// <summary>Get the language enum which corresponds to a locale code (e.g. <see cref="LocalizedContentManager.LanguageCode.fr"/> given <c>fr-FR</c>).</summary>
- /// <param name="locale">The locale code to search. This must exactly match the language; no fallback is performed.</param>
- /// <param name="language">The matched language enum, if any.</param>
- /// <returns>Returns whether a valid language was found.</returns>
- public bool TryGetLanguageEnum(string locale, out LocalizedContentManager.LanguageCode language)
- {
- return this.LocaleCodes.Value.TryGetValue(locale, out language);
- }
-
/// <summary>Get the locale code which corresponds to a language enum (e.g. <c>fr-FR</c> given <see cref="LocalizedContentManager.LanguageCode.fr"/>).</summary>
/// <param name="language">The language enum to search.</param>
public string GetLocaleCode(LocalizedContentManager.LanguageCode language)
@@ -486,9 +495,22 @@ namespace StardewModdingAPI.Framework
}
/// <summary>Get the language enums (like <see cref="LocalizedContentManager.LanguageCode.ja"/>) indexed by locale code (like <c>ja-JP</c>).</summary>
- private IDictionary<string, LocalizedContentManager.LanguageCode> GetLocaleCodes()
+ /// <param name="includeCustomLanguages">Whether to read custom languages from <c>Data/AdditionalLanguages</c>.</param>
+ private Dictionary<string, LocalizedContentManager.LanguageCode> GetLocaleCodes(bool includeCustomLanguages)
{
- IDictionary<string, LocalizedContentManager.LanguageCode> map = new Dictionary<string, LocalizedContentManager.LanguageCode>();
+ var map = new Dictionary<string, LocalizedContentManager.LanguageCode>(StringComparer.OrdinalIgnoreCase);
+
+ // custom languages
+ if (includeCustomLanguages)
+ {
+ foreach (ModLanguage language in Game1.content.Load<List<ModLanguage>>("Data/AdditionalLanguages"))
+ {
+ if (!string.IsNullOrWhiteSpace(language?.LanguageCode))
+ map[language.LanguageCode] = LocalizedContentManager.LanguageCode.mod;
+ }
+ }
+
+ // vanilla languages (override custom language if they conflict)
foreach (LocalizedContentManager.LanguageCode code in Enum.GetValues(typeof(LocalizedContentManager.LanguageCode)))
{
string locale = this.GetLocaleCode(code);
diff --git a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs
index 5645c0fa..26f0921d 100644
--- a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs
@@ -56,7 +56,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
public LanguageCode Language => this.GetCurrentLanguage();
/// <inheritdoc />
- public string FullRootDirectory => Path.Combine(Constants.ExecutionPath, this.RootDirectory);
+ public string FullRootDirectory => Path.Combine(Constants.GamePath, this.RootDirectory);
/// <inheritdoc />
public bool IsNamespaced { get; }
@@ -160,14 +160,6 @@ namespace StardewModdingAPI.Framework.ContentManagers
return this.IsNormalizedKeyLoaded(assetName, language);
}
- /// <inheritdoc />
- public IEnumerable<string> GetAssetKeys()
- {
- return this.Cache.Keys
- .Select(this.GetAssetName)
- .Distinct();
- }
-
/****
** Cache invalidation
****/
@@ -177,13 +169,13 @@ namespace StardewModdingAPI.Framework.ContentManagers
IDictionary<string, object> removeAssets = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
this.Cache.Remove((key, asset) =>
{
- this.ParseCacheKey(key, out string assetName, out _);
+ string baseAssetName = this.Coordinator.ParseAssetName(key).BaseName;
// check if asset should be removed
- bool remove = removeAssets.ContainsKey(assetName);
- if (!remove && predicate(assetName, asset.GetType()))
+ bool remove = removeAssets.ContainsKey(baseAssetName);
+ if (!remove && predicate(baseAssetName, asset.GetType()))
{
- removeAssets[assetName] = asset;
+ removeAssets[baseAssetName] = asset;
remove = true;
}
@@ -275,44 +267,9 @@ namespace StardewModdingAPI.Framework.ContentManagers
this.BaseDisposableReferences.Clear();
}
- /// <summary>Parse a cache key into its component parts.</summary>
- /// <param name="cacheKey">The input cache key.</param>
- /// <param name="assetName">The original asset name.</param>
- /// <param name="localeCode">The asset locale code (or <c>null</c> if not localized).</param>
- protected void ParseCacheKey(string cacheKey, out string assetName, out string localeCode)
- {
- // handle localized key
- if (!string.IsNullOrWhiteSpace(cacheKey))
- {
- int lastSepIndex = cacheKey.LastIndexOf(".", StringComparison.Ordinal);
- if (lastSepIndex >= 0)
- {
- string suffix = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1);
- if (this.Coordinator.TryGetLanguageEnum(suffix, out _))
- {
- assetName = cacheKey.Substring(0, lastSepIndex);
- localeCode = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1);
- return;
- }
- }
- }
-
- // handle simple key
- assetName = cacheKey;
- localeCode = null;
- }
-
/// <summary>Get whether an asset has already been loaded.</summary>
/// <param name="normalizedAssetName">The normalized asset name.</param>
/// <param name="language">The language to check.</param>
protected abstract bool IsNormalizedKeyLoaded(string normalizedAssetName, LanguageCode language);
-
- /// <summary>Get the asset name from a cache key.</summary>
- /// <param name="cacheKey">The input cache key.</param>
- private string GetAssetName(string cacheKey)
- {
- this.ParseCacheKey(cacheKey, out string assetName, out string _);
- return assetName;
- }
}
}
diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
index ab198076..0ca9e277 100644
--- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
@@ -73,46 +73,46 @@ namespace StardewModdingAPI.Framework.ContentManagers
}
// normalize asset name
- assetName = this.AssertAndNormalizeAssetName(assetName);
- if (this.TryParseExplicitLanguageAssetKey(assetName, out string newAssetName, out LanguageCode newLanguage))
- return this.Load<T>(newAssetName, newLanguage, useCache);
+ IAssetName parsedName = this.Coordinator.ParseAssetName(assetName);
+ if (parsedName.LanguageCode.HasValue)
+ return this.Load<T>(parsedName.BaseName, parsedName.LanguageCode.Value, useCache);
// get from cache
- if (useCache && this.IsLoaded(assetName, language))
- return this.RawLoad<T>(assetName, language, useCache: true);
+ if (useCache && this.IsLoaded(parsedName.Name, language))
+ return this.RawLoad<T>(parsedName.Name, language, useCache: true);
// get managed asset
- if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath))
+ if (this.Coordinator.TryParseManagedAssetKey(parsedName.Name, out string contentManagerID, out string relativePath))
{
T managedAsset = this.Coordinator.LoadManagedAsset<T>(contentManagerID, relativePath);
- this.TrackAsset(assetName, managedAsset, language, useCache);
+ this.TrackAsset(parsedName.Name, managedAsset, language, useCache);
return managedAsset;
}
// load asset
T data;
- if (this.AssetsBeingLoaded.Contains(assetName))
+ if (this.AssetsBeingLoaded.Contains(parsedName.Name))
{
- this.Monitor.Log($"Broke loop while loading asset '{assetName}'.", LogLevel.Warn);
+ this.Monitor.Log($"Broke loop while loading asset '{parsedName.Name}'.", LogLevel.Warn);
this.Monitor.Log($"Bypassing mod loaders for this asset. Stack trace:\n{Environment.StackTrace}");
- data = this.RawLoad<T>(assetName, language, useCache);
+ data = this.RawLoad<T>(parsedName.Name, language, useCache);
}
else
{
- data = this.AssetsBeingLoaded.Track(assetName, () =>
+ data = this.AssetsBeingLoaded.Track(parsedName.Name, () =>
{
string locale = this.GetLocale(language);
- IAssetInfo info = new AssetInfo(locale, assetName, typeof(T), this.AssertAndNormalizeAssetName);
+ IAssetInfo info = new AssetInfo(locale, parsedName, typeof(T), this.AssertAndNormalizeAssetName);
IAssetData asset =
this.ApplyLoader<T>(info)
- ?? new AssetDataForObject(info, this.RawLoad<T>(assetName, language, useCache), this.AssertAndNormalizeAssetName);
+ ?? new AssetDataForObject(info, this.RawLoad<T>(parsedName.Name, language, useCache), this.AssertAndNormalizeAssetName);
asset = this.ApplyEditors<T>(info, asset);
return (T)asset.Data;
});
}
// update cache & return data
- this.TrackAsset(assetName, data, language, useCache);
+ this.TrackAsset(parsedName.Name, data, language, useCache);
return data;
}
@@ -124,13 +124,16 @@ namespace StardewModdingAPI.Framework.ContentManagers
// find assets for which a translatable version was loaded
HashSet<string> removeAssetNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (string key in this.LocalizedAssetNames.Where(p => p.Key != p.Value).Select(p => p.Key))
- removeAssetNames.Add(this.TryParseExplicitLanguageAssetKey(key, out string assetName, out _) ? assetName : key);
+ {
+ IAssetName assetName = this.Coordinator.ParseAssetName(key);
+ removeAssetNames.Add(assetName.BaseName);
+ }
// invalidate translatable assets
string[] invalidated = this
.InvalidateCache((key, type) =>
removeAssetNames.Contains(key)
- || (this.TryParseExplicitLanguageAssetKey(key, out string assetName, out _) && removeAssetNames.Contains(assetName))
+ || removeAssetNames.Contains(this.Coordinator.ParseAssetName(key).BaseName)
)
.Select(p => p.Key)
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
@@ -168,9 +171,10 @@ namespace StardewModdingAPI.Framework.ContentManagers
{
// handle explicit language in asset name
{
- if (this.TryParseExplicitLanguageAssetKey(assetName, out string newAssetName, out LanguageCode newLanguage))
+ IAssetName parsedName = this.Coordinator.ParseAssetName(assetName);
+ if (parsedName.LanguageCode.HasValue)
{
- this.TrackAsset(newAssetName, value, newLanguage, useCache);
+ this.TrackAsset(parsedName.BaseName, value, parsedName.LanguageCode.Value, useCache);
return;
}
}
@@ -238,30 +242,6 @@ namespace StardewModdingAPI.Framework.ContentManagers
}
}
- /// <summary>Parse an asset key that contains an explicit language into its asset name and language, if applicable.</summary>
- /// <param name="rawAsset">The asset key to parse.</param>
- /// <param name="assetName">The asset name without the language code.</param>
- /// <param name="language">The language code removed from the asset name.</param>
- /// <returns>Returns whether the asset key contains an explicit language and was successfully parsed.</returns>
- private bool TryParseExplicitLanguageAssetKey(string rawAsset, out string assetName, out LanguageCode language)
- {
- if (string.IsNullOrWhiteSpace(rawAsset))
- throw new SContentLoadException("The asset key is empty.");
-
- // extract language code
- int splitIndex = rawAsset.LastIndexOf('.');
- if (splitIndex != -1 && this.Coordinator.TryGetLanguageEnum(rawAsset.Substring(splitIndex + 1), out language))
- {
- assetName = rawAsset.Substring(0, splitIndex);
- return true;
- }
-
- // no explicit language code found
- assetName = rawAsset;
- language = this.Language;
- return false;
- }
-
/// <summary>Load the initial asset from the registered <see cref="Loaders"/>.</summary>
/// <param name="info">The basic asset metadata.</param>
/// <returns>Returns the loaded asset metadata, or <c>null</c> if no loader matched.</returns>
@@ -277,7 +257,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
}
catch (Exception ex)
{
- entry.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
+ entry.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{info.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
return false;
}
})
@@ -289,7 +269,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
if (loaders.Length > 1)
{
string[] loaderNames = loaders.Select(p => p.Mod.DisplayName).ToArray();
- this.Monitor.Log($"Multiple mods want to provide the '{info.AssetName}' asset ({string.Join(", ", loaderNames)}), but an asset can't be loaded multiple times. SMAPI will use the default asset instead; uninstall one of the mods to fix this. (Message for modders: you should usually use {typeof(IAssetEditor)} instead to avoid conflicts.)", LogLevel.Warn);
+ this.Monitor.Log($"Multiple mods want to provide the '{info.Name}' asset ({string.Join(", ", loaderNames)}), but an asset can't be loaded multiple times. SMAPI will use the default asset instead; uninstall one of the mods to fix this. (Message for modders: you should usually use {typeof(IAssetEditor)} instead to avoid conflicts.)", LogLevel.Warn);
return null;
}
@@ -300,11 +280,11 @@ namespace StardewModdingAPI.Framework.ContentManagers
try
{
data = loader.Load<T>(info);
- this.Monitor.Log($"{mod.DisplayName} loaded asset '{info.AssetName}'.", LogLevel.Trace);
+ this.Monitor.Log($"{mod.DisplayName} loaded asset '{info.Name}'.", LogLevel.Trace);
}
catch (Exception ex)
{
- mod.LogAsMod($"Mod crashed when loading asset '{info.AssetName}'. SMAPI will use the default asset instead. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
+ mod.LogAsMod($"Mod crashed when loading asset '{info.Name}'. SMAPI will use the default asset instead. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
return null;
}
@@ -349,7 +329,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
}
catch (Exception ex)
{
- mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
+ mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{info.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
continue;
}
@@ -358,22 +338,22 @@ namespace StardewModdingAPI.Framework.ContentManagers
try
{
editor.Edit<T>(asset);
- this.Monitor.Log($"{mod.DisplayName} edited {info.AssetName}.");
+ this.Monitor.Log($"{mod.DisplayName} edited {info.Name}.");
}
catch (Exception ex)
{
- mod.LogAsMod($"Mod crashed when editing asset '{info.AssetName}', which may cause errors in-game. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
+ mod.LogAsMod($"Mod crashed when editing asset '{info.Name}', which may cause errors in-game. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
}
// validate edit
if (asset.Data == null)
{
- mod.LogAsMod($"Mod incorrectly set asset '{info.AssetName}' to a null value; ignoring override.", LogLevel.Warn);
+ mod.LogAsMod($"Mod incorrectly set asset '{info.Name}' to a null value; ignoring override.", LogLevel.Warn);
asset = GetNewData(prevAsset);
}
else if (!(asset.Data is T))
{
- mod.LogAsMod($"Mod incorrectly set asset '{asset.AssetName}' to incompatible type '{asset.Data.GetType()}', expected '{typeof(T)}'; ignoring override.", LogLevel.Warn);
+ mod.LogAsMod($"Mod incorrectly set asset '{asset.Name}' to incompatible type '{asset.Data.GetType()}', expected '{typeof(T)}'; ignoring override.", LogLevel.Warn);
asset = GetNewData(prevAsset);
}
}
@@ -393,21 +373,21 @@ namespace StardewModdingAPI.Framework.ContentManagers
// can't load a null asset
if (data == null)
{
- mod.LogAsMod($"SMAPI blocked asset replacement for '{info.AssetName}': mod incorrectly set asset to a null value.", LogLevel.Error);
+ mod.LogAsMod($"SMAPI blocked asset replacement for '{info.Name}': mod incorrectly set asset to a null value.", LogLevel.Error);
return false;
}
// when replacing a map, the vanilla tilesheets must have the same order and IDs
if (data is Map loadedMap)
{
- TilesheetReference[] vanillaTilesheetRefs = this.Coordinator.GetVanillaTilesheetIds(info.AssetName);
+ TilesheetReference[] vanillaTilesheetRefs = this.Coordinator.GetVanillaTilesheetIds(info.Name.Name);
foreach (TilesheetReference vanillaSheet in vanillaTilesheetRefs)
{
// add missing tilesheet
if (loadedMap.GetTileSheet(vanillaSheet.Id) == null)
{
mod.Monitor.LogOnce("SMAPI fixed maps loaded by this mod to prevent errors. See the log file for details.", LogLevel.Warn);
- this.Monitor.Log($"Fixed broken map replacement: {mod.DisplayName} loaded '{info.AssetName}' without a required tilesheet (id: {vanillaSheet.Id}, source: {vanillaSheet.ImageSource}).");
+ this.Monitor.Log($"Fixed broken map replacement: {mod.DisplayName} loaded '{info.Name}' without a required tilesheet (id: {vanillaSheet.Id}, source: {vanillaSheet.ImageSource}).");
loadedMap.AddTileSheet(new TileSheet(vanillaSheet.Id, loadedMap, vanillaSheet.ImageSource, vanillaSheet.SheetSize, vanillaSheet.TileSize));
}
@@ -417,17 +397,17 @@ namespace StardewModdingAPI.Framework.ContentManagers
{
// only show warning if not farm map
// This is temporary: mods shouldn't do this for any vanilla map, but these are the ones we know will crash. Showing a warning for others instead gives modders time to update their mods, while still simplifying troubleshooting.
- bool isFarmMap = info.AssetNameEquals("Maps/Farm") || info.AssetNameEquals("Maps/Farm_Combat") || info.AssetNameEquals("Maps/Farm_Fishing") || info.AssetNameEquals("Maps/Farm_Foraging") || info.AssetNameEquals("Maps/Farm_FourCorners") || info.AssetNameEquals("Maps/Farm_Island") || info.AssetNameEquals("Maps/Farm_Mining");
+ bool isFarmMap = info.Name.IsEquivalentTo("Maps/Farm") || info.Name.IsEquivalentTo("Maps/Farm_Combat") || info.Name.IsEquivalentTo("Maps/Farm_Fishing") || info.Name.IsEquivalentTo("Maps/Farm_Foraging") || info.Name.IsEquivalentTo("Maps/Farm_FourCorners") || info.Name.IsEquivalentTo("Maps/Farm_Island") || info.Name.IsEquivalentTo("Maps/Farm_Mining");
string reason = $"mod reordered the original tilesheets, which {(isFarmMap ? "would cause a crash" : "often causes crashes")}.\nTechnical details for mod author: Expected order: {string.Join(", ", vanillaTilesheetRefs.Select(p => p.Id))}. See https://stardewvalleywiki.com/Modding:Maps#Tilesheet_order for help.";
SCore.DeprecationManager.PlaceholderWarn("3.8.2", DeprecationLevel.PendingRemoval);
if (isFarmMap)
{
- mod.LogAsMod($"SMAPI blocked '{info.AssetName}' map load: {reason}", LogLevel.Error);
+ mod.LogAsMod($"SMAPI blocked '{info.Name}' map load: {reason}", LogLevel.Error);
return false;
}
- mod.LogAsMod($"SMAPI found an issue with '{info.AssetName}' map load: {reason}", LogLevel.Warn);
+ mod.LogAsMod($"SMAPI found an issue with '{info.Name}' map load: {reason}", LogLevel.Warn);
}
}
}
diff --git a/src/SMAPI/Framework/ContentManagers/IContentManager.cs b/src/SMAPI/Framework/ContentManagers/IContentManager.cs
index d7963305..ba7dbc06 100644
--- a/src/SMAPI/Framework/ContentManagers/IContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/IContentManager.cs
@@ -58,9 +58,6 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <param name="language">The language.</param>
bool IsLoaded(string assetName, LocalizedContentManager.LanguageCode language);
- /// <summary>Get the cached asset keys.</summary>
- IEnumerable<string> GetAssetKeys();
-
/// <summary>Purge matched assets from the cache.</summary>
/// <param name="predicate">Matches the asset keys to invalidate.</param>
/// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
index beb90a5d..50ea6e61 100644
--- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
@@ -80,7 +80,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
{
// normalize key
bool isXnbFile = Path.GetExtension(assetName).ToLower() == ".xnb";
- assetName = this.AssertAndNormalizeAssetName(assetName);
+ IAssetName parsedName = this.Coordinator.ParseAssetName(assetName);
// disable caching
// This is necessary to avoid assets being shared between content managers, which can
@@ -97,21 +97,21 @@ namespace StardewModdingAPI.Framework.ContentManagers
// resolve managed asset key
{
- if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath))
+ if (this.Coordinator.TryParseManagedAssetKey(parsedName.Name, out string contentManagerID, out string relativePath))
{
if (contentManagerID != this.Name)
- throw new SContentLoadException($"Can't load managed asset key '{assetName}' through content manager '{this.Name}' for a different mod.");
- assetName = relativePath;
+ throw new SContentLoadException($"Can't load managed asset key '{parsedName}' through content manager '{this.Name}' for a different mod.");
+ parsedName = this.Coordinator.ParseAssetName(relativePath);
}
}
// get local asset
- SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}");
+ SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading asset '{parsedName}' from {this.Name}: {reasonPhrase}");
T asset;
try
{
// get file
- FileInfo file = this.GetModFile(isXnbFile ? $"{assetName}.xnb" : assetName); // .xnb extension is stripped from asset names passed to the content manager
+ FileInfo file = this.GetModFile(isXnbFile ? $"{parsedName}.xnb" : parsedName.Name); // .xnb extension is stripped from asset names passed to the content manager
if (!file.Exists)
throw GetContentError("the specified path doesn't exist.");
@@ -121,11 +121,11 @@ namespace StardewModdingAPI.Framework.ContentManagers
// XNB file
case ".xnb":
{
- asset = this.RawLoad<T>(assetName, useCache: false);
+ asset = this.RawLoad<T>(parsedName.Name, useCache: false);
if (asset is Map map)
{
- map.assetPath = assetName;
- this.FixTilesheetPaths(map, relativeMapPath: assetName, fixEagerPathPrefixes: true);
+ map.assetPath = parsedName.Name;
+ this.FixTilesheetPaths(map, relativeMapPath: parsedName.Name, fixEagerPathPrefixes: true);
}
}
break;
@@ -173,8 +173,8 @@ namespace StardewModdingAPI.Framework.ContentManagers
// fetch & cache
FormatManager formatManager = FormatManager.Instance;
Map map = formatManager.LoadMap(file.FullName);
- map.assetPath = assetName;
- this.FixTilesheetPaths(map, relativeMapPath: assetName, fixEagerPathPrefixes: false);
+ map.assetPath = parsedName.Name;
+ this.FixTilesheetPaths(map, relativeMapPath: parsedName.Name, fixEagerPathPrefixes: false);
asset = (T)(object)map;
}
break;
@@ -185,11 +185,11 @@ namespace StardewModdingAPI.Framework.ContentManagers
}
catch (Exception ex) when (!(ex is SContentLoadException))
{
- throw new SContentLoadException($"The content manager failed loading content asset '{assetName}' from {this.Name}.", ex);
+ throw new SContentLoadException($"The content manager failed loading content asset '{parsedName}' from {this.Name}.", ex);
}
// track & return asset
- this.TrackAsset(assetName, asset, language, useCache);
+ this.TrackAsset(parsedName.Name, asset, language, useCache);
return asset;
}
@@ -252,16 +252,20 @@ namespace StardewModdingAPI.Framework.ContentManagers
// premultiply pixels
Color[] data = new Color[texture.Width * texture.Height];
texture.GetData(data);
+ bool changed = false;
for (int i = 0; i < data.Length; i++)
{
- var pixel = data[i];
- if (pixel.A == byte.MinValue || pixel.A == byte.MaxValue)
+ Color pixel = data[i];
+ if (pixel.A is (byte.MinValue or byte.MaxValue))
continue; // no need to change fully transparent/opaque pixels
data[i] = new Color(pixel.R * pixel.A / byte.MaxValue, pixel.G * pixel.A / byte.MaxValue, pixel.B * pixel.A / byte.MaxValue, pixel.A); // slower version: Color.FromNonPremultiplied(data[i].ToVector4())
+ changed = true;
}
- texture.SetData(data);
+ if (changed)
+ texture.SetData(data);
+
return texture;
}
diff --git a/src/SMAPI/Framework/Events/ManagedEvent.cs b/src/SMAPI/Framework/Events/ManagedEvent.cs
index fa20a079..f48c3aeb 100644
--- a/src/SMAPI/Framework/Events/ManagedEvent.cs
+++ b/src/SMAPI/Framework/Events/ManagedEvent.cs
@@ -21,7 +21,7 @@ namespace StardewModdingAPI.Framework.Events
private readonly List<ManagedEventHandler<TEventArgs>> Handlers = new List<ManagedEventHandler<TEventArgs>>();
/// <summary>A cached snapshot of <see cref="Handlers"/>, or <c>null</c> to rebuild it next raise.</summary>
- private ManagedEventHandler<TEventArgs>[] CachedHandlers = new ManagedEventHandler<TEventArgs>[0];
+ private ManagedEventHandler<TEventArgs>[] CachedHandlers = Array.Empty<ManagedEventHandler<TEventArgs>>();
/// <summary>The total number of event handlers registered for this events, regardless of whether they're still registered.</summary>
private int RegistrationIndex;
diff --git a/src/SMAPI/Framework/Input/GamePadStateBuilder.cs b/src/SMAPI/Framework/Input/GamePadStateBuilder.cs
index b0bb7f80..3a99214f 100644
--- a/src/SMAPI/Framework/Input/GamePadStateBuilder.cs
+++ b/src/SMAPI/Framework/Input/GamePadStateBuilder.cs
@@ -104,10 +104,10 @@ namespace StardewModdingAPI.Framework.Input
this.LeftStickPos.Y = isDown ? 1 : 0;
break;
case SButton.LeftThumbstickDown:
- this.LeftStickPos.Y = isDown ? 1 : 0;
+ this.LeftStickPos.Y = isDown ? -1 : 0;
break;
case SButton.LeftThumbstickLeft:
- this.LeftStickPos.X = isDown ? 1 : 0;
+ this.LeftStickPos.X = isDown ? -1 : 0;
break;
case SButton.LeftThumbstickRight:
this.LeftStickPos.X = isDown ? 1 : 0;
@@ -118,10 +118,10 @@ namespace StardewModdingAPI.Framework.Input
this.RightStickPos.Y = isDown ? 1 : 0;
break;
case SButton.RightThumbstickDown:
- this.RightStickPos.Y = isDown ? 1 : 0;
+ this.RightStickPos.Y = isDown ? -1 : 0;
break;
case SButton.RightThumbstickLeft:
- this.RightStickPos.X = isDown ? 1 : 0;
+ this.RightStickPos.X = isDown ? -1 : 0;
break;
case SButton.RightThumbstickRight:
this.RightStickPos.X = isDown ? 1 : 0;
diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs
index bfca2264..a01248a8 100644
--- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs
@@ -129,7 +129,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
{
string actualKey = this.GetActualAssetKey(key, ContentSource.GameContent);
this.Monitor.Log($"Requested cache invalidation for '{actualKey}'.", LogLevel.Trace);
- return this.ContentCore.InvalidateCache(asset => asset.AssetNameEquals(actualKey)).Any();
+ return this.ContentCore.InvalidateCache(asset => asset.Name.IsEquivalentTo(actualKey)).Any();
}
/// <inheritdoc />
@@ -153,7 +153,8 @@ namespace StardewModdingAPI.Framework.ModHelpers
throw new ArgumentNullException(nameof(data), "Can't get a patch helper for a null value.");
assetName ??= $"temp/{Guid.NewGuid():N}";
- return new AssetDataForObject(this.CurrentLocale, assetName, data, this.NormalizeAssetName);
+
+ return new AssetDataForObject(this.CurrentLocale, this.ContentCore.ParseAssetName(assetName), data, this.NormalizeAssetName);
}
diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs
index 10bf9f94..9174aea6 100644
--- a/src/SMAPI/Framework/Models/SConfig.cs
+++ b/src/SMAPI/Framework/Models/SConfig.cs
@@ -92,9 +92,9 @@ namespace StardewModdingAPI.Framework.Models
custom[pair.Key] = value;
}
- HashSet<string> curSuppressUpdateChecks = new HashSet<string>(this.SuppressUpdateChecks ?? new string[0], StringComparer.OrdinalIgnoreCase);
+ HashSet<string> curSuppressUpdateChecks = new HashSet<string>(this.SuppressUpdateChecks ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase);
if (SConfig.DefaultSuppressUpdateChecks.Count != curSuppressUpdateChecks.Count || SConfig.DefaultSuppressUpdateChecks.Any(p => !curSuppressUpdateChecks.Contains(p)))
- custom[nameof(this.SuppressUpdateChecks)] = "[" + string.Join(", ", this.SuppressUpdateChecks ?? new string[0]) + "]";
+ custom[nameof(this.SuppressUpdateChecks)] = "[" + string.Join(", ", this.SuppressUpdateChecks ?? Array.Empty<string>()) + "]";
return custom;
}
diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs
index 58537031..8f810644 100644
--- a/src/SMAPI/Framework/SCore.cs
+++ b/src/SMAPI/Framework/SCore.cs
@@ -43,6 +43,7 @@ using StardewModdingAPI.Toolkit.Serialization;
using StardewModdingAPI.Toolkit.Utilities;
using StardewModdingAPI.Utilities;
using StardewValley;
+using StardewValley.Menus;
using xTile.Display;
using MiniMonoModHotfix = MonoMod.Utils.MiniMonoModHotfix;
using PathUtilities = StardewModdingAPI.Toolkit.Utilities.PathUtilities;
@@ -123,6 +124,9 @@ namespace StardewModdingAPI.Framework
/// <summary>Whether post-game-startup initialization has been performed.</summary>
private bool IsInitialized;
+ /// <summary>Whether the game has initialized for any custom languages from <c>Data/AdditionalLanguages</c>.</summary>
+ private bool AreCustomLanguagesInitialized;
+
/// <summary>Whether the player just returned to the title screen.</summary>
public bool JustReturnedToTitle { get; set; }
@@ -988,6 +992,13 @@ namespace StardewModdingAPI.Framework
// preloaded
if (Context.IsSaveLoaded && Context.LoadStage != LoadStage.Loaded && Context.LoadStage != LoadStage.Ready && Game1.dayOfMonth != 0)
this.OnLoadStageChanged(LoadStage.Loaded);
+
+ // additional languages initialized
+ if (!this.AreCustomLanguagesInitialized && TitleMenu.ticksUntilLanguageLoad < 0)
+ {
+ this.AreCustomLanguagesInitialized = true;
+ this.ContentCore.OnAdditionalLanguagesInitialized();
+ }
}
/*********
@@ -1246,7 +1257,7 @@ namespace StardewModdingAPI.Framework
{
using RegistryKey key = Registry.LocalMachine.OpenSubKey(registryKey);
if (key == null)
- return new string[0];
+ return Array.Empty<string>();
return key
.GetSubKeyNames()
@@ -1574,13 +1585,13 @@ namespace StardewModdingAPI.Framework
/// <param name="list">A list of interceptors to update for the change.</param>
private void OnAssetInterceptorsChanged<T>(IModMetadata mod, IEnumerable<T> added, IEnumerable<T> removed, IList<ModLinked<T>> list)
{
- foreach (T interceptor in added ?? new T[0])
+ foreach (T interceptor in added ?? Array.Empty<T>())
{
this.ReloadAssetInterceptorsQueue.Add(new AssetInterceptorChange(mod, interceptor, wasAdded: true));
list.Add(new ModLinked<T>(mod, interceptor));
}
- foreach (T interceptor in removed ?? new T[0])
+ foreach (T interceptor in removed ?? Array.Empty<T>())
{
this.ReloadAssetInterceptorsQueue.Add(new AssetInterceptorChange(mod, interceptor, wasAdded: false));
foreach (ModLinked<T> entry in list.Where(p => p.Mod == mod && object.ReferenceEquals(p.Data, interceptor)).ToArray())
diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/ImmutableCollectionWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/ImmutableCollectionWatcher.cs
index 30e6274f..009e0282 100644
--- a/src/SMAPI/Framework/StateTracking/FieldWatchers/ImmutableCollectionWatcher.cs
+++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/ImmutableCollectionWatcher.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
@@ -16,10 +17,10 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
public bool IsChanged { get; } = false;
/// <summary>The values added since the last reset.</summary>
- public IEnumerable<TValue> Added { get; } = new TValue[0];
+ public IEnumerable<TValue> Added { get; } = Array.Empty<TValue>();
/// <summary>The values removed since the last reset.</summary>
- public IEnumerable<TValue> Removed { get; } = new TValue[0];
+ public IEnumerable<TValue> Removed { get; } = Array.Empty<TValue>();
/*********
diff --git a/src/SMAPI/Framework/StateTracking/LocationTracker.cs b/src/SMAPI/Framework/StateTracking/LocationTracker.cs
index 6d3a62bb..748e4ecc 100644
--- a/src/SMAPI/Framework/StateTracking/LocationTracker.cs
+++ b/src/SMAPI/Framework/StateTracking/LocationTracker.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Xna.Framework;
@@ -84,7 +85,7 @@ namespace StardewModdingAPI.Framework.StateTracking
this.FurnitureWatcher
});
- this.UpdateChestWatcherList(added: location.Objects.Pairs, removed: new KeyValuePair<Vector2, SObject>[0]);
+ this.UpdateChestWatcherList(added: location.Objects.Pairs, removed: Array.Empty<KeyValuePair<Vector2, SObject>>());
}
/// <summary>Update the current value if needed.</summary>
diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs
index 0908b02a..72f45a87 100644
--- a/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs
+++ b/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs
@@ -14,7 +14,7 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
** Fields
*********/
/// <summary>An empty item list diff.</summary>
- private readonly SnapshotItemListDiff EmptyItemListDiff = new SnapshotItemListDiff(new Item[0], new Item[0], new ItemStackSizeChange[0]);
+ private readonly SnapshotItemListDiff EmptyItemListDiff = new SnapshotItemListDiff(Array.Empty<Item>(), Array.Empty<Item>(), Array.Empty<ItemStackSizeChange>());
/*********
diff --git a/src/SMAPI/Framework/TemporaryHacks/MiniMonoModHotfix.cs b/src/SMAPI/Framework/TemporaryHacks/MiniMonoModHotfix.cs
index 9d63ab2c..173438f1 100644
--- a/src/SMAPI/Framework/TemporaryHacks/MiniMonoModHotfix.cs
+++ b/src/SMAPI/Framework/TemporaryHacks/MiniMonoModHotfix.cs
@@ -24,7 +24,7 @@ namespace MonoMod.Utils
{
// .NET Framework can break member ordering if using Module.Resolve* on certain members.
- private static object[] _NoArgs = new object[0];
+ private static object[] _NoArgs = Array.Empty<object>();
private static object[] _CacheGetterArgs = { /* MemberListType.All */ 0, /* name apparently always null? */ null };
private static Type t_RuntimeModule =
diff --git a/src/SMAPI/IAssetInfo.cs b/src/SMAPI/IAssetInfo.cs
index 6cdf01ee..6ac8358d 100644
--- a/src/SMAPI/IAssetInfo.cs
+++ b/src/SMAPI/IAssetInfo.cs
@@ -11,7 +11,11 @@ namespace StardewModdingAPI
/// <summary>The content's locale code, if the content is localized.</summary>
string Locale { get; }
+ /// <summary>The asset name being read.</summary>
+ public IAssetName Name { get; }
+
/// <summary>The normalized asset name being read. The format may change between platforms; see <see cref="AssetNameEquals"/> to compare with a known path.</summary>
+ [Obsolete($"Use {nameof(Name)} instead.")]
string AssetName { get; }
/// <summary>The content data type.</summary>
@@ -23,6 +27,7 @@ namespace StardewModdingAPI
*********/
/// <summary>Get whether the asset name being loaded matches a given name after normalization.</summary>
/// <param name="path">The expected asset path, relative to the game's content folder and without the .xnb extension or locale suffix (like 'Data\ObjectInformation').</param>
+ [Obsolete($"Use {nameof(Name)}.{nameof(IAssetName.IsEquivalentTo)} instead.")]
bool AssetNameEquals(string path);
}
}
diff --git a/src/SMAPI/IAssetName.cs b/src/SMAPI/IAssetName.cs
new file mode 100644
index 00000000..a5bfea93
--- /dev/null
+++ b/src/SMAPI/IAssetName.cs
@@ -0,0 +1,44 @@
+using System;
+using StardewValley;
+
+namespace StardewModdingAPI
+{
+ /// <summary>The name for an asset loaded through the content pipeline.</summary>
+ public interface IAssetName : IEquatable<IAssetName>
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The full normalized asset name, including the locale if applicable (like <c>Data/Achievements.fr-FR</c>).</summary>
+ string Name { get; }
+
+ /// <summary>The base asset name without the locale code.</summary>
+ string BaseName { get; }
+
+ /// <summary>The locale code specified in the <see cref="Name"/>, if it's a valid code recognized by the game content.</summary>
+ string LocaleCode { get; }
+
+ /// <summary>The language code matching the <see cref="LocaleCode"/>, if applicable.</summary>
+ LocalizedContentManager.LanguageCode? LanguageCode { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Get whether the given asset name is equivalent, ignoring capitalization and formatting.</summary>
+ /// <param name="assetName">The asset name to compare this instance to.</param>
+ /// <param name="useBaseName">Whether to compare the given name with the <see cref="BaseName"/> (if true) or <see cref="Name"/> (if false). This has no effect on any locale included in the given <paramref name="assetName"/>.</param>
+ bool IsEquivalentTo(string assetName, bool useBaseName = false);
+
+ /// <summary>Get whether the asset name starts with the given value, ignoring capitalization and formatting. This can be used with a trailing slash to test for an asset folder, like <c>Data/</c>.</summary>
+ /// <param name="prefix">The prefix to match.</param>
+ /// <param name="allowPartialWord">Whether to match if the prefix occurs mid-word, so <c>Data/AchievementsToIgnore</c> matches prefix <c>Data/Achievements</c>. If this is false, the prefix only matches if the asset name starts with the prefix followed by a non-alphanumeric character (including <c>.</c>, <c>/</c>, or <c>\\</c>) or the end of string.</param>
+ /// <param name="allowSubfolder">Whether to match the prefix if there's a subfolder path after it, so <c>Data/Achievements/Example</c> matches prefix <c>Data/Achievements</c>. If this is false, the prefix only matches if the asset name has no <c>/</c> or <c>\\</c> characters after the prefix.</param>
+ bool StartsWith(string prefix, bool allowPartialWord = true, bool allowSubfolder = true);
+
+ /// <summary>Get whether the asset is directly within the given asset path.</summary>
+ /// <remarks>For example, <c>Characters/Dialogue/Abigail</c> is directly under <c>Characters/Dialogue</c> but not <c>Characters</c> or <c>Characters/Dialogue/Ab</c>. To allow sub-paths, use <see cref="StartsWith"/> instead.</remarks>
+ /// <param name="assetFolder">The asset path to check. This doesn't need a trailing slash.</param>
+ bool IsDirectlyUnderPath(string assetFolder);
+ }
+}
diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs
index 552bc000..e7fac578 100644
--- a/src/SMAPI/Metadata/CoreAssetPropagator.cs
+++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs
@@ -87,22 +87,22 @@ namespace StardewModdingAPI.Metadata
/// <param name="ignoreWorld">Whether the in-game world is fully unloaded (e.g. on the title screen), so there's no need to propagate changes into the world.</param>
/// <param name="propagatedAssets">A lookup of asset names to whether they've been propagated.</param>
/// <param name="updatedNpcWarps">Whether the NPC pathfinding cache was reloaded.</param>
- public void Propagate(IDictionary<string, Type> assets, bool ignoreWorld, out IDictionary<string, bool> propagatedAssets, out bool updatedNpcWarps)
+ public void Propagate(IDictionary<IAssetName, Type> assets, bool ignoreWorld, out IDictionary<IAssetName, bool> propagatedAssets, out bool updatedNpcWarps)
{
// group into optimized lists
var buckets = assets.GroupBy(p =>
{
- if (this.IsInFolder(p.Key, "Characters") || this.IsInFolder(p.Key, "Characters\\Monsters"))
+ if (p.Key.IsDirectlyUnderPath("Characters") || p.Key.IsDirectlyUnderPath("Characters/Monsters"))
return AssetBucket.Sprite;
- if (this.IsInFolder(p.Key, "Portraits"))
+ if (p.Key.IsDirectlyUnderPath("Portraits"))
return AssetBucket.Portrait;
return AssetBucket.Other;
});
// reload assets
- propagatedAssets = assets.ToDictionary(p => p.Key, _ => false, StringComparer.OrdinalIgnoreCase);
+ propagatedAssets = assets.ToDictionary(p => p.Key, _ => false);
updatedNpcWarps = false;
foreach (var bucket in buckets)
{
@@ -149,16 +149,16 @@ namespace StardewModdingAPI.Metadata
** Private methods
*********/
/// <summary>Reload one of the game's core assets (if applicable).</summary>
- /// <param name="key">The asset key to reload.</param>
+ /// <param name="assetName">The asset name to reload.</param>
/// <param name="type">The asset type to reload.</param>
/// <param name="ignoreWorld">Whether the in-game world is fully unloaded (e.g. on the title screen), so there's no need to propagate changes into the world.</param>
/// <param name="changedWarps">Whether any map warps were changed as part of this propagation.</param>
/// <returns>Returns whether an asset was loaded. The return value may be true or false, or a non-null value for true.</returns>
[SuppressMessage("ReSharper", "StringLiteralTypo", Justification = "These deliberately match the asset names.")]
- private bool PropagateOther(string key, Type type, bool ignoreWorld, out bool changedWarps)
+ private bool PropagateOther(IAssetName assetName, Type type, bool ignoreWorld, out bool changedWarps)
{
var content = this.MainContentManager;
- key = this.AssertAndNormalizeAssetName(key);
+ string key = assetName.Name;
changedWarps = false;
/****
@@ -170,7 +170,7 @@ namespace StardewModdingAPI.Metadata
{
foreach (TileSheet tilesheet in Game1.currentLocation.map.TileSheets)
{
- if (this.NormalizeAssetNameIgnoringEmpty(tilesheet.ImageSource) == key)
+ if (assetName.IsEquivalentTo(tilesheet.ImageSource))
Game1.mapDisplayDevice.LoadTileSheet(tilesheet);
}
}
@@ -188,7 +188,7 @@ namespace StardewModdingAPI.Metadata
{
GameLocation location = info.Location;
- if (!string.IsNullOrWhiteSpace(location.mapPath.Value) && this.NormalizeAssetNameIgnoringEmpty(location.mapPath.Value) == key)
+ if (assetName.IsEquivalentTo(location.mapPath.Value))
{
static ISet<string> GetWarpSet(GameLocation location)
{
@@ -213,25 +213,24 @@ namespace StardewModdingAPI.Metadata
/****
** Propagate by key
****/
- Reflector reflection = this.Reflection;
- switch (key.ToLower().Replace("/", "\\")) // normalized key so we can compare statically
+ switch (assetName.Name.ToLower().Replace("\\", "/")) // normalized key so we can compare statically
{
/****
** Animals
****/
- case "animals\\horse":
- return !ignoreWorld && this.ReloadPetOrHorseSprites<Horse>(content, key);
+ case "animals/horse":
+ return !ignoreWorld && this.ReloadPetOrHorseSprites<Horse>(content, assetName);
/****
** Buildings
****/
- case "buildings\\houses": // Farm
+ case "buildings/houses": // Farm
Farm.houseTextures = this.LoadAndDisposeIfNeeded(Farm.houseTextures, key);
return true;
- case "buildings\\houses_paintmask": // Farm
+ case "buildings/houses_paintmask": // Farm
{
- bool removedFromCache = this.RemoveFromPaintMaskCache(key);
+ bool removedFromCache = this.RemoveFromPaintMaskCache(assetName);
Farm farm = Game1.getFarm();
farm?.ApplyHousePaint();
@@ -242,149 +241,149 @@ namespace StardewModdingAPI.Metadata
/****
** Content\Characters\Farmer
****/
- case "characters\\farmer\\accessories": // Game1.LoadContent
+ case "characters/farmer/accessories": // Game1.LoadContent
FarmerRenderer.accessoriesTexture = this.LoadAndDisposeIfNeeded(FarmerRenderer.accessoriesTexture, key);
return true;
- case "characters\\farmer\\farmer_base": // Farmer
- case "characters\\farmer\\farmer_base_bald":
- case "characters\\farmer\\farmer_girl_base":
- case "characters\\farmer\\farmer_girl_base_bald":
- return !ignoreWorld && this.ReloadPlayerSprites(key);
+ case "characters/farmer/farmer_base": // Farmer
+ case "characters/farmer/farmer_base_bald":
+ case "characters/farmer/farmer_girl_base":
+ case "characters/farmer/farmer_girl_base_bald":
+ return !ignoreWorld && this.ReloadPlayerSprites(assetName);
- case "characters\\farmer\\hairstyles": // Game1.LoadContent
+ case "characters/farmer/hairstyles": // Game1.LoadContent
FarmerRenderer.hairStylesTexture = this.LoadAndDisposeIfNeeded(FarmerRenderer.hairStylesTexture, key);
return true;
- case "characters\\farmer\\hats": // Game1.LoadContent
+ case "characters/farmer/hats": // Game1.LoadContent
FarmerRenderer.hatsTexture = this.LoadAndDisposeIfNeeded(FarmerRenderer.hatsTexture, key);
return true;
- case "characters\\farmer\\pants": // Game1.LoadContent
+ case "characters/farmer/pants": // Game1.LoadContent
FarmerRenderer.pantsTexture = this.LoadAndDisposeIfNeeded(FarmerRenderer.pantsTexture, key);
return true;
- case "characters\\farmer\\shirts": // Game1.LoadContent
+ case "characters/farmer/shirts": // Game1.LoadContent
FarmerRenderer.shirtsTexture = this.LoadAndDisposeIfNeeded(FarmerRenderer.shirtsTexture, key);
return true;
/****
** Content\Data
****/
- case "data\\achievements": // Game1.LoadContent
+ case "data/achievements": // Game1.LoadContent
Game1.achievements = content.Load<Dictionary<int, string>>(key);
return true;
- case "data\\bigcraftablesinformation": // Game1.LoadContent
+ case "data/bigcraftablesinformation": // Game1.LoadContent
Game1.bigCraftablesInformation = content.Load<Dictionary<int, string>>(key);
return true;
- case "data\\clothinginformation": // Game1.LoadContent
+ case "data/clothinginformation": // Game1.LoadContent
Game1.clothingInformation = content.Load<Dictionary<int, string>>(key);
return true;
- case "data\\concessions": // MovieTheater.GetConcessions
+ case "data/concessions": // MovieTheater.GetConcessions
MovieTheater.ClearCachedLocalizedData();
return true;
- case "data\\concessiontastes": // MovieTheater.GetConcessionTasteForCharacter
+ case "data/concessiontastes": // MovieTheater.GetConcessionTasteForCharacter
this.Reflection
.GetField<List<ConcessionTaste>>(typeof(MovieTheater), "_concessionTastes")
.SetValue(content.Load<List<ConcessionTaste>>(key));
return true;
- case "data\\cookingrecipes": // CraftingRecipe.InitShared
+ case "data/cookingrecipes": // CraftingRecipe.InitShared
CraftingRecipe.cookingRecipes = content.Load<Dictionary<string, string>>(key);
return true;
- case "data\\craftingrecipes": // CraftingRecipe.InitShared
+ case "data/craftingrecipes": // CraftingRecipe.InitShared
CraftingRecipe.craftingRecipes = content.Load<Dictionary<string, string>>(key);
return true;
- case "data\\farmanimals": // FarmAnimal constructor
+ case "data/farmanimals": // FarmAnimal constructor
return !ignoreWorld && this.ReloadFarmAnimalData();
- case "data\\hairdata": // Farmer.GetHairStyleMetadataFile
+ case "data/hairdata": // Farmer.GetHairStyleMetadataFile
return this.ReloadHairData();
- case "data\\movies": // MovieTheater.GetMovieData
- case "data\\moviesreactions": // MovieTheater.GetMovieReactions
+ case "data/movies": // MovieTheater.GetMovieData
+ case "data/moviesreactions": // MovieTheater.GetMovieReactions
MovieTheater.ClearCachedLocalizedData();
return true;
- case "data\\npcdispositions": // NPC constructor
- return !ignoreWorld && this.ReloadNpcDispositions(content, key);
+ case "data/npcdispositions": // NPC constructor
+ return !ignoreWorld && this.ReloadNpcDispositions(content, assetName);
- case "data\\npcgifttastes": // Game1.LoadContent
+ case "data/npcgifttastes": // Game1.LoadContent
Game1.NPCGiftTastes = content.Load<Dictionary<string, string>>(key);
return true;
- case "data\\objectcontexttags": // Game1.LoadContent
+ case "data/objectcontexttags": // Game1.LoadContent
Game1.objectContextTags = content.Load<Dictionary<string, string>>(key);
return true;
- case "data\\objectinformation": // Game1.LoadContent
+ case "data/objectinformation": // Game1.LoadContent
Game1.objectInformation = content.Load<Dictionary<int, string>>(key);
return true;
/****
** Content\Fonts
****/
- case "fonts\\spritefont1": // Game1.LoadContent
+ case "fonts/spritefont1": // Game1.LoadContent
Game1.dialogueFont = content.Load<SpriteFont>(key);
return true;
- case "fonts\\smallfont": // Game1.LoadContent
+ case "fonts/smallfont": // Game1.LoadContent
Game1.smallFont = content.Load<SpriteFont>(key);
return true;
- case "fonts\\tinyfont": // Game1.LoadContent
+ case "fonts/tinyfont": // Game1.LoadContent
Game1.tinyFont = content.Load<SpriteFont>(key);
return true;
- case "fonts\\tinyfontborder": // Game1.LoadContent
+ case "fonts/tinyfontborder": // Game1.LoadContent
Game1.tinyFontBorder = content.Load<SpriteFont>(key);
return true;
/****
** Content\LooseSprites\Lighting
****/
- case "loosesprites\\lighting\\greenlight": // Game1.LoadContent
+ case "loosesprites/lighting/greenlight": // Game1.LoadContent
Game1.cauldronLight = content.Load<Texture2D>(key);
return true;
- case "loosesprites\\lighting\\indoorwindowlight": // Game1.LoadContent
+ case "loosesprites/lighting/indoorwindowlight": // Game1.LoadContent
Game1.indoorWindowLight = content.Load<Texture2D>(key);
return true;
- case "loosesprites\\lighting\\lantern": // Game1.LoadContent
+ case "loosesprites/lighting/lantern": // Game1.LoadContent
Game1.lantern = content.Load<Texture2D>(key);
return true;
- case "loosesprites\\lighting\\sconcelight": // Game1.LoadContent
+ case "loosesprites/lighting/sconcelight": // Game1.LoadContent
Game1.sconceLight = content.Load<Texture2D>(key);
return true;
- case "loosesprites\\lighting\\windowlight": // Game1.LoadContent
+ case "loosesprites/lighting/windowlight": // Game1.LoadContent
Game1.windowLight = content.Load<Texture2D>(key);
return true;
/****
** Content\LooseSprites
****/
- case "loosesprites\\birds": // Game1.LoadContent
+ case "loosesprites/birds": // Game1.LoadContent
Game1.birdsSpriteSheet = content.Load<Texture2D>(key);
return true;
- case "loosesprites\\concessions": // Game1.LoadContent
+ case "loosesprites/concessions": // Game1.LoadContent
Game1.concessionsSpriteSheet = content.Load<Texture2D>(key);
return true;
- case "loosesprites\\controllermaps": // Game1.LoadContent
+ case "loosesprites/controllermaps": // Game1.LoadContent
Game1.controllerMaps = content.Load<Texture2D>(key);
return true;
- case "loosesprites\\cursors": // Game1.LoadContent
+ case "loosesprites/cursors": // Game1.LoadContent
Game1.mouseCursors = content.Load<Texture2D>(key);
foreach (DayTimeMoneyBox menu in Game1.onScreenMenus.OfType<DayTimeMoneyBox>())
{
@@ -393,59 +392,59 @@ namespace StardewModdingAPI.Metadata
}
if (!ignoreWorld)
- this.ReloadDoorSprites(content, key);
+ this.ReloadDoorSprites(content, assetName);
return true;
- case "loosesprites\\cursors2": // Game1.LoadContent
+ case "loosesprites/cursors2": // Game1.LoadContent
Game1.mouseCursors2 = content.Load<Texture2D>(key);
return true;
- case "loosesprites\\daybg": // Game1.LoadContent
+ case "loosesprites/daybg": // Game1.LoadContent
Game1.daybg = content.Load<Texture2D>(key);
return true;
- case "loosesprites\\font_bold": // Game1.LoadContent
+ case "loosesprites/font_bold": // Game1.LoadContent
SpriteText.spriteTexture = content.Load<Texture2D>(key);
return true;
- case "loosesprites\\font_colored": // Game1.LoadContent
+ case "loosesprites/font_colored": // Game1.LoadContent
SpriteText.coloredTexture = content.Load<Texture2D>(key);
return true;
- case "loosesprites\\giftbox": // Game1.LoadContent
+ case "loosesprites/giftbox": // Game1.LoadContent
Game1.giftboxTexture = content.Load<Texture2D>(key);
return true;
- case "loosesprites\\nightbg": // Game1.LoadContent
+ case "loosesprites/nightbg": // Game1.LoadContent
Game1.nightbg = content.Load<Texture2D>(key);
return true;
- case "loosesprites\\shadow": // Game1.LoadContent
+ case "loosesprites/shadow": // Game1.LoadContent
Game1.shadowTexture = content.Load<Texture2D>(key);
return true;
- case "loosesprites\\suspensionbridge": // SuspensionBridge constructor
- return !ignoreWorld && this.ReloadSuspensionBridges(content, key);
+ case "loosesprites/suspensionbridge": // SuspensionBridge constructor
+ return !ignoreWorld && this.ReloadSuspensionBridges(content, assetName);
/****
** Content\Maps
****/
- case "maps\\menutiles": // Game1.LoadContent
+ case "maps/menutiles": // Game1.LoadContent
Game1.menuTexture = content.Load<Texture2D>(key);
return true;
- case "maps\\menutilesuncolored": // Game1.LoadContent
+ case "maps/menutilesuncolored": // Game1.LoadContent
Game1.uncoloredMenuTexture = content.Load<Texture2D>(key);
return true;
- case "maps\\springobjects": // Game1.LoadContent
+ case "maps/springobjects": // Game1.LoadContent
Game1.objectSpriteSheet = content.Load<Texture2D>(key);
return true;
/****
** Content\Minigames
****/
- case "minigames\\clouds": // TitleMenu
+ case "minigames/clouds": // TitleMenu
{
if (Game1.activeClickableMenu is TitleMenu titleMenu)
{
@@ -455,128 +454,128 @@ namespace StardewModdingAPI.Metadata
}
return false;
- case "minigames\\titlebuttons": // TitleMenu
- return this.ReloadTitleButtons(content, key);
+ case "minigames/titlebuttons": // TitleMenu
+ return this.ReloadTitleButtons(content, assetName);
/****
** Content\Strings
****/
- case "strings\\stringsfromcsfiles":
+ case "strings/stringsfromcsfiles":
return this.ReloadStringsFromCsFiles(content);
/****
** Content\TileSheets
****/
- case "tilesheets\\animations": // Game1.LoadContent
+ case "tilesheets/animations": // Game1.LoadContent
Game1.animations = content.Load<Texture2D>(key);
return true;
- case "tilesheets\\buffsicons": // Game1.LoadContent
+ case "tilesheets/buffsicons": // Game1.LoadContent
Game1.buffsIcons = content.Load<Texture2D>(key);
return true;
- case "tilesheets\\bushes": // new Bush()
+ case "tilesheets/bushes": // new Bush()
Bush.texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key));
return true;
- case "tilesheets\\chairtiles": // Game1.LoadContent
- return this.ReloadChairTiles(content, key, ignoreWorld);
+ case "tilesheets/chairtiles": // Game1.LoadContent
+ return this.ReloadChairTiles(content, assetName, ignoreWorld);
- case "tilesheets\\craftables": // Game1.LoadContent
+ case "tilesheets/craftables": // Game1.LoadContent
Game1.bigCraftableSpriteSheet = content.Load<Texture2D>(key);
return true;
- case "tilesheets\\critters": // Critter constructor
- return !ignoreWorld && this.ReloadCritterTextures(content, key) > 0;
+ case "tilesheets/critters": // Critter constructor
+ return !ignoreWorld && this.ReloadCritterTextures(content, assetName) > 0;
- case "tilesheets\\crops": // Game1.LoadContent
+ case "tilesheets/crops": // Game1.LoadContent
Game1.cropSpriteSheet = content.Load<Texture2D>(key);
return true;
- case "tilesheets\\debris": // Game1.LoadContent
+ case "tilesheets/debris": // Game1.LoadContent
Game1.debrisSpriteSheet = content.Load<Texture2D>(key);
return true;
- case "tilesheets\\emotes": // Game1.LoadContent
+ case "tilesheets/emotes": // Game1.LoadContent
Game1.emoteSpriteSheet = content.Load<Texture2D>(key);
return true;
- case "tilesheets\\fruittrees": // FruitTree
+ case "tilesheets/fruittrees": // FruitTree
FruitTree.texture = content.Load<Texture2D>(key);
return true;
- case "tilesheets\\furniture": // Game1.LoadContent
+ case "tilesheets/furniture": // Game1.LoadContent
Furniture.furnitureTexture = content.Load<Texture2D>(key);
return true;
- case "tilesheets\\furniturefront": // Game1.LoadContent
+ case "tilesheets/furniturefront": // Game1.LoadContent
Furniture.furnitureFrontTexture = content.Load<Texture2D>(key);
return true;
- case "tilesheets\\projectiles": // Game1.LoadContent
+ case "tilesheets/projectiles": // Game1.LoadContent
Projectile.projectileSheet = content.Load<Texture2D>(key);
return true;
- case "tilesheets\\rain": // Game1.LoadContent
+ case "tilesheets/rain": // Game1.LoadContent
Game1.rainTexture = content.Load<Texture2D>(key);
return true;
- case "tilesheets\\tools": // Game1.ResetToolSpriteSheet
+ case "tilesheets/tools": // Game1.ResetToolSpriteSheet
Game1.ResetToolSpriteSheet();
return true;
- case "tilesheets\\weapons": // Game1.LoadContent
+ case "tilesheets/weapons": // Game1.LoadContent
Tool.weaponsTexture = content.Load<Texture2D>(key);
return true;
/****
** Content\TerrainFeatures
****/
- case "terrainfeatures\\flooring": // from Flooring
+ case "terrainfeatures/flooring": // from Flooring
Flooring.floorsTexture = content.Load<Texture2D>(key);
return true;
- case "terrainfeatures\\flooring_winter": // from Flooring
+ case "terrainfeatures/flooring_winter": // from Flooring
Flooring.floorsTextureWinter = content.Load<Texture2D>(key);
return true;
- case "terrainfeatures\\grass": // from Grass
- return !ignoreWorld && this.ReloadGrassTextures(content, key);
+ case "terrainfeatures/grass": // from Grass
+ return !ignoreWorld && this.ReloadGrassTextures(content, assetName);
- case "terrainfeatures\\hoedirt": // from HoeDirt
+ case "terrainfeatures/hoedirt": // from HoeDirt
HoeDirt.lightTexture = content.Load<Texture2D>(key);
return true;
- case "terrainfeatures\\hoedirtdark": // from HoeDirt
+ case "terrainfeatures/hoedirtdark": // from HoeDirt
HoeDirt.darkTexture = content.Load<Texture2D>(key);
return true;
- case "terrainfeatures\\hoedirtsnow": // from HoeDirt
+ case "terrainfeatures/hoedirtsnow": // from HoeDirt
HoeDirt.snowTexture = content.Load<Texture2D>(key);
return true;
- case "terrainfeatures\\mushroom_tree": // from Tree
- return !ignoreWorld && this.ReloadTreeTextures(content, key, Tree.mushroomTree);
+ case "terrainfeatures/mushroom_tree": // from Tree
+ return !ignoreWorld && this.ReloadTreeTextures(content, assetName, Tree.mushroomTree);
- case "terrainfeatures\\tree_palm": // from Tree
- return !ignoreWorld && this.ReloadTreeTextures(content, key, Tree.palmTree);
+ case "terrainfeatures/tree_palm": // from Tree
+ return !ignoreWorld && this.ReloadTreeTextures(content, assetName, Tree.palmTree);
- case "terrainfeatures\\tree1_fall": // from Tree
- case "terrainfeatures\\tree1_spring": // from Tree
- case "terrainfeatures\\tree1_summer": // from Tree
- case "terrainfeatures\\tree1_winter": // from Tree
- return !ignoreWorld && this.ReloadTreeTextures(content, key, Tree.bushyTree);
+ case "terrainfeatures/tree1_fall": // from Tree
+ case "terrainfeatures/tree1_spring": // from Tree
+ case "terrainfeatures/tree1_summer": // from Tree
+ case "terrainfeatures/tree1_winter": // from Tree
+ return !ignoreWorld && this.ReloadTreeTextures(content, assetName, Tree.bushyTree);
- case "terrainfeatures\\tree2_fall": // from Tree
- case "terrainfeatures\\tree2_spring": // from Tree
- case "terrainfeatures\\tree2_summer": // from Tree
- case "terrainfeatures\\tree2_winter": // from Tree
- return !ignoreWorld && this.ReloadTreeTextures(content, key, Tree.leafyTree);
+ case "terrainfeatures/tree2_fall": // from Tree
+ case "terrainfeatures/tree2_spring": // from Tree
+ case "terrainfeatures/tree2_summer": // from Tree
+ case "terrainfeatures/tree2_winter": // from Tree
+ return !ignoreWorld && this.ReloadTreeTextures(content, assetName, Tree.leafyTree);
- case "terrainfeatures\\tree3_fall": // from Tree
- case "terrainfeatures\\tree3_spring": // from Tree
- case "terrainfeatures\\tree3_winter": // from Tree
- return !ignoreWorld && this.ReloadTreeTextures(content, key, Tree.pineTree);
+ case "terrainfeatures/tree3_fall": // from Tree
+ case "terrainfeatures/tree3_spring": // from Tree
+ case "terrainfeatures/tree3_winter": // from Tree
+ return !ignoreWorld && this.ReloadTreeTextures(content, assetName, Tree.pineTree);
}
/****
@@ -585,25 +584,25 @@ namespace StardewModdingAPI.Metadata
if (!ignoreWorld)
{
// dynamic textures
- if (this.KeyStartsWith(key, "animals\\cat"))
- return this.ReloadPetOrHorseSprites<Cat>(content, key);
- if (this.KeyStartsWith(key, "animals\\dog"))
- return this.ReloadPetOrHorseSprites<Dog>(content, key);
- if (this.IsInFolder(key, "Animals"))
- return this.ReloadFarmAnimalSprites(content, key);
+ if (assetName.StartsWith("animals/cat"))
+ return this.ReloadPetOrHorseSprites<Cat>(content, assetName);
+ if (assetName.StartsWith("animals/dog"))
+ return this.ReloadPetOrHorseSprites<Dog>(content, assetName);
+ if (assetName.IsDirectlyUnderPath("Animals"))
+ return this.ReloadFarmAnimalSprites(content, assetName);
- if (this.IsInFolder(key, "Buildings"))
- return this.ReloadBuildings(key);
+ if (assetName.IsDirectlyUnderPath("Buildings"))
+ return this.ReloadBuildings(assetName);
- if (this.KeyStartsWith(key, "LooseSprites\\Fence"))
- return this.ReloadFenceTextures(key);
+ if (assetName.StartsWith("LooseSprites/Fence"))
+ return this.ReloadFenceTextures(assetName);
// dynamic data
- if (this.IsInFolder(key, "Characters\\Dialogue"))
- return this.ReloadNpcDialogue(key);
+ if (assetName.IsDirectlyUnderPath("Characters/Dialogue"))
+ return this.ReloadNpcDialogue(assetName);
- if (this.IsInFolder(key, "Characters\\schedules"))
- return this.ReloadNpcSchedules(key);
+ if (assetName.IsDirectlyUnderPath("Characters/schedules"))
+ return this.ReloadNpcSchedules(assetName);
}
return false;
@@ -618,14 +617,14 @@ namespace StardewModdingAPI.Metadata
****/
/// <summary>Reload buttons on the title screen.</summary>
/// <param name="content">The content manager through which to reload the asset.</param>
- /// <param name="key">The asset key to reload.</param>
+ /// <param name="assetName">The asset name to reload.</param>
/// <returns>Returns whether any textures were reloaded.</returns>
/// <remarks>Derived from the <see cref="TitleMenu"/> constructor and <see cref="TitleMenu.setUpIcons"/>.</remarks>
- private bool ReloadTitleButtons(LocalizedContentManager content, string key)
+ private bool ReloadTitleButtons(LocalizedContentManager content, IAssetName assetName)
{
if (Game1.activeClickableMenu is TitleMenu titleMenu)
{
- Texture2D texture = content.Load<Texture2D>(key);
+ Texture2D texture = content.Load<Texture2D>(assetName.Name);
titleMenu.titleButtonsTexture = texture;
titleMenu.backButton.texture = texture;
@@ -645,21 +644,21 @@ namespace StardewModdingAPI.Metadata
/// <summary>Reload the sprites for matching pets or horses.</summary>
/// <typeparam name="TAnimal">The animal type.</typeparam>
/// <param name="content">The content manager through which to reload the asset.</param>
- /// <param name="key">The asset key to reload.</param>
+ /// <param name="assetName">The asset name to reload.</param>
/// <returns>Returns whether any textures were reloaded.</returns>
- private bool ReloadPetOrHorseSprites<TAnimal>(LocalizedContentManager content, string key)
+ private bool ReloadPetOrHorseSprites<TAnimal>(LocalizedContentManager content, IAssetName assetName)
where TAnimal : NPC
{
// find matches
TAnimal[] animals = this.GetCharacters()
.OfType<TAnimal>()
- .Where(p => key == this.NormalizeAssetNameIgnoringEmpty(p.Sprite?.Texture?.Name))
+ .Where(p => assetName.IsEquivalentTo(p.Sprite?.Texture?.Name))
.ToArray();
if (!animals.Any())
return false;
// update sprites
- Texture2D texture = content.Load<Texture2D>(key);
+ Texture2D texture = content.Load<Texture2D>(assetName.Name);
foreach (TAnimal animal in animals)
animal.Sprite.spriteTexture = texture;
return true;
@@ -667,10 +666,10 @@ namespace StardewModdingAPI.Metadata
/// <summary>Reload the sprites for matching farm animals.</summary>
/// <param name="content">The content manager through which to reload the asset.</param>
- /// <param name="key">The asset key to reload.</param>
+ /// <param name="assetName">The asset name to reload.</param>
/// <returns>Returns whether any textures were reloaded.</returns>
/// <remarks>Derived from <see cref="FarmAnimal.reload"/>.</remarks>
- private bool ReloadFarmAnimalSprites(LocalizedContentManager content, string key)
+ private bool ReloadFarmAnimalSprites(LocalizedContentManager content, IAssetName assetName)
{
// find matches
FarmAnimal[] animals = this.GetFarmAnimals().ToArray();
@@ -678,7 +677,7 @@ namespace StardewModdingAPI.Metadata
return false;
// update sprites
- Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key));
+ Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(assetName.Name));
foreach (FarmAnimal animal in animals)
{
// get expected key
@@ -687,26 +686,26 @@ namespace StardewModdingAPI.Metadata
: animal.type.Value;
if (animal.showDifferentTextureWhenReadyForHarvest.Value && animal.currentProduce.Value <= 0)
expectedKey = $"Sheared{expectedKey}";
- expectedKey = $"Animals\\{expectedKey}";
+ expectedKey = $"Animals/{expectedKey}";
// reload asset
- if (expectedKey == key)
+ if (assetName.IsEquivalentTo(expectedKey))
animal.Sprite.spriteTexture = texture.Value;
}
return texture.IsValueCreated;
}
/// <summary>Reload building textures.</summary>
- /// <param name="key">The asset key to reload.</param>
+ /// <param name="assetName">The asset name to reload.</param>
/// <returns>Returns whether any textures were reloaded.</returns>
- private bool ReloadBuildings(string key)
+ private bool ReloadBuildings(IAssetName assetName)
{
// get paint mask info
const string paintMaskSuffix = "_PaintMask";
- bool isPaintMask = key.EndsWith(paintMaskSuffix, StringComparison.OrdinalIgnoreCase);
+ bool isPaintMask = assetName.BaseName.EndsWith(paintMaskSuffix, StringComparison.OrdinalIgnoreCase);
// get building type
- string type = Path.GetFileName(key);
+ string type = Path.GetFileName(assetName.Name)!;
if (isPaintMask)
type = type.Substring(0, type.Length - paintMaskSuffix.Length);
@@ -718,7 +717,7 @@ namespace StardewModdingAPI.Metadata
.ToArray();
// remove from paint mask cache
- bool removedFromCache = this.RemoveFromPaintMaskCache(key);
+ bool removedFromCache = this.RemoveFromPaintMaskCache(assetName);
// reload textures
if (buildings.Any())
@@ -734,12 +733,12 @@ namespace StardewModdingAPI.Metadata
/// <summary>Reload map seat textures.</summary>
/// <param name="content">The content manager through which to reload the asset.</param>
- /// <param name="key">The asset key to reload.</param>
+ /// <param name="assetName">The asset name to reload.</param>
/// <param name="ignoreWorld">Whether the in-game world is fully unloaded (e.g. on the title screen), so there's no need to propagate changes into the world.</param>
/// <returns>Returns whether any textures were reloaded.</returns>
- private bool ReloadChairTiles(LocalizedContentManager content, string key, bool ignoreWorld)
+ private bool ReloadChairTiles(LocalizedContentManager content, IAssetName assetName, bool ignoreWorld)
{
- MapSeat.mapChairTexture = content.Load<Texture2D>(key);
+ MapSeat.mapChairTexture = content.Load<Texture2D>(assetName.Name);
if (!ignoreWorld)
{
@@ -747,9 +746,7 @@ namespace StardewModdingAPI.Metadata
{
foreach (MapSeat seat in location.mapSeats.Where(p => p != null))
{
- string curKey = this.NormalizeAssetNameIgnoringEmpty(seat._loadedTextureFile);
-
- if (curKey == null || key.Equals(curKey, StringComparison.OrdinalIgnoreCase))
+ if (assetName.IsEquivalentTo(seat._loadedTextureFile))
seat.overlayTexture = MapSeat.mapChairTexture;
}
}
@@ -760,9 +757,9 @@ namespace StardewModdingAPI.Metadata
/// <summary>Reload critter textures.</summary>
/// <param name="content">The content manager through which to reload the asset.</param>
- /// <param name="key">The asset key to reload.</param>
+ /// <param name="assetName">The asset name to reload.</param>
/// <returns>Returns the number of reloaded assets.</returns>
- private int ReloadCritterTextures(LocalizedContentManager content, string key)
+ private int ReloadCritterTextures(LocalizedContentManager content, IAssetName assetName)
{
// get critters
Critter[] critters =
@@ -770,7 +767,7 @@ namespace StardewModdingAPI.Metadata
from location in this.GetLocations()
where location.critters != null
from Critter critter in location.critters
- where this.NormalizeAssetNameIgnoringEmpty(critter.sprite?.Texture?.Name) == key
+ where assetName.IsEquivalentTo(critter.sprite?.Texture?.Name)
select critter
)
.ToArray();
@@ -778,7 +775,7 @@ namespace StardewModdingAPI.Metadata
return 0;
// update sprites
- Texture2D texture = content.Load<Texture2D>(key);
+ Texture2D texture = content.Load<Texture2D>(assetName.Name);
foreach (var entry in critters)
entry.sprite.spriteTexture = texture;
@@ -787,11 +784,11 @@ namespace StardewModdingAPI.Metadata
/// <summary>Reload the sprites for interior doors.</summary>
/// <param name="content">The content manager through which to reload the asset.</param>
- /// <param name="key">The asset key to reload.</param>
+ /// <param name="assetName">The asset name to reload.</param>
/// <returns>Returns whether any doors were affected.</returns>
- private bool ReloadDoorSprites(LocalizedContentManager content, string key)
+ private bool ReloadDoorSprites(LocalizedContentManager content, IAssetName assetName)
{
- Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key));
+ Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(assetName.Name));
foreach (GameLocation location in this.GetLocations())
{
@@ -804,11 +801,9 @@ namespace StardewModdingAPI.Metadata
if (door?.Sprite == null)
continue;
- string textureName = this.NormalizeAssetNameIgnoringEmpty(this.Reflection.GetField<string>(door.Sprite, "textureName").GetValue());
- if (textureName != key)
- continue;
-
- door.Sprite.texture = texture.Value;
+ string curKey = this.Reflection.GetField<string>(door.Sprite, "textureName").GetValue();
+ if (assetName.IsEquivalentTo(curKey))
+ door.Sprite.texture = texture.Value;
}
}
@@ -831,12 +826,12 @@ namespace StardewModdingAPI.Metadata
}
/// <summary>Reload the sprites for a fence type.</summary>
- /// <param name="key">The asset key to reload.</param>
+ /// <param name="assetName">The asset name to reload.</param>
/// <returns>Returns whether any textures were reloaded.</returns>
- private bool ReloadFenceTextures(string key)
+ private bool ReloadFenceTextures(IAssetName assetName)
{
- // get fence type
- if (!int.TryParse(this.GetSegments(key)[1].Substring("Fence".Length), out int fenceType))
+ // get fence type (e.g. LooseSprites/Fence3 => 3)
+ if (!int.TryParse(this.GetSegments(assetName.BaseName)[1].Substring("Fence".Length), out int fenceType))
return false;
// get fences
@@ -859,22 +854,22 @@ namespace StardewModdingAPI.Metadata
/// <summary>Reload tree textures.</summary>
/// <param name="content">The content manager through which to reload the asset.</param>
- /// <param name="key">The asset key to reload.</param>
+ /// <param name="assetName">The asset name to reload.</param>
/// <returns>Returns whether any textures were reloaded.</returns>
- private bool ReloadGrassTextures(LocalizedContentManager content, string key)
+ private bool ReloadGrassTextures(LocalizedContentManager content, IAssetName assetName)
{
Grass[] grasses =
(
from location in this.GetLocations()
from grass in location.terrainFeatures.Values.OfType<Grass>()
- where this.NormalizeAssetNameIgnoringEmpty(grass.textureName()) == key
+ where assetName.IsEquivalentTo(grass.textureName())
select grass
)
.ToArray();
if (grasses.Any())
{
- Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key));
+ Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(assetName.Name));
foreach (Grass grass in grasses)
grass.texture = texture;
return true;
@@ -936,11 +931,11 @@ namespace StardewModdingAPI.Metadata
/// <summary>Reload the disposition data for matching NPCs.</summary>
/// <param name="content">The content manager through which to reload the asset.</param>
- /// <param name="key">The asset key to reload.</param>
+ /// <param name="assetName">The asset name to reload.</param>
/// <returns>Returns whether any NPCs were affected.</returns>
- private bool ReloadNpcDispositions(LocalizedContentManager content, string key)
+ private bool ReloadNpcDispositions(LocalizedContentManager content, IAssetName assetName)
{
- IDictionary<string, string> data = content.Load<Dictionary<string, string>>(key);
+ IDictionary<string, string> data = content.Load<Dictionary<string, string>>(assetName.Name);
bool changed = false;
foreach (NPC npc in this.GetCharacters())
{
@@ -957,16 +952,16 @@ namespace StardewModdingAPI.Metadata
/// <summary>Reload the sprites for matching NPCs.</summary>
/// <param name="keys">The asset keys to reload.</param>
/// <param name="propagated">The asset keys which have been propagated.</param>
- private void ReloadNpcSprites(IEnumerable<string> keys, IDictionary<string, bool> propagated)
+ private void ReloadNpcSprites(IEnumerable<IAssetName> keys, IDictionary<IAssetName, bool> propagated)
{
// get NPCs
- HashSet<string> lookup = new HashSet<string>(keys, StringComparer.OrdinalIgnoreCase);
+ IDictionary<string, IAssetName> lookup = keys.ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase);
var characters =
(
from npc in this.GetCharacters()
let key = this.NormalizeAssetNameIgnoringEmpty(npc.Sprite?.Texture?.Name)
- where key != null && lookup.Contains(key)
- select new { Npc = npc, Key = key }
+ where key != null && lookup.ContainsKey(key)
+ select new { Npc = npc, AssetName = lookup[key] }
)
.ToArray();
if (!characters.Any())
@@ -975,56 +970,56 @@ namespace StardewModdingAPI.Metadata
// update sprite
foreach (var target in characters)
{
- target.Npc.Sprite.spriteTexture = this.LoadAndDisposeIfNeeded(target.Npc.Sprite.spriteTexture, target.Key);
- propagated[target.Key] = true;
+ target.Npc.Sprite.spriteTexture = this.LoadAndDisposeIfNeeded(target.Npc.Sprite.spriteTexture, target.AssetName.Name);
+ propagated[target.AssetName] = true;
}
}
/// <summary>Reload the portraits for matching NPCs.</summary>
/// <param name="keys">The asset key to reload.</param>
/// <param name="propagated">The asset keys which have been propagated.</param>
- private void ReloadNpcPortraits(IEnumerable<string> keys, IDictionary<string, bool> propagated)
+ private void ReloadNpcPortraits(IEnumerable<IAssetName> keys, IDictionary<IAssetName, bool> propagated)
{
// get NPCs
- HashSet<string> lookup = new HashSet<string>(keys, StringComparer.OrdinalIgnoreCase);
+ IDictionary<string, IAssetName> lookup = keys.ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase);
var characters =
(
from npc in this.GetCharacters()
where npc.isVillager()
let key = this.NormalizeAssetNameIgnoringEmpty(npc.Portrait?.Name)
- where key != null && lookup.Contains(key)
- select new { Npc = npc, Key = key }
+ where key != null && lookup.ContainsKey(key)
+ select new { Npc = npc, AssetName = lookup[key] }
)
.ToList();
// special case: Gil is a private NPC field on the AdventureGuild class (only used for the portrait)
{
string gilKey = this.NormalizeAssetNameIgnoringEmpty("Portraits/Gil");
- if (lookup.Contains(gilKey))
+ if (lookup.TryGetValue(gilKey, out IAssetName assetName))
{
GameLocation adventureGuild = Game1.getLocationFromName("AdventureGuild");
if (adventureGuild != null)
- characters.Add(new { Npc = this.Reflection.GetField<NPC>(adventureGuild, "Gil").GetValue(), Key = gilKey });
+ characters.Add(new { Npc = this.Reflection.GetField<NPC>(adventureGuild, "Gil").GetValue(), AssetName = assetName });
}
}
// update portrait
foreach (var target in characters)
{
- target.Npc.Portrait = this.LoadAndDisposeIfNeeded(target.Npc.Portrait, target.Key);
- propagated[target.Key] = true;
+ target.Npc.Portrait = this.LoadAndDisposeIfNeeded(target.Npc.Portrait, target.AssetName.Name);
+ propagated[target.AssetName] = true;
}
}
/// <summary>Reload the sprites for matching players.</summary>
- /// <param name="key">The asset key to reload.</param>
- private bool ReloadPlayerSprites(string key)
+ /// <param name="assetName">The asset name to reload.</param>
+ private bool ReloadPlayerSprites(IAssetName assetName)
{
Farmer[] players =
(
from player in Game1.getOnlineFarmers()
- where key == this.NormalizeAssetNameIgnoringEmpty(player.getTexture())
+ where assetName.IsEquivalentTo(player.getTexture())
select player
)
.ToArray();
@@ -1040,11 +1035,11 @@ namespace StardewModdingAPI.Metadata
/// <summary>Reload suspension bridge textures.</summary>
/// <param name="content">The content manager through which to reload the asset.</param>
- /// <param name="key">The asset key to reload.</param>
+ /// <param name="assetName">The asset name to reload.</param>
/// <returns>Returns whether any textures were reloaded.</returns>
- private bool ReloadSuspensionBridges(LocalizedContentManager content, string key)
+ private bool ReloadSuspensionBridges(LocalizedContentManager content, IAssetName assetName)
{
- Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key));
+ Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(assetName.Name));
foreach (GameLocation location in this.GetLocations(buildingInteriors: false))
{
@@ -1063,10 +1058,10 @@ namespace StardewModdingAPI.Metadata
/// <summary>Reload tree textures.</summary>
/// <param name="content">The content manager through which to reload the asset.</param>
- /// <param name="key">The asset key to reload.</param>
+ /// <param name="assetName">The asset name to reload.</param>
/// <param name="type">The type to reload.</param>
/// <returns>Returns whether any textures were reloaded.</returns>
- private bool ReloadTreeTextures(LocalizedContentManager content, string key, int type)
+ private bool ReloadTreeTextures(LocalizedContentManager content, IAssetName assetName, int type)
{
Tree[] trees = this.GetLocations()
.SelectMany(p => p.terrainFeatures.Values.OfType<Tree>())
@@ -1075,7 +1070,7 @@ namespace StardewModdingAPI.Metadata
if (trees.Any())
{
- Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key));
+ Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(assetName.Name));
foreach (Tree tree in trees)
tree.texture = texture;
return true;
@@ -1088,12 +1083,12 @@ namespace StardewModdingAPI.Metadata
** Reload data methods
****/
/// <summary>Reload the dialogue data for matching NPCs.</summary>
- /// <param name="key">The asset key to reload.</param>
+ /// <param name="assetName">The asset name to reload.</param>
/// <returns>Returns whether any assets were reloaded.</returns>
- private bool ReloadNpcDialogue(string key)
+ private bool ReloadNpcDialogue(IAssetName assetName)
{
// get NPCs
- string name = Path.GetFileName(key);
+ string name = Path.GetFileName(assetName.Name);
NPC[] villagers = this.GetCharacters().Where(npc => npc.Name == name && npc.isVillager()).ToArray();
if (!villagers.Any())
return false;
@@ -1118,12 +1113,12 @@ namespace StardewModdingAPI.Metadata
}
/// <summary>Reload the schedules for matching NPCs.</summary>
- /// <param name="key">The asset key to reload.</param>
+ /// <param name="assetName">The asset name to reload.</param>
/// <returns>Returns whether any assets were reloaded.</returns>
- private bool ReloadNpcSchedules(string key)
+ private bool ReloadNpcSchedules(IAssetName assetName)
{
// get NPCs
- string name = Path.GetFileName(key);
+ string name = Path.GetFileName(assetName.Name);
NPC[] villagers = this.GetCharacters().Where(npc => npc.Name == name && npc.isVillager()).ToArray();
if (!villagers.Any())
return false;
@@ -1157,17 +1152,17 @@ namespace StardewModdingAPI.Metadata
/// <remarks>Derived from the <see cref="Game1.TranslateFields"/>.</remarks>
private bool ReloadStringsFromCsFiles(LocalizedContentManager content)
{
- Game1.samBandName = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.2156");
- Game1.elliottBookName = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.2157");
+ Game1.samBandName = content.LoadString("Strings/StringsFromCSFiles:Game1.cs.2156");
+ Game1.elliottBookName = content.LoadString("Strings/StringsFromCSFiles:Game1.cs.2157");
string[] dayNames = this.Reflection.GetField<string[]>(typeof(Game1), "_shortDayDisplayName").GetValue();
- dayNames[0] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3042");
- dayNames[1] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3043");
- dayNames[2] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3044");
- dayNames[3] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3045");
- dayNames[4] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3046");
- dayNames[5] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3047");
- dayNames[6] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3048");
+ dayNames[0] = content.LoadString("Strings/StringsFromCSFiles:Game1.cs.3042");
+ dayNames[1] = content.LoadString("Strings/StringsFromCSFiles:Game1.cs.3043");
+ dayNames[2] = content.LoadString("Strings/StringsFromCSFiles:Game1.cs.3044");
+ dayNames[3] = content.LoadString("Strings/StringsFromCSFiles:Game1.cs.3045");
+ dayNames[4] = content.LoadString("Strings/StringsFromCSFiles:Game1.cs.3046");
+ dayNames[5] = content.LoadString("Strings/StringsFromCSFiles:Game1.cs.3047");
+ dayNames[6] = content.LoadString("Strings/StringsFromCSFiles:Game1.cs.3048");
return true;
}
@@ -1247,42 +1242,13 @@ namespace StardewModdingAPI.Metadata
return this.AssertAndNormalizeAssetName(path);
}
- /// <summary>Get whether a key starts with a substring after the substring is normalized.</summary>
- /// <param name="key">The key to check.</param>
- /// <param name="rawSubstring">The substring to normalize and find.</param>
- private bool KeyStartsWith(string key, string rawSubstring)
- {
- if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(rawSubstring))
- return false;
-
- return key.StartsWith(this.NormalizeAssetNameIgnoringEmpty(rawSubstring), StringComparison.OrdinalIgnoreCase);
- }
-
- /// <summary>Get whether a normalized asset key is in the given folder.</summary>
- /// <param name="key">The normalized asset key (like <c>Animals/cat</c>).</param>
- /// <param name="folder">The key folder (like <c>Animals</c>); doesn't need to be normalized.</param>
- /// <param name="allowSubfolders">Whether to return true if the key is inside a subfolder of the <paramref name="folder"/>.</param>
- private bool IsInFolder(string key, string folder, bool allowSubfolders = false)
- {
- return
- this.KeyStartsWith(key, $"{folder}\\")
- && (allowSubfolders || this.CountSegments(key) == this.CountSegments(folder) + 1);
- }
-
/// <summary>Get the segments in a path (e.g. 'a/b' is 'a' and 'b').</summary>
/// <param name="path">The path to check.</param>
private string[] GetSegments(string path)
{
return path != null
? PathUtilities.GetSegments(path)
- : new string[0];
- }
-
- /// <summary>Count the number of segments in a path (e.g. 'a/b' is 2).</summary>
- /// <param name="path">The path to check.</param>
- private int CountSegments(string path)
- {
- return this.GetSegments(path).Length;
+ : Array.Empty<string>();
}
/// <summary>Load a texture, and dispose the old one if <see cref="AggressiveMemoryOptimizations"/> is enabled and it's different from the new instance.</summary>
@@ -1308,8 +1274,8 @@ namespace StardewModdingAPI.Metadata
}
/// <summary>Remove a case-insensitive key from the paint mask cache.</summary>
- /// <param name="key">The paint mask asset key.</param>
- private bool RemoveFromPaintMaskCache(string key)
+ /// <param name="assetName">The paint mask asset name.</param>
+ private bool RemoveFromPaintMaskCache(IAssetName assetName)
{
// make cache case-insensitive
// This is needed for cache invalidation since mods may specify keys with a different capitalization
@@ -1317,7 +1283,7 @@ namespace StardewModdingAPI.Metadata
BuildingPainter.paintMaskLookup = new Dictionary<string, List<List<int>>>(BuildingPainter.paintMaskLookup, StringComparer.OrdinalIgnoreCase);
// remove key from cache
- return BuildingPainter.paintMaskLookup.Remove(key);
+ return BuildingPainter.paintMaskLookup.Remove(assetName.Name);
}
/// <summary>Metadata about a location used in asset propagation.</summary>
diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs
index 0c90f2aa..f2f65287 100644
--- a/src/SMAPI/Program.cs
+++ b/src/SMAPI/Program.cs
@@ -66,7 +66,7 @@ namespace StardewModdingAPI
{
Program.AssemblyPathsByName = new(StringComparer.OrdinalIgnoreCase);
- foreach (string searchPath in new[] { EarlyConstants.ExecutionPath, Program.DllSearchPath })
+ foreach (string searchPath in new[] { EarlyConstants.GamePath, Program.DllSearchPath })
{
foreach (string dllPath in Directory.EnumerateFiles(searchPath, "*.dll"))
{
@@ -110,7 +110,7 @@ namespace StardewModdingAPI
catch (Exception ex)
{
// file doesn't exist
- if (!File.Exists(Path.Combine(EarlyConstants.ExecutionPath, $"{EarlyConstants.GameAssemblyName}.exe")))
+ if (!File.Exists(Path.Combine(EarlyConstants.GamePath, $"{EarlyConstants.GameAssemblyName}.exe")))
Program.PrintErrorAndExit("Oops! SMAPI can't find the game. Make sure you're running StardewModdingAPI.exe in your game folder.");
// can't load file
@@ -160,8 +160,8 @@ namespace StardewModdingAPI
/// <remarks>This is needed to resolve native DLLs like libSkiaSharp.</remarks>
private static void AssertDepsJson()
{
- string sourcePath = Path.Combine(Constants.ExecutionPath, "Stardew Valley.deps.json");
- string targetPath = Path.Combine(Constants.ExecutionPath, "StardewModdingAPI.deps.json");
+ string sourcePath = Path.Combine(Constants.GamePath, "Stardew Valley.deps.json");
+ string targetPath = Path.Combine(Constants.GamePath, "StardewModdingAPI.deps.json");
if (!File.Exists(targetPath) || FileUtilities.GetFileHash(sourcePath) != FileUtilities.GetFileHash(targetPath))
{
@@ -194,7 +194,7 @@ namespace StardewModdingAPI
// normalise
modsPath = !string.IsNullOrWhiteSpace(rawModsPath)
- ? Path.Combine(Constants.ExecutionPath, rawModsPath)
+ ? Path.Combine(Constants.GamePath, rawModsPath)
: Constants.DefaultModsPath;
}
diff --git a/src/SMAPI/Utilities/Keybind.cs b/src/SMAPI/Utilities/Keybind.cs
index 87b867a9..403ecf4a 100644
--- a/src/SMAPI/Utilities/Keybind.cs
+++ b/src/SMAPI/Utilities/Keybind.cs
@@ -48,7 +48,7 @@ namespace StardewModdingAPI.Utilities
if (string.IsNullOrWhiteSpace(input))
{
parsed = new Keybind(SButton.None);
- errors = new string[0];
+ errors = Array.Empty<string>();
return true;
}
@@ -97,7 +97,7 @@ namespace StardewModdingAPI.Utilities
else
{
parsed = new Keybind(buttons);
- errors = new string[0];
+ errors = Array.Empty<string>();
return true;
}
}
diff --git a/src/SMAPI/Utilities/KeybindList.cs b/src/SMAPI/Utilities/KeybindList.cs
index 28cae240..f8f569af 100644
--- a/src/SMAPI/Utilities/KeybindList.cs
+++ b/src/SMAPI/Utilities/KeybindList.cs
@@ -55,7 +55,7 @@ namespace StardewModdingAPI.Utilities
if (string.IsNullOrWhiteSpace(input))
{
parsed = new KeybindList();
- errors = new string[0];
+ errors = Array.Empty<string>();
return true;
}
@@ -83,7 +83,7 @@ namespace StardewModdingAPI.Utilities
else
{
parsed = new KeybindList(keybinds.ToArray());
- errors = new string[0];
+ errors = Array.Empty<string>();
return true;
}
}