summaryrefslogtreecommitdiff
path: root/src/SMAPI/Framework
diff options
context:
space:
mode:
Diffstat (limited to 'src/SMAPI/Framework')
-rw-r--r--src/SMAPI/Framework/Command.cs4
-rw-r--r--src/SMAPI/Framework/CommandManager.cs39
-rw-r--r--src/SMAPI/Framework/Commands/HarmonySummaryCommand.cs20
-rw-r--r--src/SMAPI/Framework/Commands/HelpCommand.cs6
-rw-r--r--src/SMAPI/Framework/Content/AssetData.cs7
-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.cs93
-rw-r--r--src/SMAPI/Framework/Content/AssetDataForObject.cs35
-rw-r--r--src/SMAPI/Framework/Content/AssetEditOperation.cs41
-rw-r--r--src/SMAPI/Framework/Content/AssetInfo.cs56
-rw-r--r--src/SMAPI/Framework/Content/AssetInterceptorChange.cs11
-rw-r--r--src/SMAPI/Framework/Content/AssetLoadOperation.cs41
-rw-r--r--src/SMAPI/Framework/Content/AssetName.cs199
-rw-r--r--src/SMAPI/Framework/Content/AssetOperationGroup.cs33
-rw-r--r--src/SMAPI/Framework/Content/ContentCache.cs17
-rw-r--r--src/SMAPI/Framework/Content/TilesheetReference.cs1
-rw-r--r--src/SMAPI/Framework/ContentCoordinator.cs389
-rw-r--r--src/SMAPI/Framework/ContentManagers/BaseContentManager.cs201
-rw-r--r--src/SMAPI/Framework/ContentManagers/GameContentManager.cs396
-rw-r--r--src/SMAPI/Framework/ContentManagers/GameContentManagerForAssetPropagation.cs10
-rw-r--r--src/SMAPI/Framework/ContentManagers/IContentManager.cs37
-rw-r--r--src/SMAPI/Framework/ContentManagers/ModContentManager.cs273
-rw-r--r--src/SMAPI/Framework/ContentPack.cs63
-rw-r--r--src/SMAPI/Framework/CursorPosition.cs2
-rw-r--r--src/SMAPI/Framework/DeprecationManager.cs138
-rw-r--r--src/SMAPI/Framework/Deprecations/DeprecationLevel.cs (renamed from src/SMAPI/Framework/DeprecationLevel.cs)2
-rw-r--r--src/SMAPI/Framework/Deprecations/DeprecationManager.cs187
-rw-r--r--src/SMAPI/Framework/Deprecations/DeprecationWarning.cs (renamed from src/SMAPI/Framework/DeprecationWarning.cs)22
-rw-r--r--src/SMAPI/Framework/Deprecations/ImmutableStackTrace.cs53
-rw-r--r--src/SMAPI/Framework/Events/EventManager.cs130
-rw-r--r--src/SMAPI/Framework/Events/ManagedEvent.cs20
-rw-r--r--src/SMAPI/Framework/Events/ManagedEventHandler.cs8
-rw-r--r--src/SMAPI/Framework/Events/ModContentEvents.cs50
-rw-r--r--src/SMAPI/Framework/Events/ModDisplayEvents.cs23
-rw-r--r--src/SMAPI/Framework/Events/ModEvents.cs20
-rw-r--r--src/SMAPI/Framework/Events/ModGameLoopEvents.cs30
-rw-r--r--src/SMAPI/Framework/Events/ModInputEvents.cs12
-rw-r--r--src/SMAPI/Framework/Events/ModMultiplayerEvents.cs10
-rw-r--r--src/SMAPI/Framework/Events/ModPlayerEvents.cs8
-rw-r--r--src/SMAPI/Framework/Events/ModSpecialisedEvents.cs8
-rw-r--r--src/SMAPI/Framework/Events/ModWorldEvents.cs20
-rw-r--r--src/SMAPI/Framework/Exceptions/SContentLoadException.cs4
-rw-r--r--src/SMAPI/Framework/GameVersion.cs8
-rw-r--r--src/SMAPI/Framework/IModMetadata.cs24
-rw-r--r--src/SMAPI/Framework/Input/GamePadStateBuilder.cs22
-rw-r--r--src/SMAPI/Framework/Input/KeyboardStateBuilder.cs11
-rw-r--r--src/SMAPI/Framework/Input/MouseStateBuilder.cs7
-rw-r--r--src/SMAPI/Framework/Input/SInputState.cs18
-rw-r--r--src/SMAPI/Framework/InternalExtensions.cs13
-rw-r--r--src/SMAPI/Framework/Logging/InterceptingTextWriter.cs22
-rw-r--r--src/SMAPI/Framework/Logging/LogFileManager.cs2
-rw-r--r--src/SMAPI/Framework/Logging/LogManager.cs32
-rw-r--r--src/SMAPI/Framework/ModHelpers/BaseHelper.cs15
-rw-r--r--src/SMAPI/Framework/ModHelpers/CommandHelper.cs9
-rw-r--r--src/SMAPI/Framework/ModHelpers/ContentHelper.cs108
-rw-r--r--src/SMAPI/Framework/ModHelpers/ContentPackHelper.cs6
-rw-r--r--src/SMAPI/Framework/ModHelpers/DataHelper.cs32
-rw-r--r--src/SMAPI/Framework/ModHelpers/GameContentHelper.cs145
-rw-r--r--src/SMAPI/Framework/ModHelpers/InputHelper.cs6
-rw-r--r--src/SMAPI/Framework/ModHelpers/ModContentHelper.cs101
-rw-r--r--src/SMAPI/Framework/ModHelpers/ModHelper.cs60
-rw-r--r--src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs29
-rw-r--r--src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs12
-rw-r--r--src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs30
-rw-r--r--src/SMAPI/Framework/ModHelpers/TranslationHelper.cs10
-rw-r--r--src/SMAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs6
-rw-r--r--src/SMAPI/Framework/ModLoading/AssemblyLoader.cs53
-rw-r--r--src/SMAPI/Framework/ModLoading/AssemblyParseResult.cs13
-rw-r--r--src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs2
-rw-r--r--src/SMAPI/Framework/ModLoading/Finders/FieldFinder.cs2
-rw-r--r--src/SMAPI/Framework/ModLoading/Finders/MethodFinder.cs2
-rw-r--r--src/SMAPI/Framework/ModLoading/Finders/PropertyFinder.cs2
-rw-r--r--src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs13
-rw-r--r--src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs11
-rw-r--r--src/SMAPI/Framework/ModLoading/Finders/TypeAssemblyFinder.cs4
-rw-r--r--src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs6
-rw-r--r--src/SMAPI/Framework/ModLoading/Framework/BaseInstructionHandler.cs2
-rw-r--r--src/SMAPI/Framework/ModLoading/Framework/RecursiveRewriter.cs23
-rw-r--r--src/SMAPI/Framework/ModLoading/Framework/RewriteHelper.cs8
-rw-r--r--src/SMAPI/Framework/ModLoading/InvalidModStateException.cs2
-rw-r--r--src/SMAPI/Framework/ModLoading/ModMetadata.cs36
-rw-r--r--src/SMAPI/Framework/ModLoading/ModResolver.cs115
-rw-r--r--src/SMAPI/Framework/ModLoading/RewriteFacades/AccessToolsFacade.cs4
-rw-r--r--src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyInstanceFacade.cs7
-rw-r--r--src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyMethodFacade.cs4
-rw-r--r--src/SMAPI/Framework/ModLoading/RewriteFacades/SpriteBatchFacade.cs3
-rw-r--r--src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs18
-rw-r--r--src/SMAPI/Framework/ModLoading/Rewriters/HarmonyRewriter.cs16
-rw-r--r--src/SMAPI/Framework/ModLoading/Rewriters/HeuristicFieldRewriter.cs19
-rw-r--r--src/SMAPI/Framework/ModLoading/Rewriters/HeuristicMethodRewriter.cs13
-rw-r--r--src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs11
-rw-r--r--src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs4
-rw-r--r--src/SMAPI/Framework/ModLoading/Symbols/SymbolReaderProvider.cs6
-rw-r--r--src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs12
-rw-r--r--src/SMAPI/Framework/ModRegistry.cs38
-rw-r--r--src/SMAPI/Framework/Models/SConfig.cs89
-rw-r--r--src/SMAPI/Framework/Monitor.cs2
-rw-r--r--src/SMAPI/Framework/Networking/ModMessageModel.cs19
-rw-r--r--src/SMAPI/Framework/Networking/MultiplayerPeer.cs14
-rw-r--r--src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs5
-rw-r--r--src/SMAPI/Framework/Networking/RemoteContextModModel.cs28
-rw-r--r--src/SMAPI/Framework/Networking/RemoteContextModel.cs33
-rw-r--r--src/SMAPI/Framework/Networking/SGalaxyNetServer.cs6
-rw-r--r--src/SMAPI/Framework/Networking/SLidgrenServer.cs4
-rw-r--r--src/SMAPI/Framework/Reflection/CacheEntry.cs10
-rw-r--r--src/SMAPI/Framework/Reflection/IInterfaceProxyFactory.cs17
-rw-r--r--src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs47
-rw-r--r--src/SMAPI/Framework/Reflection/OriginalInterfaceProxyBuilder.cs (renamed from src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs)10
-rw-r--r--src/SMAPI/Framework/Reflection/OriginalInterfaceProxyFactory.cs57
-rw-r--r--src/SMAPI/Framework/Reflection/ReflectedField.cs10
-rw-r--r--src/SMAPI/Framework/Reflection/ReflectedMethod.cs16
-rw-r--r--src/SMAPI/Framework/Reflection/ReflectedProperty.cs8
-rw-r--r--src/SMAPI/Framework/Reflection/Reflector.cs182
-rw-r--r--src/SMAPI/Framework/Rendering/SDisplayDevice.cs6
-rw-r--r--src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs2
-rw-r--r--src/SMAPI/Framework/RequestExitDelegate.cs7
-rw-r--r--src/SMAPI/Framework/SChatBox.cs2
-rw-r--r--src/SMAPI/Framework/SCore.cs450
-rw-r--r--src/SMAPI/Framework/SGame.cs37
-rw-r--r--src/SMAPI/Framework/SGameRunner.cs4
-rw-r--r--src/SMAPI/Framework/SModHooks.cs2
-rw-r--r--src/SMAPI/Framework/SMultiplayer.cs135
-rw-r--r--src/SMAPI/Framework/Serialization/KeybindConverter.cs10
-rw-r--r--src/SMAPI/Framework/Singleton.cs2
-rw-r--r--src/SMAPI/Framework/SnapshotDiff.cs4
-rw-r--r--src/SMAPI/Framework/SnapshotItemListDiff.cs3
-rw-r--r--src/SMAPI/Framework/SnapshotListDiff.cs6
-rw-r--r--src/SMAPI/Framework/StateTracking/ChestTracker.cs7
-rw-r--r--src/SMAPI/Framework/StateTracking/Comparers/EquatableComparer.cs2
-rw-r--r--src/SMAPI/Framework/StateTracking/Comparers/GenericEqualsComparer.cs2
-rw-r--r--src/SMAPI/Framework/StateTracking/Comparers/ObjectReferenceComparer.cs2
-rw-r--r--src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs4
-rw-r--r--src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableWatcher.cs3
-rw-r--r--src/SMAPI/Framework/StateTracking/FieldWatchers/ImmutableCollectionWatcher.cs7
-rw-r--r--src/SMAPI/Framework/StateTracking/FieldWatchers/NetCollectionWatcher.cs4
-rw-r--r--src/SMAPI/Framework/StateTracking/FieldWatchers/NetDictionaryWatcher.cs1
-rw-r--r--src/SMAPI/Framework/StateTracking/FieldWatchers/ObservableCollectionWatcher.cs12
-rw-r--r--src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs13
-rw-r--r--src/SMAPI/Framework/StateTracking/LocationTracker.cs17
-rw-r--r--src/SMAPI/Framework/StateTracking/PlayerTracker.cs16
-rw-r--r--src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs16
-rw-r--r--src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs15
-rw-r--r--src/SMAPI/Framework/StateTracking/Snapshots/WatcherSnapshot.cs20
-rw-r--r--src/SMAPI/Framework/StateTracking/Snapshots/WorldLocationsSnapshot.cs6
-rw-r--r--src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs21
-rw-r--r--src/SMAPI/Framework/TemporaryHacks/MiniMonoModHotfix.cs122
-rw-r--r--src/SMAPI/Framework/Translator.cs23
-rw-r--r--src/SMAPI/Framework/Utilities/Countdown.cs2
-rw-r--r--src/SMAPI/Framework/Utilities/TickCacheDictionary.cs51
-rw-r--r--src/SMAPI/Framework/WatcherCore.cs4
151 files changed, 3538 insertions, 1989 deletions
diff --git a/src/SMAPI/Framework/Command.cs b/src/SMAPI/Framework/Command.cs
index 8c9df47d..dca1dd09 100644
--- a/src/SMAPI/Framework/Command.cs
+++ b/src/SMAPI/Framework/Command.cs
@@ -9,7 +9,7 @@ namespace StardewModdingAPI.Framework
** Accessor
*********/
/// <summary>The mod that registered the command (or <c>null</c> if registered by SMAPI).</summary>
- public IModMetadata Mod { get; }
+ public IModMetadata? Mod { get; }
/// <summary>The command name, which the user must type to trigger it.</summary>
public string Name { get; }
@@ -29,7 +29,7 @@ namespace StardewModdingAPI.Framework
/// <param name="name">The command name, which the user must type to trigger it.</param>
/// <param name="documentation">The human-readable documentation shown when the player runs the built-in 'help' command.</param>
/// <param name="callback">The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user.</param>
- public Command(IModMetadata mod, string name, string documentation, Action<string, string[]> callback)
+ public Command(IModMetadata? mod, string name, string documentation, Action<string, string[]> callback)
{
this.Mod = mod;
this.Name = name;
diff --git a/src/SMAPI/Framework/CommandManager.cs b/src/SMAPI/Framework/CommandManager.cs
index ff540ad8..d3b9c8ee 100644
--- a/src/SMAPI/Framework/CommandManager.cs
+++ b/src/SMAPI/Framework/CommandManager.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
using StardewModdingAPI.Framework.Commands;
@@ -34,20 +35,19 @@ namespace StardewModdingAPI.Framework
/// <param name="name">The command name, which the user must type to trigger it.</param>
/// <param name="documentation">The human-readable documentation shown when the player runs the built-in 'help' command.</param>
/// <param name="callback">The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user.</param>
- /// <param name="allowNullCallback">Whether to allow a null <paramref name="callback"/> argument; this should only used for backwards compatibility.</param>
/// <exception cref="ArgumentNullException">The <paramref name="name"/> or <paramref name="callback"/> is null or empty.</exception>
/// <exception cref="FormatException">The <paramref name="name"/> is not a valid format.</exception>
/// <exception cref="ArgumentException">There's already a command with that name.</exception>
- public CommandManager Add(IModMetadata mod, string name, string documentation, Action<string, string[]> callback, bool allowNullCallback = false)
+ public CommandManager Add(IModMetadata? mod, string name, string documentation, Action<string, string[]> callback)
{
- name = this.GetNormalizedName(name);
+ name = this.GetNormalizedName(name)!; // null-checked below
// validate format
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentNullException(nameof(name), "Can't register a command with no name.");
if (name.Any(char.IsWhiteSpace))
throw new FormatException($"Can't register the '{name}' command because the name can't contain whitespace.");
- if (callback == null && !allowNullCallback)
+ if (callback == null)
throw new ArgumentNullException(nameof(callback), $"Can't register the '{name}' command because without a callback.");
// ensure uniqueness
@@ -65,16 +65,19 @@ namespace StardewModdingAPI.Framework
/// <exception cref="ArgumentException">There's already a command with that name.</exception>
public CommandManager Add(IInternalCommand command, IMonitor monitor)
{
- return this.Add(null, command.Name, command.Description, (name, args) => command.HandleCommand(args, monitor));
+ return this.Add(null, command.Name, command.Description, (_, args) => command.HandleCommand(args, monitor));
}
/// <summary>Get a command by its unique name.</summary>
/// <param name="name">The command name.</param>
/// <returns>Returns the matching command, or <c>null</c> if not found.</returns>
- public Command Get(string name)
+ public Command? Get(string? name)
{
- name = this.GetNormalizedName(name);
- this.Commands.TryGetValue(name, out Command command);
+ name = this.GetNormalizedName(name)!;
+ if (string.IsNullOrWhiteSpace(name))
+ return null;
+
+ this.Commands.TryGetValue(name, out Command? command);
return command;
}
@@ -93,7 +96,7 @@ namespace StardewModdingAPI.Framework
/// <param name="command">The command which can handle the input.</param>
/// <param name="screenId">The screen ID on which to run the command.</param>
/// <returns>Returns true if the input was successfully parsed and matched to a command; else false.</returns>
- public bool TryParse(string input, out string name, out string[] args, out Command command, out int screenId)
+ public bool TryParse(string? input, [NotNullWhen(true)] out string? name, [NotNullWhen(true)] out string[]? args, [NotNullWhen(true)] out Command? command, out int screenId)
{
// ignore if blank
if (string.IsNullOrWhiteSpace(input))
@@ -107,7 +110,7 @@ namespace StardewModdingAPI.Framework
// parse input
args = this.ParseArgs(input);
- name = this.GetNormalizedName(args[0]);
+ name = this.GetNormalizedName(args[0])!;
args = args.Skip(1).ToArray();
// get screen ID argument
@@ -115,7 +118,7 @@ namespace StardewModdingAPI.Framework
for (int i = 0; i < args.Length; i++)
{
// consume arg & set screen ID
- if (this.TryParseScreenId(args[i], out int rawScreenId, out string error))
+ if (this.TryParseScreenId(args[i], out int rawScreenId, out string? error))
{
args = args.Take(i).Concat(args.Skip(i + 1)).ToArray();
screenId = rawScreenId;
@@ -139,15 +142,15 @@ namespace StardewModdingAPI.Framework
/// <param name="name">The command name.</param>
/// <param name="arguments">The command arguments.</param>
/// <returns>Returns whether a matching command was triggered.</returns>
- public bool Trigger(string name, string[] arguments)
+ public bool Trigger(string? name, string[] arguments)
{
// get normalized name
- name = this.GetNormalizedName(name);
- if (name == null)
+ name = this.GetNormalizedName(name)!;
+ if (string.IsNullOrWhiteSpace(name))
return false;
// get command
- if (this.Commands.TryGetValue(name, out Command command))
+ if (this.Commands.TryGetValue(name, out Command? command))
{
command.Callback.Invoke(name, arguments);
return true;
@@ -166,7 +169,7 @@ namespace StardewModdingAPI.Framework
{
bool inQuotes = false;
IList<string> args = new List<string>();
- StringBuilder currentArg = new StringBuilder();
+ StringBuilder currentArg = new();
foreach (char ch in input)
{
if (ch == '"')
@@ -190,7 +193,7 @@ namespace StardewModdingAPI.Framework
/// <param name="screen">The parsed screen ID, if any.</param>
/// <param name="error">The error which indicates an invalid screen ID, if applicable.</param>
/// <returns>Returns whether the screen ID was parsed successfully.</returns>
- private bool TryParseScreenId(string arg, out int screen, out string error)
+ private bool TryParseScreenId(string arg, out int screen, out string? error)
{
screen = -1;
error = null;
@@ -219,7 +222,7 @@ namespace StardewModdingAPI.Framework
/// <summary>Get a normalized command name.</summary>
/// <param name="name">The command name.</param>
- private string GetNormalizedName(string name)
+ private string? GetNormalizedName(string? name)
{
name = name?.Trim().ToLower();
return !string.IsNullOrWhiteSpace(name)
diff --git a/src/SMAPI/Framework/Commands/HarmonySummaryCommand.cs b/src/SMAPI/Framework/Commands/HarmonySummaryCommand.cs
index 45b34556..6dc6f131 100644
--- a/src/SMAPI/Framework/Commands/HarmonySummaryCommand.cs
+++ b/src/SMAPI/Framework/Commands/HarmonySummaryCommand.cs
@@ -30,7 +30,7 @@ namespace StardewModdingAPI.Framework.Commands
{
SearchResult[] matches = this.FilterPatches(args).OrderBy(p => p.MethodName).ToArray();
- StringBuilder result = new StringBuilder();
+ StringBuilder result = new();
if (!matches.Any())
result.AppendLine("No current patches match your search.");
@@ -71,9 +71,9 @@ namespace StardewModdingAPI.Framework.Commands
private IEnumerable<SearchResult> FilterPatches(string[] searchTerms)
{
bool hasSearch = searchTerms.Any();
- bool IsMatch(string target) => !hasSearch || searchTerms.Any(search => target != null && target.IndexOf(search, StringComparison.OrdinalIgnoreCase) > -1);
+ bool IsMatch(string? target) => !hasSearch || searchTerms.Any(search => target != null && target.IndexOf(search, StringComparison.OrdinalIgnoreCase) > -1);
- foreach (var patch in this.GetAllPatches())
+ foreach (SearchResult patch in this.GetAllPatches())
{
// matches entire patch
if (IsMatch(patch.MethodDescription))
@@ -83,10 +83,10 @@ namespace StardewModdingAPI.Framework.Commands
}
// matches individual patchers
- foreach (var pair in patch.PatchTypesByOwner.ToArray())
+ foreach ((string patcherId, ISet<PatchType> patchTypes) in patch.PatchTypesByOwner.ToArray())
{
- if (!IsMatch(pair.Key) && !pair.Value.Any(type => IsMatch(type.ToString())))
- patch.PatchTypesByOwner.Remove(pair.Key);
+ if (!IsMatch(patcherId) && !patchTypes.Any(type => IsMatch(type.ToString())))
+ patch.PatchTypesByOwner.Remove(patcherId);
}
if (patch.PatchTypesByOwner.Any())
@@ -112,13 +112,13 @@ namespace StardewModdingAPI.Framework.Commands
// get patch types by owner
var typesByOwner = new Dictionary<string, ISet<PatchType>>();
- foreach (var group in patchGroups)
+ foreach ((PatchType type, IReadOnlyCollection<Patch> patches) in patchGroups)
{
- foreach (var patch in group.Value)
+ foreach (Patch patch in patches)
{
- if (!typesByOwner.TryGetValue(patch.owner, out ISet<PatchType> patchTypes))
+ if (!typesByOwner.TryGetValue(patch.owner, out ISet<PatchType>? patchTypes))
typesByOwner[patch.owner] = patchTypes = new HashSet<PatchType>();
- patchTypes.Add(group.Key);
+ patchTypes.Add(type);
}
}
diff --git a/src/SMAPI/Framework/Commands/HelpCommand.cs b/src/SMAPI/Framework/Commands/HelpCommand.cs
index baf3116e..65dc3bce 100644
--- a/src/SMAPI/Framework/Commands/HelpCommand.cs
+++ b/src/SMAPI/Framework/Commands/HelpCommand.cs
@@ -39,7 +39,7 @@ namespace StardewModdingAPI.Framework.Commands
{
if (args.Any())
{
- Command result = this.CommandManager.Get(args[0]);
+ Command? result = this.CommandManager.Get(args[0]);
if (result == null)
monitor.Log("There's no command with that name. Type 'help' by itself for more info.", LogLevel.Error);
else
@@ -61,10 +61,10 @@ namespace StardewModdingAPI.Framework.Commands
+ "--------------\n"
+ "The following commands are registered. For more info about a command, type 'help command_name'.\n\n";
- IGrouping<string, string>[] groups = (from command in this.CommandManager.GetAll() orderby command.Mod?.DisplayName, command.Name group command.Name by command.Mod?.DisplayName).ToArray();
+ IGrouping<string, string>[] groups = (from command in this.CommandManager.GetAll() orderby command.Mod?.DisplayName, command.Name group command.Name by command.Mod?.DisplayName ?? "SMAPI").ToArray();
foreach (var group in groups)
{
- string modName = group.Key ?? "SMAPI";
+ string modName = group.Key;
string[] commandNames = group.ToArray();
message += $"{modName}:\n {string.Join("\n ", commandNames)}\n\n";
}
diff --git a/src/SMAPI/Framework/Content/AssetData.cs b/src/SMAPI/Framework/Content/AssetData.cs
index 5c90d83b..0367e999 100644
--- a/src/SMAPI/Framework/Content/AssetData.cs
+++ b/src/SMAPI/Framework/Content/AssetData.cs
@@ -5,12 +5,13 @@ namespace StardewModdingAPI.Framework.Content
/// <summary>Base implementation for a content helper which encapsulates access and changes to content being read from a data file.</summary>
/// <typeparam name="TValue">The interface value type.</typeparam>
internal class AssetData<TValue> : AssetInfo, IAssetData<TValue>
+ where TValue : notnull
{
/*********
** Fields
*********/
/// <summary>A callback to invoke when the data is replaced (if any).</summary>
- private readonly Action<TValue> OnDataReplaced;
+ private readonly Action<TValue>? OnDataReplaced;
/*********
@@ -25,11 +26,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..d9bfa7bf 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..97729c95 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..b8722ead 100644
--- a/src/SMAPI/Framework/Content/AssetDataForMap.cs
+++ b/src/SMAPI/Framework/Content/AssetDataForMap.cs
@@ -2,11 +2,14 @@ using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Xna.Framework;
+using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Toolkit.Utilities;
using StardewValley;
using xTile;
+using xTile.Dimensions;
using xTile.Layers;
using xTile.Tiles;
+using Rectangle = Microsoft.Xna.Framework.Rectangle;
namespace StardewModdingAPI.Framework.Content
{
@@ -14,16 +17,27 @@ namespace StardewModdingAPI.Framework.Content
internal class AssetDataForMap : AssetData<Map>, IAssetDataForMap
{
/*********
+ ** Fields
+ *********/
+ /// <summary>Simplifies access to private code.</summary>
+ private readonly Reflector Reflection;
+
+
+ /*********
** Public methods
*********/
/// <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)
- : base(locale, assetName, data, getNormalizedPath, onDataReplaced) { }
+ /// <param name="reflection">Simplifies access to private code.</param>
+ public AssetDataForMap(string? locale, IAssetName assetName, Map data, Func<string, string> getNormalizedPath, Action<Map> onDataReplaced, Reflector reflection)
+ : base(locale, assetName, data, getNormalizedPath, onDataReplaced)
+ {
+ this.Reflection = reflection;
+ }
/// <inheritdoc />
/// <remarks>Derived from <see cref="GameLocation.ApplyMapOverride(Map,string,Rectangle?,Rectangle?)"/> with a few changes:
@@ -85,8 +99,15 @@ namespace StardewModdingAPI.Framework.Content
}
// get target layers
- IDictionary<Layer, Layer> sourceToTargetLayers = source.Layers.ToDictionary(p => p, p => target.GetLayer(p.Id));
- HashSet<Layer> orphanedTargetLayers = new HashSet<Layer>(target.Layers.Except(sourceToTargetLayers.Values));
+ Dictionary<Layer, Layer> sourceToTargetLayers =
+ (
+ from sourceLayer in source.Layers
+ let targetLayer = target.GetLayer(sourceLayer.Id)
+ where targetLayer != null
+ select (sourceLayer, targetLayer)
+ )
+ .ToDictionary(p => p.sourceLayer, p => p.targetLayer);
+ HashSet<Layer> orphanedTargetLayers = new(target.Layers.Except(sourceToTargetLayers.Values));
// apply tiles
bool replaceAll = patchMode == PatchMapMode.Replace;
@@ -96,8 +117,8 @@ namespace StardewModdingAPI.Framework.Content
for (int y = 0; y < sourceArea.Value.Height; y++)
{
// calculate tile positions
- Point sourcePos = new Point(sourceArea.Value.X + x, sourceArea.Value.Y + y);
- Point targetPos = new Point(targetArea.Value.X + x, targetArea.Value.Y + y);
+ Point sourcePos = new(sourceArea.Value.X + x, sourceArea.Value.Y + y);
+ Point targetPos = new(targetArea.Value.X + x, targetArea.Value.Y + y);
// replace tiles on target-only layers
if (replaceAll)
@@ -110,8 +131,7 @@ namespace StardewModdingAPI.Framework.Content
foreach (Layer sourceLayer in source.Layers)
{
// get layer
- Layer targetLayer = sourceToTargetLayers[sourceLayer];
- if (targetLayer == null)
+ if (!sourceToTargetLayers.TryGetValue(sourceLayer, out Layer? targetLayer))
{
target.AddLayer(targetLayer = new Layer(sourceLayer.Id, target, target.Layers[0].LayerSize, Layer.m_tileSize));
sourceToTargetLayers[sourceLayer] = target.GetLayer(sourceLayer.Id);
@@ -121,11 +141,13 @@ namespace StardewModdingAPI.Framework.Content
targetLayer.Properties.CopyFrom(sourceLayer.Properties);
// create new tile
- Tile sourceTile = sourceLayer.Tiles[sourcePos.X, sourcePos.Y];
- Tile newTile = sourceTile != null
- ? this.CreateTile(sourceTile, targetLayer, tilesheetMap[sourceTile.TileSheet])
- : null;
- newTile?.Properties.CopyFrom(sourceTile.Properties);
+ Tile? sourceTile = sourceLayer.Tiles[sourcePos.X, sourcePos.Y];
+ Tile? newTile = null;
+ if (sourceTile != null)
+ {
+ newTile = this.CreateTile(sourceTile, targetLayer, tilesheetMap[sourceTile.TileSheet]);
+ newTile?.Properties.CopyFrom(sourceTile.Properties);
+ }
// replace tile
if (newTile != null || replaceByLayer || replaceAll)
@@ -135,6 +157,43 @@ namespace StardewModdingAPI.Framework.Content
}
}
+ /// <inheritdoc />
+ public bool ExtendMap(int minWidth = 0, int minHeight = 0)
+ {
+ bool resized = false;
+ Map map = this.Data;
+
+ // resize layers
+ foreach (Layer layer in map.Layers)
+ {
+ // check if resize needed
+ if (layer.LayerWidth >= minWidth && layer.LayerHeight >= minHeight)
+ continue;
+ resized = true;
+
+ // build new tile matrix
+ int width = Math.Max(minWidth, layer.LayerWidth);
+ int height = Math.Max(minHeight, layer.LayerHeight);
+ Tile[,] tiles = new Tile[width, height];
+ for (int x = 0; x < layer.LayerWidth; x++)
+ {
+ for (int y = 0; y < layer.LayerHeight; y++)
+ tiles[x, y] = layer.Tiles[x, y];
+ }
+
+ // update fields
+ this.Reflection.GetField<Tile[,]>(layer, "m_tiles").SetValue(tiles);
+ this.Reflection.GetField<TileArray>(layer, "m_tileArray").SetValue(new TileArray(layer, tiles));
+ this.Reflection.GetField<Size>(layer, "m_layerSize").SetValue(new Size(width, height));
+ }
+
+ // resize map
+ if (resized)
+ this.Reflection.GetMethod(map, "UpdateDisplaySize").Invoke();
+
+ return resized;
+ }
+
/*********
** Private methods
@@ -143,11 +202,11 @@ namespace StardewModdingAPI.Framework.Content
/// <param name="sourceTile">The source tile to copy.</param>
/// <param name="targetLayer">The target layer.</param>
/// <param name="targetSheet">The target tilesheet.</param>
- private Tile CreateTile(Tile sourceTile, Layer targetLayer, TileSheet targetSheet)
+ private Tile? CreateTile(Tile sourceTile, Layer targetLayer, TileSheet targetSheet)
{
switch (sourceTile)
{
- case StaticTile _:
+ case StaticTile:
return new StaticTile(targetLayer, targetSheet, sourceTile.BlendMode, sourceTile.TileIndex);
case AnimatedTile animatedTile:
@@ -168,7 +227,7 @@ namespace StardewModdingAPI.Framework.Content
}
/// <summary>Normalize a map tilesheet path for comparison. This value should *not* be used as the actual tilesheet path.</summary>
/// <param name="path">The path to normalize.</param>
- private string NormalizeTilesheetPathForComparison(string path)
+ private string NormalizeTilesheetPathForComparison(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return string.Empty;
diff --git a/src/SMAPI/Framework/Content/AssetDataForObject.cs b/src/SMAPI/Framework/Content/AssetDataForObject.cs
index b7e8dfeb..6c40f5f9 100644
--- a/src/SMAPI/Framework/Content/AssetDataForObject.cs
+++ b/src/SMAPI/Framework/Content/AssetDataForObject.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework.Graphics;
+using StardewModdingAPI.Framework.Reflection;
using xTile;
namespace StardewModdingAPI.Framework.Content
@@ -9,47 +10,61 @@ namespace StardewModdingAPI.Framework.Content
internal class AssetDataForObject : AssetData<object>, IAssetData
{
/*********
+ ** Fields
+ *********/
+ /// <summary>Simplifies access to private code.</summary>
+ private readonly Reflector Reflection;
+
+
+ /*********
** Public methods
*********/
/// <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)
- : base(locale, assetName, data, getNormalizedPath, onDataReplaced: null) { }
+ /// <param name="reflection">Simplifies access to private code.</param>
+ /// <param name="onDataReplaced">A callback to invoke when the data is replaced (if any).</param>
+ public AssetDataForObject(string? locale, IAssetName assetName, object data, Func<string, string> getNormalizedPath, Reflector reflection, Action<object>? onDataReplaced = null)
+ : base(locale, assetName, data, getNormalizedPath, onDataReplaced)
+ {
+ this.Reflection = reflection;
+ }
/// <summary>Construct an instance.</summary>
/// <param name="info">The asset metadata.</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(IAssetInfo info, object data, Func<string, string> getNormalizedPath)
- : this(info.Locale, info.AssetName, data, getNormalizedPath) { }
+ /// <param name="reflection">Simplifies access to private code.</param>
+ /// <param name="onDataReplaced">A callback to invoke when the data is replaced (if any).</param>
+ public AssetDataForObject(IAssetInfo info, object data, Func<string, string> getNormalizedPath, Reflector reflection, Action<object>? onDataReplaced = null)
+ : this(info.Locale, info.Name, data, getNormalizedPath, reflection, onDataReplaced) { }
/// <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, this.Reflection);
}
/// <inheritdoc />
public TData GetData<TData>()
{
- if (!(this.Data is TData))
+ if (this.Data is not TData data)
throw new InvalidCastException($"The content data of type {this.Data.GetType().FullName} can't be converted to the requested {typeof(TData).FullName}.");
- return (TData)this.Data;
+ return data;
}
}
}
diff --git a/src/SMAPI/Framework/Content/AssetEditOperation.cs b/src/SMAPI/Framework/Content/AssetEditOperation.cs
new file mode 100644
index 00000000..464948b0
--- /dev/null
+++ b/src/SMAPI/Framework/Content/AssetEditOperation.cs
@@ -0,0 +1,41 @@
+using System;
+using StardewModdingAPI.Events;
+
+namespace StardewModdingAPI.Framework.Content
+{
+ /// <summary>An edit to apply to an asset when it's requested from the content pipeline.</summary>
+ internal class AssetEditOperation
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod applying the edit.</summary>
+ public IModMetadata Mod { get; }
+
+ /// <summary>If there are multiple edits that apply to the same asset, the priority with which this one should be applied.</summary>
+ public AssetEditPriority Priority { get; }
+
+ /// <summary>The content pack on whose behalf the edit is being applied, if any.</summary>
+ public IModMetadata? OnBehalfOf { get; }
+
+ /// <summary>Apply the edit to an asset.</summary>
+ public Action<IAssetData> ApplyEdit { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="mod">The mod applying the edit.</param>
+ /// <param name="priority">If there are multiple edits that apply to the same asset, the priority with which this one should be applied.</param>
+ /// <param name="onBehalfOf">The content pack on whose behalf the edit is being applied, if any.</param>
+ /// <param name="applyEdit">Apply the edit to an asset.</param>
+ public AssetEditOperation(IModMetadata mod, AssetEditPriority priority, IModMetadata? onBehalfOf, Action<IAssetData> applyEdit)
+ {
+ this.Mod = mod;
+ this.Priority = priority;
+ this.OnBehalfOf = onBehalfOf;
+ this.ApplyEdit = applyEdit;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Content/AssetInfo.cs b/src/SMAPI/Framework/Content/AssetInfo.cs
index d8106439..363fffb3 100644
--- a/src/SMAPI/Framework/Content/AssetInfo.cs
+++ b/src/SMAPI/Framework/Content/AssetInfo.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework.Graphics;
+using StardewModdingAPI.Framework.Deprecations;
namespace StardewModdingAPI.Framework.Content
{
@@ -17,10 +18,35 @@ namespace StardewModdingAPI.Framework.Content
** Accessors
*********/
/// <inheritdoc />
- public string Locale { get; }
+ public string? Locale { get; }
/// <inheritdoc />
- public string AssetName { get; }
+ public IAssetName Name { get; }
+
+ /// <inheritdoc />
+ public IAssetName NameWithoutLocale { get; }
+
+ /// <inheritdoc />
+ [Obsolete($"Use {nameof(AssetInfo.Name)} or {nameof(AssetInfo.NameWithoutLocale)} instead. This property will be removed in SMAPI 4.0.0.")]
+ public string AssetName
+ {
+ get
+ {
+ SCore.DeprecationManager.Warn(
+ source: SCore.DeprecationManager.GetModFromStack(),
+ nounPhrase: $"{nameof(IAssetInfo)}.{nameof(IAssetInfo.AssetName)}",
+ version: "3.14.0",
+ severity: DeprecationLevel.Notice,
+ unlessStackIncludes: new[]
+ {
+ $"{typeof(AssetInterceptorChange).FullName}.{nameof(AssetInterceptorChange.CanIntercept)}",
+ $"{typeof(ContentCoordinator).FullName}.{nameof(ContentCoordinator.GetAssetOperations)}"
+ }
+ );
+
+ return this.NameWithoutLocale.Name;
+ }
+ }
/// <inheritdoc />
public Type DataType { get; }
@@ -31,22 +57,36 @@ 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.NameWithoutLocale = assetName.GetBaseAssetName();
this.DataType = type;
this.GetNormalizedPath = getNormalizedPath;
}
/// <inheritdoc />
+ [Obsolete($"Use {nameof(Name)}.{nameof(IAssetName.IsEquivalentTo)} or {nameof(AssetInfo.NameWithoutLocale)}.{nameof(IAssetName.IsEquivalentTo)} instead. This method will be removed in SMAPI 4.0.0.")]
public bool AssetNameEquals(string path)
{
- path = this.GetNormalizedPath(path);
- return this.AssetName.Equals(path, StringComparison.OrdinalIgnoreCase);
+ SCore.DeprecationManager.Warn(
+ source: SCore.DeprecationManager.GetModFromStack(),
+ nounPhrase: $"{nameof(IAssetInfo)}.{nameof(IAssetInfo.AssetNameEquals)}",
+ version: "3.14.0",
+ severity: DeprecationLevel.Notice,
+ unlessStackIncludes: new[]
+ {
+ $"{typeof(AssetInterceptorChange).FullName}.{nameof(AssetInterceptorChange.CanIntercept)}",
+ $"{typeof(ContentCoordinator).FullName}.{nameof(ContentCoordinator.GetAssetOperations)}"
+ }
+ );
+
+
+ return this.NameWithoutLocale.IsEquivalentTo(path);
}
@@ -75,7 +115,7 @@ namespace StardewModdingAPI.Framework.Content
return "string";
// default
- return type.FullName;
+ return type.FullName!;
}
}
}
diff --git a/src/SMAPI/Framework/Content/AssetInterceptorChange.cs b/src/SMAPI/Framework/Content/AssetInterceptorChange.cs
index 10488b84..fc8199e8 100644
--- a/src/SMAPI/Framework/Content/AssetInterceptorChange.cs
+++ b/src/SMAPI/Framework/Content/AssetInterceptorChange.cs
@@ -2,6 +2,7 @@ using System;
using System.Reflection;
using StardewModdingAPI.Internal;
+#pragma warning disable CS0618 // obsolete asset interceptors deliberately supported here
namespace StardewModdingAPI.Framework.Content
{
/// <summary>A wrapper for <see cref="IAssetEditor"/> and <see cref="IAssetLoader"/> for internal cache invalidation.</summary>
@@ -36,7 +37,7 @@ namespace StardewModdingAPI.Framework.Content
this.Instance = instance ?? throw new ArgumentNullException(nameof(instance));
this.WasAdded = wasAdded;
- if (!(instance is IAssetEditor) && !(instance is IAssetLoader))
+ if (instance is not (IAssetEditor or IAssetLoader))
throw new InvalidCastException($"The provided {nameof(instance)} value must be an {nameof(IAssetEditor)} or {nameof(IAssetLoader)} instance.");
}
@@ -44,11 +45,11 @@ namespace StardewModdingAPI.Framework.Content
/// <param name="asset">Basic metadata about the asset being loaded.</param>
public bool CanIntercept(IAssetInfo asset)
{
- MethodInfo canIntercept = this.GetType().GetMethod(nameof(this.CanInterceptImpl), BindingFlags.Instance | BindingFlags.NonPublic);
+ MethodInfo? canIntercept = this.GetType().GetMethod(nameof(this.CanInterceptImpl), BindingFlags.Instance | BindingFlags.NonPublic);
if (canIntercept == null)
throw new InvalidOperationException($"SMAPI couldn't access the {nameof(AssetInterceptorChange)}.{nameof(this.CanInterceptImpl)} implementation.");
- return (bool)canIntercept.MakeGenericMethod(asset.DataType).Invoke(this, new object[] { asset });
+ return (bool)canIntercept.MakeGenericMethod(asset.DataType).Invoke(this, new object[] { asset })!;
}
@@ -70,7 +71,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 +85,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/AssetLoadOperation.cs b/src/SMAPI/Framework/Content/AssetLoadOperation.cs
new file mode 100644
index 00000000..b6cdec27
--- /dev/null
+++ b/src/SMAPI/Framework/Content/AssetLoadOperation.cs
@@ -0,0 +1,41 @@
+using System;
+using StardewModdingAPI.Events;
+
+namespace StardewModdingAPI.Framework.Content
+{
+ /// <summary>An operation which provides the initial instance of an asset when it's requested from the content pipeline.</summary>
+ internal class AssetLoadOperation
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod loading the asset.</summary>
+ public IModMetadata Mod { get; }
+
+ /// <summary>The content pack on whose behalf the asset is being loaded, if any.</summary>
+ public IModMetadata? OnBehalfOf { get; }
+
+ /// <summary>If there are multiple loads that apply to the same asset, the priority with which this one should be applied.</summary>
+ public AssetLoadPriority Priority { get; }
+
+ /// <summary>Load the initial value for an asset.</summary>
+ public Func<IAssetInfo, object> GetData { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="mod">The mod applying the edit.</param>
+ /// <param name="priority">If there are multiple loads that apply to the same asset, the priority with which this one should be applied.</param>
+ /// <param name="onBehalfOf">The content pack on whose behalf the asset is being loaded, if any.</param>
+ /// <param name="getData">Load the initial value for an asset.</param>
+ public AssetLoadOperation(IModMetadata mod, AssetLoadPriority priority, IModMetadata? onBehalfOf, Func<IAssetInfo, object> getData)
+ {
+ this.Mod = mod;
+ this.Priority = priority;
+ this.OnBehalfOf = onBehalfOf;
+ this.GetData = getData;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Content/AssetName.cs b/src/SMAPI/Framework/Content/AssetName.cs
new file mode 100644
index 00000000..148354a1
--- /dev/null
+++ b/src/SMAPI/Framework/Content/AssetName.cs
@@ -0,0 +1,199 @@
+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 IsEquivalentTo(IAssetName? assetName, bool useBaseName = false)
+ {
+ if (useBaseName)
+ return this.BaseName.Equals(assetName?.BaseName, StringComparison.OrdinalIgnoreCase);
+
+ if (assetName is AssetName impl)
+ return this.ComparableName == impl.ComparableName;
+
+ return this.Name.Equals(assetName?.Name, 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;
+
+ string rawTrimmed = prefix.Trim();
+
+ // asset keys can't have a leading slash, but NormalizeAssetName will trim them
+ if (rawTrimmed.StartsWith('/') || rawTrimmed.StartsWith('\\'))
+ return false;
+
+ // normalize prefix
+ {
+ string normalized = PathUtilities.NormalizeAssetName(prefix);
+
+ // keep trailing slash
+ if (rawTrimmed.EndsWith('/') || rawTrimmed.EndsWith('\\'))
+ normalized += PathUtilities.PreferredAssetSeparator;
+
+ prefix = normalized;
+ }
+
+ // compare
+ if (prefix.Length == 0)
+ return true;
+
+ return
+ this.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
+ && (
+ 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)
+ );
+ }
+
+
+ /// <inheritdoc />
+ public bool IsDirectlyUnderPath(string? assetFolder)
+ {
+ if (assetFolder is null)
+ return false;
+
+ return this.StartsWith(assetFolder + "/", allowPartialWord: false, allowSubfolder: false);
+ }
+
+ /// <inheritdoc />
+ IAssetName IAssetName.GetBaseAssetName()
+ {
+ return this.LocaleCode == null
+ ? this
+ : new AssetName(this.BaseName, null, null);
+ }
+
+ /// <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/Content/AssetOperationGroup.cs b/src/SMAPI/Framework/Content/AssetOperationGroup.cs
new file mode 100644
index 00000000..a2fcb722
--- /dev/null
+++ b/src/SMAPI/Framework/Content/AssetOperationGroup.cs
@@ -0,0 +1,33 @@
+namespace StardewModdingAPI.Framework.Content
+{
+ /// <summary>A set of operations to apply to an asset for a given <see cref="IAssetEditor"/> or <see cref="IAssetLoader"/> implementation.</summary>
+ internal class AssetOperationGroup
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod applying the changes.</summary>
+ public IModMetadata Mod { get; }
+
+ /// <summary>The load operations to apply.</summary>
+ public AssetLoadOperation[] LoadOperations { get; }
+
+ /// <summary>The edit operations to apply.</summary>
+ public AssetEditOperation[] EditOperations { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="mod">The mod applying the changes.</param>
+ /// <param name="loadOperations">The load operations to apply.</param>
+ /// <param name="editOperations">The edit operations to apply.</param>
+ public AssetOperationGroup(IModMetadata mod, AssetLoadOperation[] loadOperations, AssetEditOperation[] editOperations)
+ {
+ this.Mod = mod;
+ this.LoadOperations = loadOperations;
+ this.EditOperations = editOperations;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Content/ContentCache.cs b/src/SMAPI/Framework/Content/ContentCache.cs
index 8e0c6228..736ee5da 100644
--- a/src/SMAPI/Framework/Content/ContentCache.cs
+++ b/src/SMAPI/Framework/Content/ContentCache.cs
@@ -1,10 +1,9 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.Contracts;
using System.Linq;
-using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Toolkit.Utilities;
-using StardewValley;
namespace StardewModdingAPI.Framework.Content
{
@@ -40,11 +39,10 @@ namespace StardewModdingAPI.Framework.Content
** Constructor
****/
/// <summary>Construct an instance.</summary>
- /// <param name="contentManager">The underlying content manager whose cache to manage.</param>
- /// <param name="reflection">Simplifies access to private game code.</param>
- public ContentCache(LocalizedContentManager contentManager, Reflector reflection)
+ /// <param name="loadedAssets">The asset cache for the underlying content manager.</param>
+ public ContentCache(Dictionary<string, object> loadedAssets)
{
- this.Cache = reflection.GetField<Dictionary<string, object>>(contentManager, "loadedAssets").GetValue();
+ this.Cache = loadedAssets;
}
/****
@@ -64,7 +62,8 @@ namespace StardewModdingAPI.Framework.Content
/// <summary>Normalize path separators in an asset name.</summary>
/// <param name="path">The file path to normalize.</param>
[Pure]
- public string NormalizePathSeparators(string path)
+ [return: NotNullIfNotNull("path")]
+ public string? NormalizePathSeparators(string? path)
{
return PathUtilities.NormalizeAssetName(path);
}
@@ -77,7 +76,7 @@ namespace StardewModdingAPI.Framework.Content
{
key = this.NormalizePathSeparators(key);
return key.EndsWith(".xnb", StringComparison.OrdinalIgnoreCase)
- ? key.Substring(0, key.Length - 4)
+ ? key[..^4]
: key;
}
@@ -91,7 +90,7 @@ namespace StardewModdingAPI.Framework.Content
public bool Remove(string key, bool dispose)
{
// get entry
- if (!this.Cache.TryGetValue(key, out object value))
+ if (!this.Cache.TryGetValue(key, out object? value))
return false;
// dispose & remove entry
diff --git a/src/SMAPI/Framework/Content/TilesheetReference.cs b/src/SMAPI/Framework/Content/TilesheetReference.cs
index 0919bb44..0339b802 100644
--- a/src/SMAPI/Framework/Content/TilesheetReference.cs
+++ b/src/SMAPI/Framework/Content/TilesheetReference.cs
@@ -1,4 +1,3 @@
-using System.Numerics;
using xTile.Dimensions;
namespace StardewModdingAPI.Framework.Content
diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs
index 99091f3e..84fff250 100644
--- a/src/SMAPI/Framework/ContentCoordinator.cs
+++ b/src/SMAPI/Framework/ContentCoordinator.cs
@@ -7,13 +7,17 @@ using System.Linq;
using System.Text;
using System.Threading;
using Microsoft.Xna.Framework.Content;
+using StardewModdingAPI.Events;
using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.ContentManagers;
using StardewModdingAPI.Framework.Reflection;
+using StardewModdingAPI.Framework.Utilities;
+using StardewModdingAPI.Internal;
using StardewModdingAPI.Metadata;
using StardewModdingAPI.Toolkit.Serialization;
using StardewModdingAPI.Toolkit.Utilities;
using StardewValley;
+using StardewValley.GameData;
using xTile;
namespace StardewModdingAPI.Framework
@@ -45,27 +49,36 @@ namespace StardewModdingAPI.Framework
/// <summary>A callback to invoke the first time *any* game content manager loads an asset.</summary>
private readonly Action OnLoadingFirstAsset;
- /// <summary>The loaded content managers (including the <see cref="MainContentManager"/>).</summary>
- private readonly IList<IContentManager> ContentManagers = new List<IContentManager>();
+ /// <summary>A callback to invoke when an asset is fully loaded.</summary>
+ private readonly Action<BaseContentManager, IAssetName> OnAssetLoaded;
+
+ /// <summary>A callback to invoke when any asset names have been invalidated from the cache.</summary>
+ private readonly Action<IList<IAssetName>> OnAssetsInvalidated;
+
+ /// <summary>Get the load/edit operations to apply to an asset by querying registered <see cref="IContentEvents.AssetRequested"/> event handlers.</summary>
+ private readonly Func<IAssetInfo, IList<AssetOperationGroup>> RequestAssetOperations;
- /// <summary>The language code for language-agnostic mod assets.</summary>
- private readonly LocalizedContentManager.LanguageCode DefaultLanguage = Constants.DefaultLanguage;
+ /// <summary>The loaded content managers (including the <see cref="MainContentManager"/>).</summary>
+ private readonly List<IContentManager> ContentManagers = new();
/// <summary>Whether the content coordinator has been disposed.</summary>
private bool IsDisposed;
/// <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;
+
+ /// <summary>The cached asset load/edit operations to apply, indexed by asset name.</summary>
+ private readonly TickCacheDictionary<IAssetName, AssetOperationGroup[]> AssetOperationsByKey = new();
/*********
@@ -78,9 +91,11 @@ namespace StardewModdingAPI.Framework
public LocalizedContentManager.LanguageCode Language => this.MainContentManager.Language;
/// <summary>Interceptors which provide the initial versions of matching assets.</summary>
+ [Obsolete]
public IList<ModLinked<IAssetLoader>> Loaders { get; } = new List<ModLinked<IAssetLoader>>();
/// <summary>Interceptors which edit matching assets after they're loaded.</summary>
+ [Obsolete]
public IList<ModLinked<IAssetEditor>> Editors { get; } = new List<ModLinked<IAssetEditor>>();
/// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary>
@@ -98,15 +113,21 @@ namespace StardewModdingAPI.Framework
/// <param name="reflection">Simplifies access to private code.</param>
/// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param>
/// <param name="onLoadingFirstAsset">A callback to invoke the first time *any* game content manager loads an asset.</param>
+ /// <param name="onAssetLoaded">A callback to invoke when an asset is fully loaded.</param>
/// <param name="aggressiveMemoryOptimizations">Whether to enable more aggressive memory optimizations.</param>
- public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, bool aggressiveMemoryOptimizations)
+ /// <param name="onAssetsInvalidated">A callback to invoke when any asset names have been invalidated from the cache.</param>
+ /// <param name="requestAssetOperations">Get the load/edit operations to apply to an asset by querying registered <see cref="IContentEvents.AssetRequested"/> event handlers.</param>
+ public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, Action<BaseContentManager, IAssetName> onAssetLoaded, bool aggressiveMemoryOptimizations, Action<IList<IAssetName>> onAssetsInvalidated, Func<IAssetInfo, IList<AssetOperationGroup>> requestAssetOperations)
{
this.AggressiveMemoryOptimizations = aggressiveMemoryOptimizations;
this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
this.Reflection = reflection;
this.JsonHelper = jsonHelper;
this.OnLoadingFirstAsset = onLoadingFirstAsset;
- this.FullRootDirectory = Path.Combine(Constants.ExecutionPath, rootDirectory);
+ this.OnAssetLoaded = onAssetLoaded;
+ this.OnAssetsInvalidated = onAssetsInvalidated;
+ this.RequestAssetOperations = requestAssetOperations;
+ this.FullRootDirectory = Path.Combine(Constants.GamePath, rootDirectory);
this.ContentManagers.Add(
this.MainContentManager = new GameContentManager(
name: "Game1.content",
@@ -118,6 +139,7 @@ namespace StardewModdingAPI.Framework
reflection: reflection,
onDisposing: this.OnDisposing,
onLoadingFirstAsset: onLoadingFirstAsset,
+ onAssetLoaded: onAssetLoaded,
aggressiveMemoryOptimizations: aggressiveMemoryOptimizations
)
);
@@ -131,12 +153,13 @@ namespace StardewModdingAPI.Framework
reflection: reflection,
onDisposing: this.OnDisposing,
onLoadingFirstAsset: onLoadingFirstAsset,
+ onAssetLoaded: onAssetLoaded,
aggressiveMemoryOptimizations: aggressiveMemoryOptimizations
);
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.CoreAssets = new CoreAssetPropagator(this.MainContentManager, contentManagerForAssetPropagation, this.Monitor, reflection, aggressiveMemoryOptimizations, name => this.ParseAssetName(name, allowLocales: true));
+ this.LocaleCodes = new Lazy<Dictionary<string, LocalizedContentManager.LanguageCode>>(() => this.GetLocaleCodes(customLanguages: Enumerable.Empty<ModLanguage>()));
}
/// <summary>Get a new content manager which handles reading files from the game content folder with support for interception.</summary>
@@ -145,7 +168,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,
@@ -155,6 +178,7 @@ namespace StardewModdingAPI.Framework
reflection: this.Reflection,
onDisposing: this.OnDisposing,
onLoadingFirstAsset: this.OnLoadingFirstAsset,
+ onAssetLoaded: this.OnAssetLoaded,
aggressiveMemoryOptimizations: this.AggressiveMemoryOptimizations
);
this.ContentManagers.Add(manager);
@@ -171,7 +195,7 @@ namespace StardewModdingAPI.Framework
{
return this.ContentManagerLock.InWriteLock(() =>
{
- ModContentManager manager = new ModContentManager(
+ ModContentManager manager = new(
name: name,
gameContentManager: gameContentManager,
serviceProvider: this.MainContentManager.ServiceProvider,
@@ -183,7 +207,8 @@ namespace StardewModdingAPI.Framework
reflection: this.Reflection,
jsonHelper: this.JsonHelper,
onDisposing: this.OnDisposing,
- aggressiveMemoryOptimizations: this.AggressiveMemoryOptimizations
+ aggressiveMemoryOptimizations: this.AggressiveMemoryOptimizations,
+ relativePathCache: CaseInsensitivePathLookup.GetCachedFor(rootDirectory)
);
this.ContentManagers.Add(manager);
return manager;
@@ -196,18 +221,21 @@ 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)
+ var customLanguages = this.MainContentManager.Load<List<ModLanguage?>>("Data/AdditionalLanguages");
+ this.LocaleCodes = new Lazy<Dictionary<string, LocalizedContentManager.LanguageCode>>(() => this.GetLocaleCodes(customLanguages));
+ _ = this.LocaleCodes.Value;
+ }
- // reload affected content
+ /// <summary>Perform any updates needed when the locale changes.</summary>
+ public void OnLocaleChanged()
+ {
+ // reset baseline cache
this.ContentManagerLock.InReadLock(() =>
{
- foreach (IContentManager contentManager in this.ContentManagers)
- contentManager.OnLocaleChanged();
-
this.VanillaContentManager.Unload();
});
}
@@ -239,12 +267,28 @@ namespace StardewModdingAPI.Framework
// Note that we *must* propagate changes here, otherwise when mods invalidate the cache later to reapply
// their changes, the assets won't be found in the cache so no changes will be propagated.
if (LocalizedContentManager.CurrentLanguageCode != LocalizedContentManager.LanguageCode.en)
- this.InvalidateCache((contentManager, key, type) => contentManager is GameContentManager);
+ this.InvalidateCache((contentManager, _, _) => contentManager is GameContentManager);
+ }
+
+ /// <summary>Parse a raw asset name.</summary>
+ /// <param name="rawName">The raw asset name to parse.</param>
+ /// <param name="allowLocales">Whether to parse locales in the <paramref name="rawName"/>. If this is false, any locale codes in the name are treated as if they were part of the base name (e.g. for mod files).</param>
+ /// <exception cref="ArgumentException">The <paramref name="rawName"/> is null or empty.</exception>
+ public AssetName ParseAssetName(string rawName, bool allowLocales)
+ {
+ return !string.IsNullOrWhiteSpace(rawName)
+ ? AssetName.Parse(
+ rawName: rawName,
+ parseLocale: allowLocales
+ ? locale => this.LocaleCodes.Value.TryGetValue(locale, out LocalizedContentManager.LanguageCode langCode) ? langCode : null
+ : _ => 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)
+ /// <param name="key">The asset name.</param>
+ public bool IsManagedAssetKey(IAssetName key)
{
return key.StartsWith(this.ManagedPrefix);
}
@@ -252,9 +296,9 @@ namespace StardewModdingAPI.Framework
/// <summary>Parse a managed SMAPI asset key which maps to a mod folder.</summary>
/// <param name="key">The asset key.</param>
/// <param name="contentManagerID">The unique name for the content manager which should load this asset.</param>
- /// <param name="relativePath">The relative path within the mod folder.</param>
+ /// <param name="relativePath">The asset name within the mod folder.</param>
/// <returns>Returns whether the asset was parsed successfully.</returns>
- public bool TryParseManagedAssetKey(string key, out string contentManagerID, out string relativePath)
+ public bool TryParseManagedAssetKey(string key, [NotNullWhen(true)] out string? contentManagerID, [NotNullWhen(true)] out IAssetName? relativePath)
{
contentManagerID = null;
relativePath = null;
@@ -268,7 +312,7 @@ namespace StardewModdingAPI.Framework
if (parts.Length != 3) // managed key prefix, mod id, relative path
return false;
contentManagerID = Path.Combine(parts[0], parts[1]);
- relativePath = parts[2];
+ relativePath = this.ParseAssetName(parts[2], allowLocales: false);
return true;
}
@@ -279,32 +323,52 @@ namespace StardewModdingAPI.Framework
return Path.Combine(this.ManagedPrefix, modID.ToLower());
}
+ /// <summary>Get whether an asset from a mod folder exists.</summary>
+ /// <typeparam name="T">The expected asset type.</typeparam>
+ /// <param name="contentManagerID">The unique name for the content manager which should load this asset.</param>
+ /// <param name="assetName">The asset name within the mod folder.</param>
+ public bool DoesManagedAssetExist<T>(string contentManagerID, IAssetName assetName)
+ where T : notnull
+ {
+ // get content manager
+ IContentManager? contentManager = this.ContentManagerLock.InReadLock(() =>
+ this.ContentManagers.FirstOrDefault(p => p.IsNamespaced && p.Name == contentManagerID)
+ );
+ if (contentManager == null)
+ throw new InvalidOperationException($"The '{contentManagerID}' prefix isn't handled by any mod.");
+
+ // get whether the asset exists
+ return contentManager.DoesAssetExist<T>(assetName);
+ }
+
/// <summary>Get a copy of an asset from a mod folder.</summary>
/// <typeparam name="T">The asset type.</typeparam>
/// <param name="contentManagerID">The unique name for the content manager which should load this asset.</param>
- /// <param name="relativePath">The internal SMAPI asset key.</param>
- public T LoadManagedAsset<T>(string contentManagerID, string relativePath)
+ /// <param name="relativePath">The asset name within the mod folder.</param>
+ public T LoadManagedAsset<T>(string contentManagerID, IAssetName relativePath)
+ where T : notnull
{
// get content manager
- IContentManager contentManager = this.ContentManagerLock.InReadLock(() =>
+ IContentManager? contentManager = this.ContentManagerLock.InReadLock(() =>
this.ContentManagers.FirstOrDefault(p => p.IsNamespaced && p.Name == contentManagerID)
);
if (contentManager == null)
throw new InvalidOperationException($"The '{contentManagerID}' prefix isn't handled by any mod.");
// get fresh asset
- return contentManager.Load<T>(relativePath, this.DefaultLanguage, useCache: false);
+ return contentManager.LoadExact<T>(relativePath, useCache: false);
}
/// <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>
/// <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((_, rawName, type) =>
{
+ IAssetName assetName = this.ParseAssetName(rawName, allowLocales: true);
IAssetInfo info = new AssetInfo(locale, assetName, type, this.MainContentManager.AssertAndNormalizeAssetName);
return predicate(info);
}, dispose);
@@ -314,19 +378,20 @@ 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> invalidatedAssets = new Dictionary<IAssetName, Type>();
this.ContentManagerLock.InReadLock(() =>
{
// cached assets
foreach (IContentManager contentManager in this.ContentManagers)
{
- foreach (var entry in contentManager.InvalidateCache((key, type) => predicate(contentManager, key, type), dispose))
+ foreach ((string key, object asset) 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(key, allowLocales: true);
+ if (!invalidatedAssets.ContainsKey(assetName))
+ invalidatedAssets[assetName] = asset.GetType();
}
}
@@ -340,31 +405,38 @@ 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)))
- removedAssets[mapPath] = typeof(Map);
+ AssetName mapPath = this.ParseAssetName(this.MainContentManager.AssertAndNormalizeAssetName(location.mapPath.Value), allowLocales: true);
+ if (!invalidatedAssets.ContainsKey(mapPath) && predicate(this.MainContentManager, mapPath.Name, typeof(Map)))
+ invalidatedAssets[mapPath] = typeof(Map);
}
}
});
- // reload core game assets
- if (removedAssets.Any())
+ // handle invalidation
+ if (invalidatedAssets.Any())
{
+ // clear cached editor checks
+ foreach (IAssetName name in invalidatedAssets.Keys)
+ this.AssetOperationsByKey.Remove(name);
+
+ // raise event
+ this.OnAssetsInvalidated(invalidatedAssets.Keys.ToArray());
+
// propagate changes to the game
this.CoreAssets.Propagate(
- assets: removedAssets.ToDictionary(p => p.Key, p => p.Value),
+ assets: invalidatedAssets.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 = invalidatedAssets.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
@@ -379,20 +451,32 @@ namespace StardewModdingAPI.Framework
else
this.Monitor.Log("Invalidated 0 cache entries.");
- return removedAssets.Keys;
+ return invalidatedAssets.Keys;
+ }
+
+ /// <summary>Get the asset load and edit operations to apply to a given asset if it's (re)loaded now.</summary>
+ /// <typeparam name="T">The asset type.</typeparam>
+ /// <param name="info">The asset info to load or edit.</param>
+ public IEnumerable<AssetOperationGroup> GetAssetOperations<T>(IAssetInfo info)
+ where T : notnull
+ {
+ return this.AssetOperationsByKey.GetOrSet(
+ info.Name,
+ () => this.GetAssetOperationsWithoutCache<T>(info).ToArray()
+ );
}
/// <summary>Get all loaded instances of an asset name.</summary>
/// <param name="assetName">The asset name.</param>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "This method is provided for Content Patcher.")]
- public IEnumerable<object> GetLoadedValues(string assetName)
+ public IEnumerable<object> GetLoadedValues(IAssetName assetName)
{
return this.ContentManagerLock.InReadLock(() =>
{
List<object> values = new List<object>();
- foreach (IContentManager content in this.ContentManagers.Where(p => !p.IsNamespaced && p.IsLoaded(assetName, p.Language)))
+ foreach (IContentManager content in this.ContentManagers.Where(p => !p.IsNamespaced && p.IsLoaded(assetName)))
{
- object value = content.Load<object>(assetName, this.Language, useCache: true);
+ object value = content.LoadExact<object>(assetName, useCache: true);
values.Add(value);
}
return values;
@@ -403,9 +487,9 @@ namespace StardewModdingAPI.Framework
/// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
public TilesheetReference[] GetVanillaTilesheetIds(string assetName)
{
- if (!this.VanillaTilesheets.TryGetValue(assetName, out TilesheetReference[] tilesheets))
+ if (!this.VanillaTilesheets.TryGetValue(assetName, out TilesheetReference[]? tilesheets))
{
- tilesheets = this.TryLoadVanillaAsset(assetName, out Map map)
+ tilesheets = this.TryLoadVanillaAsset(assetName, out Map? map)
? map.TileSheets.Select((sheet, index) => new TilesheetReference(index, sheet.Id, sheet.ImageSource, sheet.SheetSize, sheet.TileSize)).ToArray()
: null;
@@ -416,23 +500,14 @@ 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)
+ public string? GetLocaleCode(LocalizedContentManager.LanguageCode language)
{
if (language == LocalizedContentManager.LanguageCode.mod && LocalizedContentManager.CurrentModLanguage == null)
return null;
- return Game1.content.LanguageCodeString(language);
+ return this.MainContentManager.LanguageCodeString(language);
}
/// <summary>Dispose held resources.</summary>
@@ -446,7 +521,7 @@ namespace StardewModdingAPI.Framework
foreach (IContentManager contentManager in this.ContentManagers)
contentManager.Dispose();
this.ContentManagers.Clear();
- this.MainContentManager = null;
+ this.MainContentManager = null!; // instance no longer usable
this.ContentManagerLock.Dispose();
}
@@ -471,7 +546,8 @@ namespace StardewModdingAPI.Framework
/// <typeparam name="T">The type of asset to load.</typeparam>
/// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
/// <param name="asset">The loaded asset data.</param>
- private bool TryLoadVanillaAsset<T>(string assetName, out T asset)
+ private bool TryLoadVanillaAsset<T>(string assetName, [NotNullWhen(true)] out T? asset)
+ where T : notnull
{
try
{
@@ -486,17 +562,186 @@ 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="customLanguages">The custom languages to add to the lookup.</param>
+ private Dictionary<string, LocalizedContentManager.LanguageCode> GetLocaleCodes(IEnumerable<ModLanguage?> customLanguages)
{
- IDictionary<string, LocalizedContentManager.LanguageCode> map = new Dictionary<string, LocalizedContentManager.LanguageCode>();
+ var map = new Dictionary<string, LocalizedContentManager.LanguageCode>(StringComparer.OrdinalIgnoreCase);
+
+ // custom languages
+ foreach (ModLanguage? language in customLanguages)
+ {
+ 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);
+ string? locale = this.GetLocaleCode(code);
if (locale != null)
map[locale] = code;
}
return map;
}
+
+ /// <summary>Get the asset load and edit operations to apply to a given asset if it's (re)loaded now, ignoring the <see cref="AssetOperationsByKey"/> cache.</summary>
+ /// <typeparam name="T">The asset type.</typeparam>
+ /// <param name="info">The asset info to load or edit.</param>
+ private IEnumerable<AssetOperationGroup> GetAssetOperationsWithoutCache<T>(IAssetInfo info)
+ where T : notnull
+ {
+ IAssetInfo legacyInfo = this.GetLegacyAssetInfo(info);
+
+ // new content API
+ foreach (AssetOperationGroup group in this.RequestAssetOperations(info))
+ yield return group;
+
+ // legacy load operations
+#pragma warning disable CS0612, CS0618 // deprecated code
+ foreach (ModLinked<IAssetLoader> loader in this.Loaders)
+ {
+ // check if loader applies
+ try
+ {
+ if (!loader.Data.CanLoad<T>(legacyInfo))
+ continue;
+ }
+ catch (Exception ex)
+ {
+ loader.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{legacyInfo.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
+ continue;
+ }
+
+ // add operation
+ yield return new AssetOperationGroup(
+ mod: loader.Mod,
+ loadOperations: new[]
+ {
+ new AssetLoadOperation(
+ mod: loader.Mod,
+ priority: AssetLoadPriority.Exclusive,
+ onBehalfOf: null,
+ getData: assetInfo => loader.Data.Load<T>(
+ this.GetLegacyAssetInfo(assetInfo)
+ )
+ )
+ },
+ editOperations: Array.Empty<AssetEditOperation>()
+ );
+ }
+
+ // legacy edit operations
+ foreach (var editor in this.Editors)
+ {
+ // check if editor applies
+ try
+ {
+ if (!editor.Data.CanEdit<T>(legacyInfo))
+ continue;
+ }
+ catch (Exception ex)
+ {
+ editor.Mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{legacyInfo.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
+ continue;
+ }
+
+ // HACK
+ //
+ // If two editors have the same priority, they're applied in registration order (so
+ // whichever was registered first is applied first). Mods often depend on this
+ // behavior, like Json Assets registering its interceptors before Content Patcher.
+ //
+ // Unfortunately the old & new content APIs have separate lists, so new-API
+ // interceptors always ran before old-API interceptors with the same priority,
+ // regardless of the registration order *between* APIs. Since the new API works in
+ // a fundamentally different way (i.e. loads/edits are defined on asset request
+ // instead of by registering a global 'editor' or 'loader' class), there's no way
+ // to track registration order between them.
+ //
+ // Until we drop the old content API in SMAPI 4.0.0, this sets the priority for
+ // specific legacy editors to maintain compatibility.
+ AssetEditPriority priority = editor.Data.GetType().FullName switch
+ {
+ "JsonAssets.Framework.ContentInjector1" => AssetEditPriority.Default - 1, // must be applied before Content Patcher
+ _ => AssetEditPriority.Default
+ };
+
+ // add operation
+ yield return new AssetOperationGroup(
+ mod: editor.Mod,
+ loadOperations: Array.Empty<AssetLoadOperation>(),
+ editOperations: new[]
+ {
+ new AssetEditOperation(
+ mod: editor.Mod,
+ priority: priority,
+ onBehalfOf: null,
+ applyEdit: assetData => editor.Data.Edit<T>(
+ this.GetLegacyAssetData(assetData)
+ )
+ )
+ }
+ );
+ }
+#pragma warning restore CS0612, CS0618
+ }
+
+ /// <summary>Get an asset info compatible with legacy <see cref="IAssetLoader"/> and <see cref="IAssetEditor"/> instances, which always expect the base name.</summary>
+ /// <param name="asset">The asset info.</param>
+ private IAssetInfo GetLegacyAssetInfo(IAssetInfo asset)
+ {
+ return new AssetInfo(
+ locale: this.GetLegacyLocale(asset),
+ assetName: this.GetLegacyAssetName(asset.Name),
+ type: asset.DataType,
+ getNormalizedPath: this.MainContentManager.AssertAndNormalizeAssetName
+ );
+ }
+
+ /// <summary>Get an asset data compatible with legacy <see cref="IAssetLoader"/> and <see cref="IAssetEditor"/> instances, which always expect the base name.</summary>
+ /// <param name="asset">The asset data.</param>
+ private IAssetData GetLegacyAssetData(IAssetData asset)
+ {
+ return new AssetDataForObject(
+ locale: this.GetLegacyLocale(asset),
+ assetName: this.GetLegacyAssetName(asset.Name),
+ data: asset.Data,
+ getNormalizedPath: this.MainContentManager.AssertAndNormalizeAssetName,
+ reflection: this.Reflection,
+ onDataReplaced: asset.ReplaceWith
+ );
+ }
+
+ /// <summary>Get the <see cref="IAssetInfo.Locale"/> value compatible with legacy <see cref="IAssetLoader"/> and <see cref="IAssetEditor"/> instances, which expect the locale to default to the current game locale or an empty string.</summary>
+ /// <param name="asset">The non-legacy asset info to map.</param>
+ private string GetLegacyLocale(IAssetInfo asset)
+ {
+ return asset.Locale ?? this.GetLocale();
+ }
+
+ /// <summary>Get an asset name compatible with legacy <see cref="IAssetLoader"/> and <see cref="IAssetEditor"/> instances, which always expect the base name.</summary>
+ /// <param name="asset">The asset name to map.</param>
+ /// <returns>Returns the legacy asset name if needed, or the <paramref name="asset"/> if no change is needed.</returns>
+ private IAssetName GetLegacyAssetName(IAssetName asset)
+ {
+ // strip _international suffix
+ const string internationalSuffix = "_international";
+ if (asset.Name.EndsWith(internationalSuffix))
+ {
+ return new AssetName(
+ baseName: asset.Name[..^internationalSuffix.Length],
+ localeCode: null,
+ languageCode: null
+ );
+ }
+
+ // else strip locale
+ if (asset.LocaleCode != null)
+ return new AssetName(asset.BaseName, null, null);
+
+ // else no change needed
+ return asset;
+ }
}
}
diff --git a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs
index 5645c0fa..b2e3ec0f 100644
--- a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs
@@ -5,6 +5,7 @@ using System.Diagnostics.Contracts;
using System.Globalization;
using System.IO;
using System.Linq;
+using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;
using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.Exceptions;
@@ -29,9 +30,15 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <summary>Encapsulates monitoring and logging.</summary>
protected readonly IMonitor Monitor;
+ /// <summary>Simplifies access to private code.</summary>
+ protected readonly Reflector Reflection;
+
/// <summary>Whether to enable more aggressive memory optimizations.</summary>
protected readonly bool AggressiveMemoryOptimizations;
+ /// <summary>Whether to automatically try resolving keys to a localized form if available.</summary>
+ protected bool TryLocalizeKeys = true;
+
/// <summary>Whether the content coordinator has been disposed.</summary>
private bool IsDisposed;
@@ -39,7 +46,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
private readonly Action<BaseContentManager> OnDisposing;
/// <summary>A list of disposable assets.</summary>
- private readonly List<WeakReference<IDisposable>> Disposables = new List<WeakReference<IDisposable>>();
+ private readonly List<WeakReference<IDisposable>> Disposables = new();
/// <summary>The disposable assets tracked by the base content manager.</summary>
/// <remarks>This should be kept empty to avoid keeping disposable assets referenced forever, which prevents garbage collection when they're unused. Disposable assets are tracked by <see cref="Disposables"/> instead, which avoids a hard reference.</remarks>
@@ -56,7 +63,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; }
@@ -82,51 +89,97 @@ namespace StardewModdingAPI.Framework.ContentManagers
// init
this.Name = name;
this.Coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator));
- this.Cache = new ContentCache(this, reflection);
+ // ReSharper disable once VirtualMemberCallInConstructor -- LoadedAssets isn't overridden by SMAPI or Stardew Valley
+ this.Cache = new ContentCache(this.LoadedAssets);
this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
+ this.Reflection = reflection;
this.OnDisposing = onDisposing;
this.IsNamespaced = isNamespaced;
this.AggressiveMemoryOptimizations = aggressiveMemoryOptimizations;
// get asset data
- this.BaseDisposableReferences = reflection.GetField<List<IDisposable>>(this, "disposableAssets").GetValue();
+ this.BaseDisposableReferences = reflection.GetField<List<IDisposable>?>(this, "disposableAssets").GetValue()
+ ?? throw new InvalidOperationException("Can't initialize content manager: the required 'disposableAssets' field wasn't found.");
}
/// <inheritdoc />
- public override T Load<T>(string assetName)
+ public virtual bool DoesAssetExist<T>(IAssetName assetName)
+ where T : notnull
{
- return this.Load<T>(assetName, this.Language, useCache: true);
+ return this.Cache.ContainsKey(assetName.Name);
}
/// <inheritdoc />
- public override T Load<T>(string assetName, LanguageCode language)
+ [Obsolete("This method is implemented for the base game and should not be used directly. To load an asset from the underlying content manager directly, use " + nameof(BaseContentManager.RawLoad) + " instead.")]
+ public sealed override T LoadBase<T>(string assetName)
{
- return this.Load<T>(assetName, language, useCache: true);
+ return this.Load<T>(assetName, LanguageCode.en);
}
/// <inheritdoc />
- public abstract T Load<T>(string assetName, LocalizedContentManager.LanguageCode language, bool useCache);
-
- /// <inheritdoc />
- [Obsolete("This method is implemented for the base game and should not be used directly. To load an asset from the underlying content manager directly, use " + nameof(BaseContentManager.RawLoad) + " instead.")]
- public override T LoadBase<T>(string assetName)
+ public sealed override T Load<T>(string assetName)
{
- return this.Load<T>(assetName, LanguageCode.en, useCache: true);
+ return this.Load<T>(assetName, this.Language);
}
/// <inheritdoc />
- public virtual void OnLocaleChanged() { }
+ public sealed override T Load<T>(string assetName, LanguageCode language)
+ {
+ assetName = this.PrenormalizeRawAssetName(assetName);
+ IAssetName parsedName = this.Coordinator.ParseAssetName(assetName, allowLocales: this.TryLocalizeKeys);
+ return this.LoadLocalized<T>(parsedName, language, useCache: true);
+ }
/// <inheritdoc />
- [Pure]
- public string NormalizePathSeparators(string path)
+ public T LoadLocalized<T>(IAssetName assetName, LanguageCode language, bool useCache)
+ where T : notnull
{
- return this.Cache.NormalizePathSeparators(path);
+ // ignore locale in English (or if disabled)
+ if (!this.TryLocalizeKeys || language == LocalizedContentManager.LanguageCode.en)
+ return this.LoadExact<T>(assetName, useCache: useCache);
+
+ // check for localized asset
+ if (!LocalizedContentManager.localizedAssetNames.TryGetValue(assetName.Name, out _))
+ {
+ string localeCode = this.LanguageCodeString(language);
+ IAssetName localizedName = new AssetName(baseName: assetName.BaseName, localeCode: localeCode, languageCode: language);
+
+ try
+ {
+ T data = this.LoadExact<T>(localizedName, useCache: useCache);
+ LocalizedContentManager.localizedAssetNames[assetName.Name] = localizedName.Name;
+ return data;
+ }
+ catch (ContentLoadException)
+ {
+ localizedName = new AssetName(assetName.BaseName + "_international", null, null);
+ try
+ {
+ T data = this.LoadExact<T>(localizedName, useCache: useCache);
+ LocalizedContentManager.localizedAssetNames[assetName.Name] = localizedName.Name;
+ return data;
+ }
+ catch (ContentLoadException)
+ {
+ LocalizedContentManager.localizedAssetNames[assetName.Name] = assetName.Name;
+ }
+ }
+ }
+
+ // use cached key
+ string rawName = LocalizedContentManager.localizedAssetNames[assetName.Name];
+ if (assetName.Name != rawName)
+ assetName = this.Coordinator.ParseAssetName(rawName, allowLocales: this.TryLocalizeKeys);
+ return this.LoadExact<T>(assetName, useCache: useCache);
}
/// <inheritdoc />
+ public abstract T LoadExact<T>(IAssetName assetName, bool useCache)
+ where T : notnull;
+
+ /// <inheritdoc />
[SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")]
- public string AssertAndNormalizeAssetName(string assetName)
+ public string AssertAndNormalizeAssetName(string? assetName)
{
// NOTE: the game checks for ContentLoadException to handle invalid keys, so avoid
// throwing other types like ArgumentException here.
@@ -154,19 +207,11 @@ namespace StardewModdingAPI.Framework.ContentManagers
}
/// <inheritdoc />
- public bool IsLoaded(string assetName, LanguageCode language)
+ public bool IsLoaded(IAssetName assetName)
{
- assetName = this.Cache.NormalizeKey(assetName);
- return this.IsNormalizedKeyLoaded(assetName, language);
+ return this.Cache.ContainsKey(assetName.Name);
}
- /// <inheritdoc />
- public IEnumerable<string> GetAssetKeys()
- {
- return this.Cache.Keys
- .Select(this.GetAssetName)
- .Distinct();
- }
/****
** Cache invalidation
@@ -177,13 +222,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, allowLocales: this.TryLocalizeKeys).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;
}
@@ -211,7 +256,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
// dispose uncached assets
foreach (WeakReference<IDisposable> reference in this.Disposables)
{
- if (reference.TryGetTarget(out IDisposable disposable))
+ if (reference.TryGetTarget(out IDisposable? disposable))
{
try
{
@@ -241,78 +286,64 @@ namespace StardewModdingAPI.Framework.ContentManagers
/*********
** Private methods
*********/
+ /// <summary>Apply initial normalization to a raw asset name before it's parsed.</summary>
+ /// <param name="assetName">The asset name to normalize.</param>
+ [return: NotNullIfNotNull("assetName")]
+ private string? PrenormalizeRawAssetName(string? assetName)
+ {
+ // trim
+ assetName = assetName?.Trim();
+
+ // For legacy reasons, mods can pass .xnb file extensions to the content pipeline which
+ // are then stripped. This will be re-added as needed when reading from raw files.
+ if (assetName?.EndsWith(".xnb") == true)
+ assetName = assetName[..^".xnb".Length];
+
+ return assetName;
+ }
+
+ /// <summary>Normalize path separators in a file path. For asset keys, see <see cref="AssertAndNormalizeAssetName"/> instead.</summary>
+ /// <param name="path">The file path to normalize.</param>
+ [Pure]
+ [return: NotNullIfNotNull("path")]
+ protected string? NormalizePathSeparators(string? path)
+ {
+ return this.Cache.NormalizePathSeparators(path);
+ }
+
/// <summary>Load an asset file directly from the underlying content manager.</summary>
/// <typeparam name="T">The type of asset to load.</typeparam>
/// <param name="assetName">The normalized asset key.</param>
/// <param name="useCache">Whether to read/write the loaded asset to the asset cache.</param>
- protected virtual T RawLoad<T>(string assetName, bool useCache)
+ protected virtual T RawLoad<T>(IAssetName assetName, bool useCache)
{
return useCache
- ? base.LoadBase<T>(assetName)
- : base.ReadAsset<T>(assetName, disposable => this.Disposables.Add(new WeakReference<IDisposable>(disposable)));
+ ? base.LoadBase<T>(assetName.Name)
+ : this.ReadAsset<T>(assetName.Name, disposable => this.Disposables.Add(new WeakReference<IDisposable>(disposable)));
}
/// <summary>Add tracking data to an asset and add it to the cache.</summary>
/// <typeparam name="T">The type of asset to inject.</typeparam>
/// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
/// <param name="value">The asset value.</param>
- /// <param name="language">The language code for which to inject the asset.</param>
/// <param name="useCache">Whether to save the asset to the asset cache.</param>
- protected virtual void TrackAsset<T>(string assetName, T value, LanguageCode language, bool useCache)
+ protected virtual void TrackAsset<T>(IAssetName assetName, T value, bool useCache)
+ where T : notnull
{
// track asset key
if (value is Texture2D texture)
- texture.Name = assetName;
+ texture.Name = assetName.Name;
- // cache asset
+ // save to cache
+ // Note: even if the asset was loaded and cached right before this method was called,
+ // we need to fully re-inject it because a mod editor may have changed the asset in a
+ // way that doesn't change the instance stored in the cache, e.g. using
+ // `asset.ReplaceWith`.
if (useCache)
- {
- assetName = this.AssertAndNormalizeAssetName(assetName);
- this.Cache[assetName] = value;
- }
+ this.Cache[assetName.Name] = value;
// avoid hard disposable references; see remarks on the field
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..083df454 100644
--- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
@@ -1,13 +1,14 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
-using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;
+using StardewModdingAPI.Events;
using StardewModdingAPI.Framework.Content;
-using StardewModdingAPI.Framework.Exceptions;
+using StardewModdingAPI.Framework.Deprecations;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.Utilities;
using StardewModdingAPI.Internal;
@@ -24,16 +25,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
** Fields
*********/
/// <summary>The assets currently being intercepted by <see cref="IAssetLoader"/> instances. This is used to prevent infinite loops when a loader loads a new asset.</summary>
- private readonly ContextHash<string> AssetsBeingLoaded = new ContextHash<string>();
-
- /// <summary>Interceptors which provide the initial versions of matching assets.</summary>
- private IList<ModLinked<IAssetLoader>> Loaders => this.Coordinator.Loaders;
-
- /// <summary>Interceptors which edit matching assets after they're loaded.</summary>
- private IList<ModLinked<IAssetEditor>> Editors => this.Coordinator.Editors;
-
- /// <summary>Maps asset names to their localized form, like <c>LooseSprites\Billboard => LooseSprites\Billboard.fr-FR</c> (localized) or <c>Maps\AnimalShop => Maps\AnimalShop</c> (not localized).</summary>
- private IDictionary<string, string> LocalizedAssetNames => LocalizedContentManager.localizedAssetNames;
+ private readonly ContextHash<string> AssetsBeingLoaded = new();
/// <summary>Whether the next load is the first for any game content manager.</summary>
private static bool IsFirstLoad = true;
@@ -41,6 +33,9 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <summary>A callback to invoke the first time *any* game content manager loads an asset.</summary>
private readonly Action OnLoadingFirstAsset;
+ /// <summary>A callback to invoke when an asset is fully loaded.</summary>
+ private readonly Action<BaseContentManager, IAssetName> OnAssetLoaded;
+
/*********
** Public methods
@@ -55,15 +50,45 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <param name="reflection">Simplifies access to private code.</param>
/// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param>
/// <param name="onLoadingFirstAsset">A callback to invoke the first time *any* game content manager loads an asset.</param>
+ /// <param name="onAssetLoaded">A callback to invoke when an asset is fully loaded.</param>
/// <param name="aggressiveMemoryOptimizations">Whether to enable more aggressive memory optimizations.</param>
- public GameContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing, Action onLoadingFirstAsset, bool aggressiveMemoryOptimizations)
+ public GameContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing, Action onLoadingFirstAsset, Action<BaseContentManager, IAssetName> onAssetLoaded, bool aggressiveMemoryOptimizations)
: base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: false, aggressiveMemoryOptimizations: aggressiveMemoryOptimizations)
{
this.OnLoadingFirstAsset = onLoadingFirstAsset;
+ this.OnAssetLoaded = onAssetLoaded;
}
/// <inheritdoc />
- public override T Load<T>(string assetName, LocalizedContentManager.LanguageCode language, bool useCache)
+ public override bool DoesAssetExist<T>(IAssetName assetName)
+ {
+ if (base.DoesAssetExist<T>(assetName))
+ return true;
+
+ // vanilla asset
+ if (File.Exists(Path.Combine(this.RootDirectory, $"{assetName.Name}.xnb")))
+ return true;
+
+ // managed asset
+ if (this.Coordinator.TryParseManagedAssetKey(assetName.Name, out string? contentManagerID, out IAssetName? relativePath))
+ return this.Coordinator.DoesManagedAssetExist<T>(contentManagerID, relativePath);
+
+ // custom asset from a loader
+ string locale = this.GetLocale();
+ IAssetInfo info = new AssetInfo(locale, assetName, typeof(T), this.AssertAndNormalizeAssetName);
+ AssetLoadOperation[] loaders = this.GetLoaders<object>(info).ToArray();
+
+ if (!this.AssertMaxOneRequiredLoader(info, loaders, out string? error))
+ {
+ this.Monitor.Log(error, LogLevel.Warn);
+ return false;
+ }
+
+ return loaders.Any();
+ }
+
+ /// <inheritdoc />
+ public override T LoadExact<T>(IAssetName assetName, bool useCache)
{
// raise first-load callback
if (GameContentManager.IsFirstLoad)
@@ -72,71 +97,45 @@ namespace StardewModdingAPI.Framework.ContentManagers
this.OnLoadingFirstAsset();
}
- // normalize asset name
- assetName = this.AssertAndNormalizeAssetName(assetName);
- if (this.TryParseExplicitLanguageAssetKey(assetName, out string newAssetName, out LanguageCode newLanguage))
- return this.Load<T>(newAssetName, newLanguage, useCache);
-
// get from cache
- if (useCache && this.IsLoaded(assetName, language))
- return this.RawLoad<T>(assetName, language, useCache: true);
+ if (useCache && this.IsLoaded(assetName))
+ return this.RawLoad<T>(assetName, useCache: true);
// get managed asset
- if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath))
+ if (this.Coordinator.TryParseManagedAssetKey(assetName.Name, out string? contentManagerID, out IAssetName? relativePath))
{
T managedAsset = this.Coordinator.LoadManagedAsset<T>(contentManagerID, relativePath);
- this.TrackAsset(assetName, managedAsset, language, useCache);
+ this.TrackAsset(assetName, managedAsset, useCache);
return managedAsset;
}
// load asset
T data;
- if (this.AssetsBeingLoaded.Contains(assetName))
+ if (this.AssetsBeingLoaded.Contains(assetName.Name))
{
this.Monitor.Log($"Broke loop while loading asset '{assetName}'.", 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>(assetName, useCache);
}
else
{
- data = this.AssetsBeingLoaded.Track(assetName, () =>
+ data = this.AssetsBeingLoaded.Track(assetName.Name, () =>
{
- string locale = this.GetLocale(language);
- IAssetInfo info = new AssetInfo(locale, assetName, typeof(T), this.AssertAndNormalizeAssetName);
+ IAssetInfo info = new AssetInfo(assetName.LocaleCode, assetName, 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>(assetName, useCache), this.AssertAndNormalizeAssetName, this.Reflection);
asset = this.ApplyEditors<T>(info, asset);
return (T)asset.Data;
});
}
- // update cache & return data
- this.TrackAsset(assetName, data, language, useCache);
- return data;
- }
+ // update cache
+ this.TrackAsset(assetName, data, useCache);
- /// <inheritdoc />
- public override void OnLocaleChanged()
- {
- base.OnLocaleChanged();
-
- // 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);
-
- // invalidate translatable assets
- string[] invalidated = this
- .InvalidateCache((key, type) =>
- removeAssetNames.Contains(key)
- || (this.TryParseExplicitLanguageAssetKey(key, out string assetName, out _) && removeAssetNames.Contains(assetName))
- )
- .Select(p => p.Key)
- .OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
- .ToArray();
- if (invalidated.Any())
- this.Monitor.Log($"Invalidated {invalidated.Length} asset names: {string.Join(", ", invalidated)} for locale change.");
+ // raise event & return data
+ this.OnAssetLoaded(this, assetName);
+ return data;
}
/// <inheritdoc />
@@ -149,231 +148,101 @@ namespace StardewModdingAPI.Framework.ContentManagers
/*********
** Private methods
*********/
- /// <inheritdoc />
- protected override bool IsNormalizedKeyLoaded(string normalizedAssetName, LanguageCode language)
- {
- string cachedKey = null;
- bool localized =
- language != LocalizedContentManager.LanguageCode.en
- && !this.Coordinator.IsManagedAssetKey(normalizedAssetName)
- && this.LocalizedAssetNames.TryGetValue(normalizedAssetName, out cachedKey);
-
- return localized
- ? this.Cache.ContainsKey(cachedKey)
- : this.Cache.ContainsKey(normalizedAssetName);
- }
-
- /// <inheritdoc />
- protected override void TrackAsset<T>(string assetName, T value, LanguageCode language, bool useCache)
- {
- // handle explicit language in asset name
- {
- if (this.TryParseExplicitLanguageAssetKey(assetName, out string newAssetName, out LanguageCode newLanguage))
- {
- this.TrackAsset(newAssetName, value, newLanguage, useCache);
- return;
- }
- }
-
- // save to cache
- // Note: even if the asset was loaded and cached right before this method was called,
- // we need to fully re-inject it here for two reasons:
- // 1. So we can look up an asset by its base or localized key (the game/XNA logic
- // only caches by the most specific key).
- // 2. Because a mod asset loader/editor may have changed the asset in a way that
- // doesn't change the instance stored in the cache, e.g. using `asset.ReplaceWith`.
- if (useCache)
- {
- string translatedKey = $"{assetName}.{this.GetLocale(language)}";
- base.TrackAsset(assetName, value, language, useCache: true);
- if (this.Cache.ContainsKey(translatedKey))
- base.TrackAsset(translatedKey, value, language, useCache: true);
-
- // track whether the injected asset is translatable for is-loaded lookups
- if (this.Cache.ContainsKey(translatedKey))
- this.LocalizedAssetNames[assetName] = translatedKey;
- else if (this.Cache.ContainsKey(assetName))
- this.LocalizedAssetNames[assetName] = assetName;
- else
- this.Monitor.Log($"Asset '{assetName}' could not be found in the cache immediately after injection.", LogLevel.Error);
- }
- }
-
- /// <summary>Load an asset file directly from the underlying content manager.</summary>
- /// <typeparam name="T">The type of asset to load.</typeparam>
- /// <param name="assetName">The normalized asset key.</param>
- /// <param name="language">The language code for which to load content.</param>
- /// <param name="useCache">Whether to read/write the loaded asset to the asset cache.</param>
- /// <remarks>Derived from <see cref="LocalizedContentManager.Load{T}(string, LocalizedContentManager.LanguageCode)"/>.</remarks>
- private T RawLoad<T>(string assetName, LanguageCode language, bool useCache)
+ /// <summary>Load the initial asset from the registered 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>
+ private IAssetData? ApplyLoader<T>(IAssetInfo info)
+ where T : notnull
{
- try
+ // find matching loader
+ AssetLoadOperation? loader;
{
- // use cached key
- if (language == this.Language && this.LocalizedAssetNames.TryGetValue(assetName, out string cachedKey))
- return base.RawLoad<T>(cachedKey, useCache);
+ AssetLoadOperation[] loaders = this.GetLoaders<T>(info).OrderByDescending(p => p.Priority).ToArray();
- // try translated key
- if (language != LocalizedContentManager.LanguageCode.en)
+ if (!this.AssertMaxOneRequiredLoader(info, loaders, out string? error))
{
- string translatedKey = $"{assetName}.{this.GetLocale(language)}";
- try
- {
- T obj = base.RawLoad<T>(translatedKey, useCache);
- this.LocalizedAssetNames[assetName] = translatedKey;
- return obj;
- }
- catch (ContentLoadException)
- {
- this.LocalizedAssetNames[assetName] = assetName;
- }
+ this.Monitor.Log(error, LogLevel.Warn);
+ return null;
}
- // try base asset
- return base.RawLoad<T>(assetName, useCache);
+ loader = loaders.FirstOrDefault();
}
- catch (ContentLoadException ex) when (ex.InnerException is FileNotFoundException innerEx && innerEx.InnerException == null)
- {
- throw new SContentLoadException($"Error loading \"{assetName}\": it isn't in the Content folder and no mod provided it.");
- }
- }
- /// <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>
- private IAssetData ApplyLoader<T>(IAssetInfo info)
- {
- // find matching loaders
- var loaders = this.Loaders
- .Where(entry =>
- {
- try
- {
- return entry.Data.CanLoad<T>(info);
- }
- 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);
- return false;
- }
- })
- .ToArray();
-
- // validate loaders
- if (!loaders.Any())
+ // no loader found
+ if (loader == null)
return null;
- 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);
- return null;
- }
// fetch asset from loader
- IModMetadata mod = loaders[0].Mod;
- IAssetLoader loader = loaders[0].Data;
+ IModMetadata mod = loader.Mod;
T data;
try
{
- data = loader.Load<T>(info);
- this.Monitor.Log($"{mod.DisplayName} loaded asset '{info.AssetName}'.", LogLevel.Trace);
+ data = (T)loader.GetData(info);
+ this.Monitor.Log($"{mod.DisplayName} loaded asset '{info.Name}'{this.GetOnBehalfOfLabel(loader.OnBehalfOf)}.");
}
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}'{this.GetOnBehalfOfLabel(loader.OnBehalfOf)}. SMAPI will use the default asset instead. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
return null;
}
// return matched asset
- return this.TryFixAndValidateLoadedAsset(info, data, mod)
- ? new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName)
+ return this.TryFixAndValidateLoadedAsset(info, data, loader)
+ ? new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName, this.Reflection)
: null;
}
- /// <summary>Apply any <see cref="Editors"/> to a loaded asset.</summary>
+ /// <summary>Apply any editors to a loaded asset.</summary>
/// <typeparam name="T">The asset type.</typeparam>
/// <param name="info">The basic asset metadata.</param>
/// <param name="asset">The loaded asset.</param>
private IAssetData ApplyEditors<T>(IAssetInfo info, IAssetData asset)
+ where T : notnull
{
- IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName);
+ IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName, this.Reflection);
// special case: if the asset was loaded with a more general type like 'object', call editors with the actual type instead.
{
Type actualType = asset.Data.GetType();
- Type actualOpenType = actualType.IsGenericType ? actualType.GetGenericTypeDefinition() : null;
+ Type? actualOpenType = actualType.IsGenericType ? actualType.GetGenericTypeDefinition() : null;
if (typeof(T) != actualType && (actualOpenType == typeof(Dictionary<,>) || actualOpenType == typeof(List<>) || actualType == typeof(Texture2D) || actualType == typeof(Map)))
{
return (IAssetData)this.GetType()
- .GetMethod(nameof(this.ApplyEditors), BindingFlags.NonPublic | BindingFlags.Instance)
+ .GetMethod(nameof(this.ApplyEditors), BindingFlags.NonPublic | BindingFlags.Instance)!
.MakeGenericMethod(actualType)
- .Invoke(this, new object[] { info, asset });
+ .Invoke(this, new object[] { info, asset })!;
}
}
// edit asset
- foreach (var entry in this.Editors)
+ AssetEditOperation[] editors = this.GetEditors<T>(info).OrderBy(p => p.Priority).ToArray();
+ foreach (AssetEditOperation editor in editors)
{
- // check for match
- IModMetadata mod = entry.Mod;
- IAssetEditor editor = entry.Data;
- try
- {
- if (!editor.CanEdit<T>(info))
- continue;
- }
- 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);
- continue;
- }
+ IModMetadata mod = editor.Mod;
// try edit
object prevAsset = asset.Data;
try
{
- editor.Edit<T>(asset);
- this.Monitor.Log($"{mod.DisplayName} edited {info.AssetName}.");
+ editor.ApplyEdit(asset);
+ this.Monitor.Log($"{mod.DisplayName} edited {info.Name}{this.GetOnBehalfOfLabel(editor.OnBehalfOf)}.");
}
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}'{this.GetOnBehalfOfLabel(editor.OnBehalfOf)}, which may cause errors in-game. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
}
// validate edit
+ // ReSharper disable once ConditionIsAlwaysTrueOrFalse -- it's only guaranteed non-null after this method
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}'{this.GetOnBehalfOfLabel(editor.OnBehalfOf)} to a null value; ignoring override.", LogLevel.Warn);
asset = GetNewData(prevAsset);
}
- else if (!(asset.Data is T))
+ else if (asset.Data is not 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}'{this.GetOnBehalfOfLabel(editor.OnBehalfOf)} to incompatible type '{asset.Data.GetType()}', expected '{typeof(T)}'; ignoring override.", LogLevel.Warn);
asset = GetNewData(prevAsset);
}
}
@@ -382,32 +251,99 @@ namespace StardewModdingAPI.Framework.ContentManagers
return asset;
}
+ /// <summary>Get the asset loaders which handle an asset.</summary>
+ /// <typeparam name="T">The asset type.</typeparam>
+ /// <param name="info">The basic asset metadata.</param>
+ private IEnumerable<AssetLoadOperation> GetLoaders<T>(IAssetInfo info)
+ where T : notnull
+ {
+ return this.Coordinator
+ .GetAssetOperations<T>(info)
+ .SelectMany(p => p.LoadOperations);
+ }
+
+ /// <summary>Get the asset editors to apply to an asset.</summary>
+ /// <typeparam name="T">The asset type.</typeparam>
+ /// <param name="info">The basic asset metadata.</param>
+ private IEnumerable<AssetEditOperation> GetEditors<T>(IAssetInfo info)
+ where T : notnull
+ {
+ return this.Coordinator
+ .GetAssetOperations<T>(info)
+ .SelectMany(p => p.EditOperations);
+ }
+
+ /// <summary>Assert that at most one loader will be applied to an asset.</summary>
+ /// <param name="info">The basic asset metadata.</param>
+ /// <param name="loaders">The asset loaders to apply.</param>
+ /// <param name="error">The error message to show to the user, if the method returns false.</param>
+ /// <returns>Returns true if only one loader will apply, else false.</returns>
+ private bool AssertMaxOneRequiredLoader(IAssetInfo info, AssetLoadOperation[] loaders, [NotNullWhen(false)] out string? error)
+ {
+ AssetLoadOperation[] required = loaders.Where(p => p.Priority == AssetLoadPriority.Exclusive).ToArray();
+ if (required.Length <= 1)
+ {
+ error = null;
+ return true;
+ }
+
+ string[] loaderNames = required
+ .Select(p => p.Mod.DisplayName + this.GetOnBehalfOfLabel(p.OnBehalfOf))
+ .OrderBy(p => p)
+ .Distinct()
+ .ToArray();
+ string errorPhrase = loaderNames.Length > 1
+ ? $"Multiple mods want to provide the '{info.Name}' asset: {string.Join(", ", loaderNames)}"
+ : $"The '{loaderNames[0]}' mod wants to provide the '{info.Name}' asset multiple times";
+
+ error = $"{errorPhrase}. An asset can't be loaded multiple times, so 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.)";
+ return false;
+ }
+
+ /// <summary>Get a parenthetical label for log messages for the content pack on whose behalf the action is being performed, if any.</summary>
+ /// <param name="onBehalfOf">The content pack on whose behalf the action is being performed.</param>
+ /// <param name="parenthetical">whether to format the label as a parenthetical shown after the mod name like <c> (for the 'X' content pack)</c>, instead of a standalone label like <c>the 'X' content pack</c>.</param>
+ /// <returns>Returns the on-behalf-of label if applicable, else <c>null</c>.</returns>
+ [return: NotNullIfNotNull("onBehalfOf")]
+ private string? GetOnBehalfOfLabel(IModMetadata? onBehalfOf, bool parenthetical = true)
+ {
+ if (onBehalfOf == null)
+ return null;
+
+ return parenthetical
+ ? $" (for the '{onBehalfOf.Manifest.Name}' content pack)"
+ : $"the '{onBehalfOf.Manifest.Name}' content pack";
+ }
+
/// <summary>Validate that an asset loaded by a mod is valid and won't cause issues, and fix issues if possible.</summary>
/// <typeparam name="T">The asset type.</typeparam>
/// <param name="info">The basic asset metadata.</param>
/// <param name="data">The loaded asset data.</param>
- /// <param name="mod">The mod which loaded the asset.</param>
+ /// <param name="loader">The loader which loaded the asset.</param>
/// <returns>Returns whether the asset passed validation checks (after any fixes were applied).</returns>
- private bool TryFixAndValidateLoadedAsset<T>(IAssetInfo info, T data, IModMetadata mod)
+ private bool TryFixAndValidateLoadedAsset<T>(IAssetInfo info, [NotNullWhen(true)] T? data, AssetLoadOperation loader)
+ where T : notnull
{
+ IModMetadata mod = loader.Mod;
+
// 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}': {this.GetOnBehalfOfLabel(loader.OnBehalfOf, parenthetical: false) ?? "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}).");
+ 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.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 +353,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.";
+ string reason = $"{this.GetOnBehalfOfLabel(loader.OnBehalfOf, parenthetical: false) ?? "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 a '{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 a '{info.Name}' map load: {reason}", LogLevel.Warn);
}
}
}
diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManagerForAssetPropagation.cs b/src/SMAPI/Framework/ContentManagers/GameContentManagerForAssetPropagation.cs
index 61683ce6..1b0e1016 100644
--- a/src/SMAPI/Framework/ContentManagers/GameContentManagerForAssetPropagation.cs
+++ b/src/SMAPI/Framework/ContentManagers/GameContentManagerForAssetPropagation.cs
@@ -21,13 +21,13 @@ namespace StardewModdingAPI.Framework.ContentManagers
** Public methods
*********/
/// <inheritdoc />
- public GameContentManagerForAssetPropagation(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing, Action onLoadingFirstAsset, bool aggressiveMemoryOptimizations)
- : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, onLoadingFirstAsset, aggressiveMemoryOptimizations) { }
+ public GameContentManagerForAssetPropagation(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing, Action onLoadingFirstAsset, Action<BaseContentManager, IAssetName> onAssetLoaded, bool aggressiveMemoryOptimizations)
+ : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, onLoadingFirstAsset, onAssetLoaded, aggressiveMemoryOptimizations) { }
/// <inheritdoc />
- public override T Load<T>(string assetName, LanguageCode language, bool useCache)
+ public override T LoadExact<T>(IAssetName assetName, bool useCache)
{
- T data = base.Load<T>(assetName, language, useCache);
+ T data = base.LoadExact<T>(assetName, useCache);
if (data is Texture2D texture)
texture.Tag = this.Tag;
@@ -37,7 +37,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <summary>Get whether a texture was loaded by this content manager.</summary>
/// <param name="texture">The texture to check.</param>
- public bool IsResponsibleFor(Texture2D texture)
+ public bool IsResponsibleFor(Texture2D? texture)
{
return
texture?.Tag is string tag
diff --git a/src/SMAPI/Framework/ContentManagers/IContentManager.cs b/src/SMAPI/Framework/ContentManagers/IContentManager.cs
index d7963305..ac67cad5 100644
--- a/src/SMAPI/Framework/ContentManagers/IContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/IContentManager.cs
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
-using System.Diagnostics.Contracts;
using Microsoft.Xna.Framework.Content;
using StardewModdingAPI.Framework.Exceptions;
using StardewValley;
@@ -29,22 +28,31 @@ namespace StardewModdingAPI.Framework.ContentManagers
/*********
** Methods
*********/
- /// <summary>Load an asset that has been processed by the content pipeline.</summary>
+ /// <summary>Get whether an asset exists and can be loaded.</summary>
+ /// <typeparam name="T">The expected asset type.</typeparam>
+ /// <param name="assetName">The normalized asset name.</param>
+ bool DoesAssetExist<T>(IAssetName assetName)
+ where T: notnull;
+
+ /// <summary>Load an asset through the content pipeline, using a localized variant of the <paramref name="assetName"/> if available.</summary>
/// <typeparam name="T">The type of asset to load.</typeparam>
- /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
- /// <param name="language">The language code for which to load content.</param>
+ /// <param name="assetName">The asset name relative to the loader root directory.</param>
+ /// <param name="language">The language for which to load the asset.</param>
/// <param name="useCache">Whether to read/write the loaded asset to the asset cache.</param>
- T Load<T>(string assetName, LocalizedContentManager.LanguageCode language, bool useCache);
+ T LoadLocalized<T>(IAssetName assetName, LocalizedContentManager.LanguageCode language, bool useCache)
+ where T : notnull;
- /// <summary>Normalize path separators in a file path. For asset keys, see <see cref="AssertAndNormalizeAssetName"/> instead.</summary>
- /// <param name="path">The file path to normalize.</param>
- [Pure]
- string NormalizePathSeparators(string path);
+ /// <summary>Load an asset through the content pipeline, using the exact asset name without checking for localized variants.</summary>
+ /// <typeparam name="T">The type of asset to load.</typeparam>
+ /// <param name="assetName">The asset name relative to the loader root directory.</param>
+ /// <param name="useCache">Whether to read/write the loaded asset to the asset cache.</param>
+ T LoadExact<T>(IAssetName assetName, bool useCache)
+ where T : notnull;
/// <summary>Assert that the given key has a valid format and return a normalized form consistent with the underlying cache.</summary>
/// <param name="assetName">The asset key to check.</param>
/// <exception cref="SContentLoadException">The asset key is empty or contains invalid characters.</exception>
- string AssertAndNormalizeAssetName(string assetName);
+ string AssertAndNormalizeAssetName(string? assetName);
/// <summary>Get the current content locale.</summary>
string GetLocale();
@@ -55,19 +63,12 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <summary>Get whether the content manager has already loaded and cached the given asset.</summary>
/// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
- /// <param name="language">The language.</param>
- bool IsLoaded(string assetName, LocalizedContentManager.LanguageCode language);
-
- /// <summary>Get the cached asset keys.</summary>
- IEnumerable<string> GetAssetKeys();
+ bool IsLoaded(IAssetName assetName);
/// <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>
/// <returns>Returns the invalidated asset names and instances.</returns>
IDictionary<string, object> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false);
-
- /// <summary>Perform any cleanup needed when the locale changes.</summary>
- void OnLocaleChanged();
}
}
diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
index beb90a5d..8f64c5a8 100644
--- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
@@ -32,8 +32,8 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <summary>The game content manager used for map tilesheets not provided by the mod.</summary>
private readonly IContentManager GameContentManager;
- /// <summary>The language code for language-agnostic mod assets.</summary>
- private readonly LanguageCode DefaultLanguage = Constants.DefaultLanguage;
+ /// <summary>A case-insensitive lookup of relative paths within the <see cref="ContentManager.RootDirectory"/>.</summary>
+ private readonly CaseInsensitivePathLookup RelativePathCache;
/// <summary>If a map tilesheet's image source has no file extensions, the file extensions to check for in the local mod folder.</summary>
private readonly string[] LocalTilesheetExtensions = { ".png", ".xnb" };
@@ -55,33 +55,31 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param>
/// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param>
/// <param name="aggressiveMemoryOptimizations">Whether to enable more aggressive memory optimizations.</param>
- public ModContentManager(string name, IContentManager gameContentManager, IServiceProvider serviceProvider, string modName, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action<BaseContentManager> onDisposing, bool aggressiveMemoryOptimizations)
+ /// <param name="relativePathCache">A case-insensitive lookup of relative paths within the <paramref name="rootDirectory"/>.</param>
+ public ModContentManager(string name, IContentManager gameContentManager, IServiceProvider serviceProvider, string modName, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action<BaseContentManager> onDisposing, bool aggressiveMemoryOptimizations, CaseInsensitivePathLookup relativePathCache)
: base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: true, aggressiveMemoryOptimizations: aggressiveMemoryOptimizations)
{
this.GameContentManager = gameContentManager;
+ this.RelativePathCache = relativePathCache;
this.JsonHelper = jsonHelper;
this.ModName = modName;
- }
- /// <inheritdoc />
- public override T Load<T>(string assetName)
- {
- return this.Load<T>(assetName, this.DefaultLanguage, useCache: false);
+ this.TryLocalizeKeys = false;
}
/// <inheritdoc />
- public override T Load<T>(string assetName, LanguageCode language)
+ public override bool DoesAssetExist<T>(IAssetName assetName)
{
- return this.Load<T>(assetName, language, useCache: false);
+ if (base.DoesAssetExist<T>(assetName))
+ return true;
+
+ FileInfo file = this.GetModFile(assetName.Name);
+ return file.Exists;
}
/// <inheritdoc />
- public override T Load<T>(string assetName, LanguageCode language, bool useCache)
+ public override T LoadExact<T>(IAssetName assetName, bool useCache)
{
- // normalize key
- bool isXnbFile = Path.GetExtension(assetName).ToLower() == ".xnb";
- assetName = this.AssertAndNormalizeAssetName(assetName);
-
// disable caching
// This is necessary to avoid assets being shared between content managers, which can
// cause changes to an asset through one content manager affecting the same asset in
@@ -90,106 +88,43 @@ namespace StardewModdingAPI.Framework.ContentManagers
if (useCache)
throw new InvalidOperationException("Mod content managers don't support asset caching.");
- // disable language handling
- // Mod files don't support automatic translation logic, so this should never happen.
- if (language != this.DefaultLanguage)
- throw new InvalidOperationException("Localized assets aren't supported by the mod content manager.");
-
// resolve managed asset key
{
- if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath))
+ if (this.Coordinator.TryParseManagedAssetKey(assetName.Name, out string? contentManagerID, out IAssetName? relativePath))
{
if (contentManagerID != this.Name)
- throw new SContentLoadException($"Can't load managed asset key '{assetName}' through content manager '{this.Name}' for a different mod.");
+ throw this.GetLoadError(assetName, "can't load a different mod's managed asset key through this mod content manager.");
assetName = relativePath;
}
}
// get local asset
- SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading asset '{assetName}' 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(assetName.Name);
if (!file.Exists)
- throw GetContentError("the specified path doesn't exist.");
+ throw this.GetLoadError(assetName, "the specified path doesn't exist.");
// load content
- switch (file.Extension.ToLower())
+ asset = file.Extension.ToLower() switch
{
- // XNB file
- case ".xnb":
- {
- asset = this.RawLoad<T>(assetName, useCache: false);
- if (asset is Map map)
- {
- map.assetPath = assetName;
- this.FixTilesheetPaths(map, relativeMapPath: assetName, fixEagerPathPrefixes: true);
- }
- }
- break;
-
- // unpacked Bitmap font
- case ".fnt":
- {
- string source = File.ReadAllText(file.FullName);
- asset = (T)(object)new XmlSource(source);
- }
- break;
-
- // unpacked data
- case ".json":
- {
- if (!this.JsonHelper.ReadJsonFileIfExists(file.FullName, out asset))
- throw GetContentError("the JSON file is invalid."); // should never happen since we check for file existence above
- }
- break;
-
- // unpacked image
- case ".png":
- {
- // validate
- if (typeof(T) != typeof(Texture2D))
- throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'.");
-
- // fetch & cache
- using FileStream stream = File.OpenRead(file.FullName);
-
- Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream);
- texture = this.PremultiplyTransparency(texture);
- asset = (T)(object)texture;
- }
- break;
-
- // unpacked map
- case ".tbin":
- case ".tmx":
- {
- // validate
- if (typeof(T) != typeof(Map))
- throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'.");
-
- // fetch & cache
- FormatManager formatManager = FormatManager.Instance;
- Map map = formatManager.LoadMap(file.FullName);
- map.assetPath = assetName;
- this.FixTilesheetPaths(map, relativeMapPath: assetName, fixEagerPathPrefixes: false);
- asset = (T)(object)map;
- }
- break;
-
- default:
- throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.fnt', '.json', '.png', '.tbin', or '.xnb'.");
- }
+ ".fnt" => this.LoadFont<T>(assetName, file),
+ ".json" => this.LoadDataFile<T>(assetName, file),
+ ".png" => this.LoadImageFile<T>(assetName, file),
+ ".tbin" or ".tmx" => this.LoadMapFile<T>(assetName, file),
+ ".xnb" => this.LoadXnbFile<T>(assetName),
+ _ => this.HandleUnknownFileType<T>(assetName, file)
+ };
}
- catch (Exception ex) when (!(ex is SContentLoadException))
+ catch (Exception ex) when (ex is not SContentLoadException)
{
- throw new SContentLoadException($"The content manager failed loading content asset '{assetName}' from {this.Name}.", ex);
+ throw this.GetLoadError(assetName, "an unexpected occurred.", ex);
}
// track & return asset
- this.TrackAsset(assetName, asset, language, useCache);
+ this.TrackAsset(assetName, asset, useCache: false);
return asset;
}
@@ -202,36 +137,137 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <summary>Get the underlying key in the game's content cache for an asset. This does not validate whether the asset exists.</summary>
/// <param name="key">The local path to a content file relative to the mod folder.</param>
/// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception>
- public string GetInternalAssetKey(string key)
+ public IAssetName GetInternalAssetKey(string key)
{
FileInfo file = this.GetModFile(key);
- string relativePath = PathUtilities.GetRelativePath(this.RootDirectory, file.FullName);
- return Path.Combine(this.Name, relativePath);
+ string relativePath = Path.GetRelativePath(this.RootDirectory, file.FullName);
+ string internalKey = Path.Combine(this.Name, relativePath);
+
+ return this.Coordinator.ParseAssetName(internalKey, allowLocales: false);
}
/*********
** Private methods
*********/
- /// <inheritdoc />
- protected override bool IsNormalizedKeyLoaded(string normalizedAssetName, LanguageCode language)
+ /// <summary>Load an unpacked font file (<c>.fnt</c>).</summary>
+ /// <typeparam name="T">The type of asset to load.</typeparam>
+ /// <param name="assetName">The asset name relative to the loader root directory.</param>
+ /// <param name="file">The file to load.</param>
+ private T LoadFont<T>(IAssetName assetName, FileInfo file)
{
- return this.Cache.ContainsKey(normalizedAssetName);
+ // validate
+ if (!typeof(T).IsAssignableFrom(typeof(XmlSource)))
+ throw this.GetLoadError(assetName, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(XmlSource)}'.");
+
+ // load
+ string source = File.ReadAllText(file.FullName);
+ return (T)(object)new XmlSource(source);
+ }
+
+ /// <summary>Load an unpacked data file (<c>.json</c>).</summary>
+ /// <typeparam name="T">The type of asset to load.</typeparam>
+ /// <param name="assetName">The asset name relative to the loader root directory.</param>
+ /// <param name="file">The file to load.</param>
+ private T LoadDataFile<T>(IAssetName assetName, FileInfo file)
+ {
+ if (!this.JsonHelper.ReadJsonFileIfExists(file.FullName, out T? asset))
+ throw this.GetLoadError(assetName, "the JSON file is invalid."); // should never happen since we check for file existence before calling this method
+
+ return asset;
+ }
+
+ /// <summary>Load an unpacked image file (<c>.json</c>).</summary>
+ /// <typeparam name="T">The type of asset to load.</typeparam>
+ /// <param name="assetName">The asset name relative to the loader root directory.</param>
+ /// <param name="file">The file to load.</param>
+ private T LoadImageFile<T>(IAssetName assetName, FileInfo file)
+ {
+ // validate
+ if (typeof(T) != typeof(Texture2D))
+ throw this.GetLoadError(assetName, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'.");
+
+ // load
+ using FileStream stream = File.OpenRead(file.FullName);
+ Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream);
+ texture = this.PremultiplyTransparency(texture);
+ return (T)(object)texture;
+ }
+
+ /// <summary>Load an unpacked image file (<c>.tbin</c> or <c>.tmx</c>).</summary>
+ /// <typeparam name="T">The type of asset to load.</typeparam>
+ /// <param name="assetName">The asset name relative to the loader root directory.</param>
+ /// <param name="file">The file to load.</param>
+ private T LoadMapFile<T>(IAssetName assetName, FileInfo file)
+ {
+ // validate
+ if (typeof(T) != typeof(Map))
+ throw this.GetLoadError(assetName, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'.");
+
+ // load
+ FormatManager formatManager = FormatManager.Instance;
+ Map map = formatManager.LoadMap(file.FullName);
+ map.assetPath = assetName.Name;
+ this.FixTilesheetPaths(map, relativeMapPath: assetName.Name, fixEagerPathPrefixes: false);
+ return (T)(object)map;
+ }
+
+ /// <summary>Load a packed file (<c>.xnb</c>).</summary>
+ /// <typeparam name="T">The type of asset to load.</typeparam>
+ /// <param name="assetName">The asset name relative to the loader root directory.</param>
+ private T LoadXnbFile<T>(IAssetName assetName)
+ {
+ // the underlying content manager adds a .xnb extension implicitly, so
+ // we need to strip it here to avoid trying to load a '.xnb.xnb' file.
+ IAssetName loadName = assetName.Name.EndsWith(".xnb", StringComparison.OrdinalIgnoreCase)
+ ? this.Coordinator.ParseAssetName(assetName.Name[..^".xnb".Length], allowLocales: false)
+ : assetName;
+
+ // load asset
+ T asset = this.RawLoad<T>(loadName, useCache: false);
+ if (asset is Map map)
+ {
+ map.assetPath = loadName.Name;
+ this.FixTilesheetPaths(map, relativeMapPath: loadName.Name, fixEagerPathPrefixes: true);
+ }
+
+ return asset;
+ }
+
+ /// <summary>Handle a request to load a file type that isn't supported by SMAPI.</summary>
+ /// <typeparam name="T">The expected file type.</typeparam>
+ /// <param name="assetName">The asset name relative to the loader root directory.</param>
+ /// <param name="file">The file to load.</param>
+ private T HandleUnknownFileType<T>(IAssetName assetName, FileInfo file)
+ {
+ throw this.GetLoadError(assetName, $"unknown file extension '{file.Extension}'; must be one of '.fnt', '.json', '.png', '.tbin', '.tmx', or '.xnb'.");
+ }
+
+ /// <summary>Get an error which indicates that an asset couldn't be loaded.</summary>
+ /// <param name="assetName">The asset name that failed to load.</param>
+ /// <param name="reasonPhrase">The reason the file couldn't be loaded.</param>
+ /// <param name="exception">The underlying exception, if applicable.</param>
+ private SContentLoadException GetLoadError(IAssetName assetName, string reasonPhrase, Exception? exception = null)
+ {
+ return new($"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}", exception);
}
/// <summary>Get a file from the mod folder.</summary>
/// <param name="path">The asset path relative to the content folder.</param>
private FileInfo GetModFile(string path)
{
+ // map to case-insensitive path if needed
+ path = this.RelativePathCache.GetFilePath(path);
+
// try exact match
- FileInfo file = new FileInfo(Path.Combine(this.FullRootDirectory, path));
+ FileInfo file = new(Path.Combine(this.FullRootDirectory, path));
// try with default extension
- if (!file.Exists && file.Extension == string.Empty)
+ if (!file.Exists)
{
foreach (string extension in this.LocalTilesheetExtensions)
{
- FileInfo result = new FileInfo(file.FullName + extension);
+ FileInfo result = new(file.FullName + extension);
if (result.Exists)
{
file = result;
@@ -252,16 +288,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;
}
@@ -296,15 +336,18 @@ namespace StardewModdingAPI.Framework.ContentManagers
// load best match
try
{
- if (!this.TryGetTilesheetAssetName(relativeMapFolder, imageSource, out string assetName, out string error))
+ if (!this.TryGetTilesheetAssetName(relativeMapFolder, imageSource, out IAssetName? assetName, out string? error))
throw new SContentLoadException($"{errorPrefix} {error}");
- if (assetName != tilesheet.ImageSource)
- this.Monitor.VerboseLog($" Mapped tilesheet '{tilesheet.ImageSource}' to '{assetName}'.");
+ if (assetName is not null)
+ {
+ if (!assetName.IsEquivalentTo(tilesheet.ImageSource))
+ this.Monitor.VerboseLog($" Mapped tilesheet '{tilesheet.ImageSource}' to '{assetName}'.");
- tilesheet.ImageSource = assetName;
+ tilesheet.ImageSource = assetName.Name;
+ }
}
- catch (Exception ex) when (!(ex is SContentLoadException))
+ catch (Exception ex) when (ex is not SContentLoadException)
{
throw new SContentLoadException($"{errorPrefix} The tilesheet couldn't be loaded.", ex);
}
@@ -318,7 +361,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <param name="error">A message indicating why the file couldn't be loaded.</param>
/// <returns>Returns whether the asset name was found.</returns>
/// <remarks>See remarks on <see cref="FixTilesheetPaths"/>.</remarks>
- private bool TryGetTilesheetAssetName(string modRelativeMapFolder, string relativePath, out string assetName, out string error)
+ private bool TryGetTilesheetAssetName(string modRelativeMapFolder, string relativePath, out IAssetName? assetName, out string? error)
{
assetName = null;
error = null;
@@ -326,7 +369,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
// nothing to do
if (string.IsNullOrWhiteSpace(relativePath))
{
- assetName = relativePath;
+ assetName = null;
return true;
}
@@ -350,10 +393,10 @@ namespace StardewModdingAPI.Framework.ContentManagers
}
// get from game assets
- string contentKey = this.GetContentKeyForTilesheetImageSource(relativePath);
+ IAssetName contentKey = this.Coordinator.ParseAssetName(this.GetContentKeyForTilesheetImageSource(relativePath), allowLocales: false);
try
{
- this.GameContentManager.Load<Texture2D>(contentKey, this.Language, useCache: true); // no need to bypass cache here, since we're not storing the asset
+ this.GameContentManager.LoadLocalized<Texture2D>(contentKey, this.GameContentManager.Language, useCache: true); // no need to bypass cache here, since we're not storing the asset
assetName = contentKey;
return true;
}
@@ -366,7 +409,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
// if the content file doesn't exist, that doesn't mean the error here is a
// content-not-found error. Unfortunately XNA doesn't provide a good way to
// detect the error type.
- if (this.GetContentFolderFileExists(contentKey))
+ if (this.GetContentFolderFileExists(contentKey.Name))
throw;
}
diff --git a/src/SMAPI/Framework/ContentPack.cs b/src/SMAPI/Framework/ContentPack.cs
index b6add7b5..dde33c95 100644
--- a/src/SMAPI/Framework/ContentPack.cs
+++ b/src/SMAPI/Framework/ContentPack.cs
@@ -1,5 +1,4 @@
using System;
-using System.Collections.Generic;
using System.IO;
using StardewModdingAPI.Framework.ModHelpers;
using StardewModdingAPI.Toolkit.Serialization;
@@ -13,14 +12,11 @@ namespace StardewModdingAPI.Framework
/*********
** Fields
*********/
- /// <summary>Provides an API for loading content assets.</summary>
- private readonly IContentHelper Content;
-
/// <summary>Encapsulates SMAPI's JSON file parsing.</summary>
private readonly JsonHelper JsonHelper;
- /// <summary>A cache of case-insensitive => exact relative paths within the content pack, for case-insensitive file lookups on Linux/macOS.</summary>
- private readonly IDictionary<string, string> RelativePaths = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+ /// <summary>A case-insensitive lookup of relative paths within the <see cref="DirectoryPath"/>.</summary>
+ private readonly CaseInsensitivePathLookup RelativePathCache;
/*********
@@ -35,6 +31,9 @@ namespace StardewModdingAPI.Framework
/// <inheritdoc />
public ITranslationHelper Translation => this.TranslationImpl;
+ /// <inheritdoc />
+ public IModContentHelper ModContent { get; }
+
/// <summary>The underlying translation helper.</summary>
internal TranslationHelper TranslationImpl { get; set; }
@@ -45,22 +44,18 @@ namespace StardewModdingAPI.Framework
/// <summary>Construct an instance.</summary>
/// <param name="directoryPath">The full path to the content pack's folder.</param>
/// <param name="manifest">The content pack's manifest.</param>
- /// <param name="content">Provides an API for loading content assets.</param>
+ /// <param name="content">Provides an API for loading content assets from the content pack's folder.</param>
/// <param name="translation">Provides translations stored in the content pack's <c>i18n</c> folder.</param>
/// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param>
- public ContentPack(string directoryPath, IManifest manifest, IContentHelper content, TranslationHelper translation, JsonHelper jsonHelper)
+ /// <param name="relativePathCache">A case-insensitive lookup of relative paths within the <paramref name="directoryPath"/>.</param>
+ public ContentPack(string directoryPath, IManifest manifest, IModContentHelper content, TranslationHelper translation, JsonHelper jsonHelper, CaseInsensitivePathLookup relativePathCache)
{
this.DirectoryPath = directoryPath;
this.Manifest = manifest;
- this.Content = content;
+ this.ModContent = content;
this.TranslationImpl = translation;
this.JsonHelper = jsonHelper;
-
- foreach (string path in Directory.EnumerateFiles(this.DirectoryPath, "*", SearchOption.AllDirectories))
- {
- string relativePath = path.Substring(this.DirectoryPath.Length + 1);
- this.RelativePaths[relativePath] = relativePath;
- }
+ this.RelativePathCache = relativePathCache;
}
/// <inheritdoc />
@@ -72,12 +67,12 @@ namespace StardewModdingAPI.Framework
}
/// <inheritdoc />
- public TModel ReadJsonFile<TModel>(string path) where TModel : class
+ public TModel? ReadJsonFile<TModel>(string path) where TModel : class
{
path = PathUtilities.NormalizePath(path);
FileInfo file = this.GetFile(path);
- return file.Exists && this.JsonHelper.ReadJsonFileIfExists(file.FullName, out TModel model)
+ return file.Exists && this.JsonHelper.ReadJsonFileIfExists(file.FullName, out TModel? model)
? model
: null;
}
@@ -90,44 +85,28 @@ namespace StardewModdingAPI.Framework
FileInfo file = this.GetFile(path, out path);
this.JsonHelper.WriteJsonFile(file.FullName, data);
- if (!this.RelativePaths.ContainsKey(path))
- this.RelativePaths[path] = path;
+ this.RelativePathCache.Add(path);
}
/// <inheritdoc />
+ [Obsolete]
public T LoadAsset<T>(string key)
+ where T : notnull
{
- key = PathUtilities.NormalizePath(key);
-
- key = this.GetCaseInsensitiveRelativePath(key);
- return this.Content.Load<T>(key, ContentSource.ModFolder);
+ return this.ModContent.Load<T>(key);
}
/// <inheritdoc />
+ [Obsolete]
public string GetActualAssetKey(string key)
{
- key = PathUtilities.NormalizePath(key);
-
- key = this.GetCaseInsensitiveRelativePath(key);
- return this.Content.GetActualAssetKey(key, ContentSource.ModFolder);
+ return this.ModContent.GetInternalAssetName(key).Name;
}
/*********
** Private methods
*********/
- /// <summary>Get the real relative path from a case-insensitive path.</summary>
- /// <param name="relativePath">The normalized relative path.</param>
- private string GetCaseInsensitiveRelativePath(string relativePath)
- {
- if (!PathUtilities.IsSafeRelativePath(relativePath))
- throw new InvalidOperationException($"You must call {nameof(IContentPack)} methods with a relative path.");
-
- return !string.IsNullOrWhiteSpace(relativePath) && this.RelativePaths.TryGetValue(relativePath, out string caseInsensitivePath)
- ? caseInsensitivePath
- : relativePath;
- }
-
/// <summary>Get the underlying file info.</summary>
/// <param name="relativePath">The normalized file path relative to the content pack directory.</param>
private FileInfo GetFile(string relativePath)
@@ -140,7 +119,11 @@ namespace StardewModdingAPI.Framework
/// <param name="actualRelativePath">The relative path after case-insensitive matching.</param>
private FileInfo GetFile(string relativePath, out string actualRelativePath)
{
- actualRelativePath = this.GetCaseInsensitiveRelativePath(relativePath);
+ if (!PathUtilities.IsSafeRelativePath(relativePath))
+ throw new InvalidOperationException($"You must call {nameof(IContentPack)} methods with a relative path.");
+
+ actualRelativePath = this.RelativePathCache.GetFilePath(relativePath);
+
return new FileInfo(Path.Combine(this.DirectoryPath, actualRelativePath));
}
}
diff --git a/src/SMAPI/Framework/CursorPosition.cs b/src/SMAPI/Framework/CursorPosition.cs
index 107481e7..24084830 100644
--- a/src/SMAPI/Framework/CursorPosition.cs
+++ b/src/SMAPI/Framework/CursorPosition.cs
@@ -39,7 +39,7 @@ namespace StardewModdingAPI.Framework
}
/// <inheritdoc />
- public bool Equals(ICursorPosition other)
+ public bool Equals(ICursorPosition? other)
{
return other != null && this.AbsolutePixels == other.AbsolutePixels;
}
diff --git a/src/SMAPI/Framework/DeprecationManager.cs b/src/SMAPI/Framework/DeprecationManager.cs
deleted file mode 100644
index fc1b434b..00000000
--- a/src/SMAPI/Framework/DeprecationManager.cs
+++ /dev/null
@@ -1,138 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-
-namespace StardewModdingAPI.Framework
-{
- /// <summary>Manages deprecation warnings.</summary>
- internal class DeprecationManager
- {
- /*********
- ** Fields
- *********/
- /// <summary>The deprecations which have already been logged (as 'mod name::noun phrase::version').</summary>
- private readonly HashSet<string> LoggedDeprecations = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
-
- /// <summary>Encapsulates monitoring and logging for a given module.</summary>
- private readonly IMonitor Monitor;
-
- /// <summary>Tracks the installed mods.</summary>
- private readonly ModRegistry ModRegistry;
-
- /// <summary>The queued deprecation warnings to display.</summary>
- private readonly IList<DeprecationWarning> QueuedWarnings = new List<DeprecationWarning>();
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Construct an instance.</summary>
- /// <param name="monitor">Encapsulates monitoring and logging for a given module.</param>
- /// <param name="modRegistry">Tracks the installed mods.</param>
- public DeprecationManager(IMonitor monitor, ModRegistry modRegistry)
- {
- this.Monitor = monitor;
- this.ModRegistry = modRegistry;
- }
-
- /// <summary>Get the source name for a mod from its unique ID.</summary>
- public string GetSourceNameFromStack()
- {
- return this.ModRegistry.GetFromStack()?.DisplayName;
- }
-
- /// <summary>Get the source name for a mod from its unique ID.</summary>
- /// <param name="modId">The mod's unique ID.</param>
- public string GetSourceName(string modId)
- {
- return this.ModRegistry.Get(modId)?.DisplayName;
- }
-
- /// <summary>Log a deprecation warning.</summary>
- /// <param name="source">The friendly mod name which used the deprecated code.</param>
- /// <param name="nounPhrase">A noun phrase describing what is deprecated.</param>
- /// <param name="version">The SMAPI version which deprecated it.</param>
- /// <param name="severity">How deprecated the code is.</param>
- public void Warn(string source, string nounPhrase, string version, DeprecationLevel severity)
- {
- // ignore if already warned
- if (!this.MarkWarned(source ?? this.GetSourceNameFromStack() ?? "<unknown>", nounPhrase, version))
- return;
-
- // queue warning
- this.QueuedWarnings.Add(new DeprecationWarning(source, nounPhrase, version, severity, Environment.StackTrace));
- }
-
- /// <summary>A placeholder method used to track deprecated code for which a separate warning will be shown.</summary>
- /// <param name="version">The SMAPI version which deprecated it.</param>
- /// <param name="severity">How deprecated the code is.</param>
- public void PlaceholderWarn(string version, DeprecationLevel severity) { }
-
- /// <summary>Print any queued messages.</summary>
- public void PrintQueued()
- {
- foreach (DeprecationWarning warning in this.QueuedWarnings.OrderBy(p => p.ModName).ThenBy(p => p.NounPhrase))
- {
- // build message
- string message = $"{warning.ModName} uses deprecated code ({warning.NounPhrase} is deprecated since SMAPI {warning.Version}).";
-
- // get log level
- LogLevel level;
- switch (warning.Level)
- {
- case DeprecationLevel.Notice:
- level = LogLevel.Trace;
- break;
-
- case DeprecationLevel.Info:
- level = LogLevel.Debug;
- break;
-
- case DeprecationLevel.PendingRemoval:
- level = LogLevel.Warn;
- break;
-
- default:
- throw new NotSupportedException($"Unknown deprecation level '{warning.Level}'.");
- }
-
- // log message
- if (warning.ModName != null)
- this.Monitor.Log(message, level);
- else
- {
- if (level == LogLevel.Trace)
- this.Monitor.Log($"{message}\n{warning.StackTrace}", level);
- else
- {
- this.Monitor.Log(message, level);
- this.Monitor.Log(warning.StackTrace, LogLevel.Debug);
- }
- }
- }
-
- this.QueuedWarnings.Clear();
- }
-
-
- /*********
- ** Private methods
- *********/
- /// <summary>Mark a deprecation warning as already logged.</summary>
- /// <param name="source">The friendly name of the assembly which used the deprecated code.</param>
- /// <param name="nounPhrase">A noun phrase describing what is deprecated (e.g. "the Extensions.AsInt32 method").</param>
- /// <param name="version">The SMAPI version which deprecated it.</param>
- /// <returns>Returns whether the deprecation was successfully marked as warned. Returns <c>false</c> if it was already marked.</returns>
- private bool MarkWarned(string source, string nounPhrase, string version)
- {
- if (string.IsNullOrWhiteSpace(source))
- throw new InvalidOperationException("The deprecation source cannot be empty.");
-
- string key = $"{source}::{nounPhrase}::{version}";
- if (this.LoggedDeprecations.Contains(key))
- return false;
- this.LoggedDeprecations.Add(key);
- return true;
- }
- }
-}
diff --git a/src/SMAPI/Framework/DeprecationLevel.cs b/src/SMAPI/Framework/Deprecations/DeprecationLevel.cs
index 12b50952..8b15b59a 100644
--- a/src/SMAPI/Framework/DeprecationLevel.cs
+++ b/src/SMAPI/Framework/Deprecations/DeprecationLevel.cs
@@ -1,4 +1,4 @@
-namespace StardewModdingAPI.Framework
+namespace StardewModdingAPI.Framework.Deprecations
{
/// <summary>Indicates how deprecated something is.</summary>
internal enum DeprecationLevel
diff --git a/src/SMAPI/Framework/Deprecations/DeprecationManager.cs b/src/SMAPI/Framework/Deprecations/DeprecationManager.cs
new file mode 100644
index 00000000..288abde2
--- /dev/null
+++ b/src/SMAPI/Framework/Deprecations/DeprecationManager.cs
@@ -0,0 +1,187 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Text;
+
+namespace StardewModdingAPI.Framework.Deprecations
+{
+ /// <summary>Manages deprecation warnings.</summary>
+ internal class DeprecationManager
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The deprecations which have already been logged (as 'mod name::noun phrase::version').</summary>
+ private readonly HashSet<string> LoggedDeprecations = new(StringComparer.OrdinalIgnoreCase);
+
+ /// <summary>Encapsulates monitoring and logging for a given module.</summary>
+ private readonly IMonitor Monitor;
+
+ /// <summary>Tracks the installed mods.</summary>
+ private readonly ModRegistry ModRegistry;
+
+ /// <summary>The queued deprecation warnings to display.</summary>
+ private readonly IList<DeprecationWarning> QueuedWarnings = new List<DeprecationWarning>();
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="monitor">Encapsulates monitoring and logging for a given module.</param>
+ /// <param name="modRegistry">Tracks the installed mods.</param>
+ public DeprecationManager(IMonitor monitor, ModRegistry modRegistry)
+ {
+ this.Monitor = monitor;
+ this.ModRegistry = modRegistry;
+ }
+
+ /// <summary>Get a mod for the closest assembly registered as a source of deprecation warnings.</summary>
+ /// <returns>Returns the source name, or <c>null</c> if no registered assemblies were found.</returns>
+ public IModMetadata? GetModFromStack()
+ {
+ return this.ModRegistry.GetFromStack();
+ }
+
+ /// <summary>Get a mod from its unique ID.</summary>
+ /// <param name="modId">The mod's unique ID.</param>
+ public IModMetadata? GetMod(string modId)
+ {
+ return this.ModRegistry.Get(modId);
+ }
+
+ /// <summary>Log a deprecation warning.</summary>
+ /// <param name="source">The mod which used the deprecated code, if known.</param>
+ /// <param name="nounPhrase">A noun phrase describing what is deprecated.</param>
+ /// <param name="version">The SMAPI version which deprecated it.</param>
+ /// <param name="severity">How deprecated the code is.</param>
+ /// <param name="unlessStackIncludes">A list of stack trace substrings which should suppress deprecation warnings if they appear in the stack trace.</param>
+ /// <param name="logStackTrace">Whether to log a stack trace showing where the deprecated code is in the mod.</param>
+ public void Warn(IModMetadata? source, string nounPhrase, string version, DeprecationLevel severity, string[]? unlessStackIncludes = null, bool logStackTrace = true)
+ {
+ // skip if already warned
+ string cacheKey = $"{source?.DisplayName ?? "<unknown>"}::{nounPhrase}::{version}";
+ if (this.LoggedDeprecations.Contains(cacheKey))
+ return;
+
+ // warn if valid
+ ImmutableStackTrace stack = ImmutableStackTrace.Get(skipFrames: 1);
+ if (!this.ShouldSuppress(stack, unlessStackIncludes))
+ {
+ this.LoggedDeprecations.Add(cacheKey);
+ this.QueuedWarnings.Add(new DeprecationWarning(source, nounPhrase, version, severity, stack, logStackTrace));
+ }
+ }
+
+ /// <summary>A placeholder method used to track deprecated code for which a separate warning will be shown.</summary>
+ /// <param name="version">The SMAPI version which deprecated it.</param>
+ /// <param name="severity">How deprecated the code is.</param>
+ public void PlaceholderWarn(string version, DeprecationLevel severity) { }
+
+ /// <summary>Print any queued messages.</summary>
+ public void PrintQueued()
+ {
+ foreach (DeprecationWarning warning in this.QueuedWarnings.OrderBy(p => p.ModName).ThenBy(p => p.NounPhrase))
+ {
+ // build message
+ string message = $"{warning.ModName} uses deprecated code ({warning.NounPhrase} is deprecated since SMAPI {warning.Version}).";
+
+ // get log level
+ LogLevel level;
+ switch (warning.Level)
+ {
+ case DeprecationLevel.Notice:
+ level = LogLevel.Trace;
+ break;
+
+ case DeprecationLevel.Info:
+ level = LogLevel.Debug;
+ break;
+
+ case DeprecationLevel.PendingRemoval:
+ level = LogLevel.Warn;
+ break;
+
+ default:
+ throw new NotSupportedException($"Unknown deprecation level '{warning.Level}'.");
+ }
+
+ // log message
+ if (level == LogLevel.Trace)
+ {
+ if (warning.LogStackTrace)
+ message += $"\n{this.GetSimplifiedStackTrace(warning.StackTrace, warning.Mod)}";
+ this.Monitor.Log(message, level);
+ }
+ else
+ {
+ this.Monitor.Log(message, level);
+ if (warning.LogStackTrace)
+ this.Monitor.Log(this.GetSimplifiedStackTrace(warning.StackTrace, warning.Mod), LogLevel.Debug);
+ }
+ }
+
+ this.QueuedWarnings.Clear();
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get whether a deprecation warning should be suppressed.</summary>
+ /// <param name="stack">The stack trace for which it was raised.</param>
+ /// <param name="unlessStackIncludes">A list of stack trace substrings which should suppress deprecation warnings if they appear in the stack trace.</param>
+ private bool ShouldSuppress(ImmutableStackTrace stack, string[]? unlessStackIncludes)
+ {
+ if (unlessStackIncludes?.Any() == true)
+ {
+ string stackTrace = stack.ToString();
+ foreach (string method in unlessStackIncludes)
+ {
+ if (stackTrace.Contains(method))
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /// <summary>Get the simplest stack trace which shows where in the mod the deprecated code was called from.</summary>
+ /// <param name="stack">The stack trace.</param>
+ /// <param name="mod">The mod for which to show a stack trace.</param>
+ private string GetSimplifiedStackTrace(ImmutableStackTrace stack, IModMetadata? mod)
+ {
+ // unknown mod, show entire stack trace
+ if (mod == null)
+ return stack.ToString();
+
+ // get frame info
+ var frames = stack
+ .GetFrames()
+ .Select(frame => (Frame: frame, Mod: this.ModRegistry.GetFrom(frame)))
+ .ToArray();
+ var modIds = new HashSet<string>(
+ from frame in frames
+ let id = frame.Mod?.Manifest.UniqueID
+ where id != null
+ select id
+ );
+
+ // can't filter to the target mod
+ if (modIds.Count != 1 || !modIds.Contains(mod.Manifest.UniqueID))
+ return stack.ToString();
+
+ // get stack frames for the target mod, plus one for context
+ var framesStartingAtMod = frames.SkipWhile(p => p.Mod == null).ToArray();
+ var displayFrames = framesStartingAtMod.TakeWhile(p => p.Mod != null).ToArray();
+ displayFrames = displayFrames.Concat(framesStartingAtMod.Skip(displayFrames.Length).Take(1)).ToArray();
+
+ // build stack trace
+ StringBuilder str = new();
+ foreach (var frame in displayFrames)
+ str.Append(new StackTrace(frame.Frame));
+ return str.ToString().TrimEnd();
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/DeprecationWarning.cs b/src/SMAPI/Framework/Deprecations/DeprecationWarning.cs
index 5201b06c..5936517b 100644
--- a/src/SMAPI/Framework/DeprecationWarning.cs
+++ b/src/SMAPI/Framework/Deprecations/DeprecationWarning.cs
@@ -1,4 +1,4 @@
-namespace StardewModdingAPI.Framework
+namespace StardewModdingAPI.Framework.Deprecations
{
/// <summary>A deprecation warning for a mod.</summary>
internal class DeprecationWarning
@@ -6,8 +6,11 @@ namespace StardewModdingAPI.Framework
/*********
** Accessors
*********/
- /// <summary>The affected mod's display name.</summary>
- public string ModName { get; }
+ /// <summary>The affected mod.</summary>
+ public IModMetadata? Mod { get; }
+
+ /// <summary>Get the display name for the affected mod.</summary>
+ public string ModName => this.Mod?.DisplayName ?? "<unknown mod>";
/// <summary>A noun phrase describing what is deprecated.</summary>
public string NounPhrase { get; }
@@ -19,25 +22,30 @@ namespace StardewModdingAPI.Framework
public DeprecationLevel Level { get; }
/// <summary>The stack trace when the deprecation warning was raised.</summary>
- public string StackTrace { get; }
+ public ImmutableStackTrace StackTrace { get; }
+
+ /// <summary>Whether to log a stack trace showing where the deprecated code is in the mod.</summary>
+ public bool LogStackTrace { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
- /// <param name="modName">The affected mod's display name.</param>
+ /// <param name="mod">The affected mod.</param>
/// <param name="nounPhrase">A noun phrase describing what is deprecated.</param>
/// <param name="version">The SMAPI version which deprecated it.</param>
/// <param name="level">The deprecation level for the affected code.</param>
/// <param name="stackTrace">The stack trace when the deprecation warning was raised.</param>
- public DeprecationWarning(string modName, string nounPhrase, string version, DeprecationLevel level, string stackTrace)
+ /// <param name="logStackTrace">Whether to log a stack trace showing where the deprecated code is in the mod.</param>
+ public DeprecationWarning(IModMetadata? mod, string nounPhrase, string version, DeprecationLevel level, ImmutableStackTrace stackTrace, bool logStackTrace)
{
- this.ModName = modName;
+ this.Mod = mod;
this.NounPhrase = nounPhrase;
this.Version = version;
this.Level = level;
this.StackTrace = stackTrace;
+ this.LogStackTrace = logStackTrace;
}
}
}
diff --git a/src/SMAPI/Framework/Deprecations/ImmutableStackTrace.cs b/src/SMAPI/Framework/Deprecations/ImmutableStackTrace.cs
new file mode 100644
index 00000000..059d871c
--- /dev/null
+++ b/src/SMAPI/Framework/Deprecations/ImmutableStackTrace.cs
@@ -0,0 +1,53 @@
+using System.Diagnostics;
+
+namespace StardewModdingAPI.Framework.Deprecations
+{
+ /// <summary>An immutable stack trace that caches its values.</summary>
+ internal class ImmutableStackTrace
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The underlying stack trace.</summary>
+ private readonly StackTrace StackTrace;
+
+ /// <summary>The individual method calls in the stack trace.</summary>
+ private StackFrame[]? Frames;
+
+ /// <summary>The string representation of the stack trace.</summary>
+ private string? StringForm;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="stackTrace">The underlying stack trace.</param>
+ public ImmutableStackTrace(StackTrace stackTrace)
+ {
+ this.StackTrace = stackTrace;
+ }
+
+ /// <summary>Get the underlying frames.</summary>
+ /// <remarks>This is a reference to the underlying stack frames, so this array should not be edited.</remarks>
+ public StackFrame[] GetFrames()
+ {
+ return this.Frames ??= this.StackTrace.GetFrames();
+ }
+
+ /// <inheritdoc />
+ public override string ToString()
+ {
+ return this.StringForm ??= this.StackTrace.ToString();
+ }
+
+ /// <summary>Get the current stack trace.</summary>
+ /// <param name="skipFrames">The number of frames up the stack from which to start the trace.</param>
+ public static ImmutableStackTrace Get(int skipFrames = 0)
+ {
+ return new ImmutableStackTrace(
+ new StackTrace(skipFrames: skipFrames + 1) // also skip this method
+ );
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Events/EventManager.cs b/src/SMAPI/Framework/Events/EventManager.cs
index dfc289ed..41540047 100644
--- a/src/SMAPI/Framework/Events/EventManager.cs
+++ b/src/SMAPI/Framework/Events/EventManager.cs
@@ -1,180 +1,192 @@
-using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
-using System.Reflection;
using StardewModdingAPI.Events;
namespace StardewModdingAPI.Framework.Events
{
/// <summary>Manages SMAPI events.</summary>
- [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Private fields are deliberately named to simplify organisation.")]
internal class EventManager
{
/*********
** Events
*********/
/****
+ ** Content
+ ****/
+ /// <inheritdoc cref="IContentEvents.AssetRequested" />
+ public readonly ManagedEvent<AssetRequestedEventArgs> AssetRequested;
+
+ /// <inheritdoc cref="IContentEvents.AssetsInvalidated" />
+ public readonly ManagedEvent<AssetsInvalidatedEventArgs> AssetsInvalidated;
+
+ /// <inheritdoc cref="IContentEvents.AssetReady" />
+ public readonly ManagedEvent<AssetReadyEventArgs> AssetReady;
+
+ /// <inheritdoc cref="IContentEvents.LocaleChanged" />
+ public readonly ManagedEvent<LocaleChangedEventArgs> LocaleChanged;
+
+
+ /****
** Display
****/
- /// <summary>Raised after a game menu is opened, closed, or replaced.</summary>
+ /// <inheritdoc cref="IDisplayEvents.MenuChanged" />
public readonly ManagedEvent<MenuChangedEventArgs> MenuChanged;
- /// <summary>Raised before the game draws anything to the screen in a draw tick, as soon as the sprite batch is opened. The sprite batch may be closed and reopened multiple times after this event is called, but it's only raised once per draw tick. This event isn't useful for drawing to the screen, since the game will draw over it.</summary>
+ /// <inheritdoc cref="IDisplayEvents.Rendering" />
public readonly ManagedEvent<RenderingEventArgs> Rendering;
- /// <summary>Raised after the game draws to the sprite patch in a draw tick, just before the final sprite batch is rendered to the screen. Since the game may open/close the sprite batch multiple times in a draw tick, the sprite batch may not contain everything being drawn and some things may already be rendered to the screen. Content drawn to the sprite batch at this point will be drawn over all vanilla content (including menus, HUD, and cursor).</summary>
+ /// <inheritdoc cref="IDisplayEvents.Rendered" />
public readonly ManagedEvent<RenderedEventArgs> Rendered;
- /// <summary>Raised before the game world is drawn to the screen.</summary>
+ /// <inheritdoc cref="IDisplayEvents.RenderingWorld" />
public readonly ManagedEvent<RenderingWorldEventArgs> RenderingWorld;
- /// <summary>Raised after the game world is drawn to the sprite patch, before it's rendered to the screen.</summary>
+ /// <inheritdoc cref="IDisplayEvents.RenderedWorld" />
public readonly ManagedEvent<RenderedWorldEventArgs> RenderedWorld;
- /// <summary>When a menu is open (<see cref="StardewValley.Game1.activeClickableMenu"/> isn't null), raised before that menu is drawn to the screen.</summary>
+ /// <inheritdoc cref="IDisplayEvents.RenderingActiveMenu" />
public readonly ManagedEvent<RenderingActiveMenuEventArgs> RenderingActiveMenu;
- /// <summary>When a menu is open (<see cref="StardewValley.Game1.activeClickableMenu"/> isn't null), raised after that menu is drawn to the sprite batch but before it's rendered to the screen.</summary>
+ /// <inheritdoc cref="IDisplayEvents.RenderedActiveMenu" />
public readonly ManagedEvent<RenderedActiveMenuEventArgs> RenderedActiveMenu;
- /// <summary>Raised before drawing the HUD (item toolbar, clock, etc) to the screen.</summary>
+ /// <inheritdoc cref="IDisplayEvents.RenderingHud" />
public readonly ManagedEvent<RenderingHudEventArgs> RenderingHud;
- /// <summary>Raised after drawing the HUD (item toolbar, clock, etc) to the sprite batch, but before it's rendered to the screen.</summary>
+ /// <inheritdoc cref="IDisplayEvents.RenderedHud" />
public readonly ManagedEvent<RenderedHudEventArgs> RenderedHud;
- /// <summary>Raised after the game window is resized.</summary>
+ /// <inheritdoc cref="IDisplayEvents.WindowResized" />
public readonly ManagedEvent<WindowResizedEventArgs> WindowResized;
/****
** Game loop
****/
- /// <summary>Raised after the game is launched, right before the first update tick.</summary>
+ /// <inheritdoc cref="IGameLoopEvents.GameLaunched" />
public readonly ManagedEvent<GameLaunchedEventArgs> GameLaunched;
- /// <summary>Raised before the game performs its overall update tick (≈60 times per second).</summary>
+ /// <inheritdoc cref="IGameLoopEvents.UpdateTicking" />
public readonly ManagedEvent<UpdateTickingEventArgs> UpdateTicking;
- /// <summary>Raised after the game performs its overall update tick (≈60 times per second).</summary>
+ /// <inheritdoc cref="IGameLoopEvents.UpdateTicked" />
public readonly ManagedEvent<UpdateTickedEventArgs> UpdateTicked;
- /// <summary>Raised once per second before the game performs its overall update tick.</summary>
+ /// <inheritdoc cref="IGameLoopEvents.OneSecondUpdateTicking" />
public readonly ManagedEvent<OneSecondUpdateTickingEventArgs> OneSecondUpdateTicking;
- /// <summary>Raised once per second after the game performs its overall update tick.</summary>
+ /// <inheritdoc cref="IGameLoopEvents.OneSecondUpdateTicked" />
public readonly ManagedEvent<OneSecondUpdateTickedEventArgs> OneSecondUpdateTicked;
- /// <summary>Raised before the game creates the save file.</summary>
+ /// <inheritdoc cref="IGameLoopEvents.SaveCreating" />
public readonly ManagedEvent<SaveCreatingEventArgs> SaveCreating;
- /// <summary>Raised after the game finishes creating the save file.</summary>
+ /// <inheritdoc cref="IGameLoopEvents.SaveCreated" />
public readonly ManagedEvent<SaveCreatedEventArgs> SaveCreated;
- /// <summary>Raised before the game begins writes data to the save file (except the initial save creation).</summary>
+ /// <inheritdoc cref="IGameLoopEvents.Saving" />
public readonly ManagedEvent<SavingEventArgs> Saving;
- /// <summary>Raised after the game finishes writing data to the save file (except the initial save creation).</summary>
+ /// <inheritdoc cref="IGameLoopEvents.Saved" />
public readonly ManagedEvent<SavedEventArgs> Saved;
- /// <summary>Raised after the player loads a save slot and the world is initialized.</summary>
+ /// <inheritdoc cref="IGameLoopEvents.SaveLoaded" />
public readonly ManagedEvent<SaveLoadedEventArgs> SaveLoaded;
- /// <summary>Raised after the game begins a new day, including when loading a save.</summary>
+ /// <inheritdoc cref="IGameLoopEvents.DayStarted" />
public readonly ManagedEvent<DayStartedEventArgs> DayStarted;
- /// <summary>Raised before the game ends the current day. This happens before it starts setting up the next day and before <see cref="Saving"/>.</summary>
+ /// <inheritdoc cref="IGameLoopEvents.DayEnding" />
public readonly ManagedEvent<DayEndingEventArgs> DayEnding;
- /// <summary>Raised after the in-game clock time changes.</summary>
+ /// <inheritdoc cref="IGameLoopEvents.TimeChanged" />
public readonly ManagedEvent<TimeChangedEventArgs> TimeChanged;
- /// <summary>Raised after the game returns to the title screen.</summary>
+ /// <inheritdoc cref="IGameLoopEvents.ReturnedToTitle" />
public readonly ManagedEvent<ReturnedToTitleEventArgs> ReturnedToTitle;
/****
** Input
****/
- /// <summary>Raised after the player presses or releases any buttons on the keyboard, controller, or mouse.</summary>
+ /// <inheritdoc cref="IInputEvents.ButtonsChanged" />
public readonly ManagedEvent<ButtonsChangedEventArgs> ButtonsChanged;
- /// <summary>Raised after the player presses a button on the keyboard, controller, or mouse.</summary>
+ /// <inheritdoc cref="IInputEvents.ButtonPressed" />
public readonly ManagedEvent<ButtonPressedEventArgs> ButtonPressed;
- /// <summary>Raised after the player released a button on the keyboard, controller, or mouse.</summary>
+ /// <inheritdoc cref="IInputEvents.ButtonReleased" />
public readonly ManagedEvent<ButtonReleasedEventArgs> ButtonReleased;
- /// <summary>Raised after the player moves the in-game cursor.</summary>
+ /// <inheritdoc cref="IInputEvents.CursorMoved" />
public readonly ManagedEvent<CursorMovedEventArgs> CursorMoved;
- /// <summary>Raised after the player scrolls the mouse wheel.</summary>
+ /// <inheritdoc cref="IInputEvents.MouseWheelScrolled" />
public readonly ManagedEvent<MouseWheelScrolledEventArgs> MouseWheelScrolled;
/****
** Multiplayer
****/
- /// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection (<see cref="IMultiplayerEvents.PeerConnected"/>), so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary>
+ /// <inheritdoc cref="IMultiplayerEvents.PeerContextReceived" />
public readonly ManagedEvent<PeerContextReceivedEventArgs> PeerContextReceived;
- /// <summary>Raised after a peer connection is approved by the game.</summary>
+ /// <inheritdoc cref="IMultiplayerEvents.PeerConnected" />
public readonly ManagedEvent<PeerConnectedEventArgs> PeerConnected;
- /// <summary>Raised after a mod message is received over the network.</summary>
+ /// <inheritdoc cref="IMultiplayerEvents.ModMessageReceived" />
public readonly ManagedEvent<ModMessageReceivedEventArgs> ModMessageReceived;
- /// <summary>Raised after the connection with a peer is severed.</summary>
+ /// <inheritdoc cref="IMultiplayerEvents.PeerDisconnected" />
public readonly ManagedEvent<PeerDisconnectedEventArgs> PeerDisconnected;
/****
** Player
****/
- /// <summary>Raised after items are added or removed to a player's inventory.</summary>
+ /// <inheritdoc cref="IPlayerEvents.InventoryChanged" />
public readonly ManagedEvent<InventoryChangedEventArgs> InventoryChanged;
- /// <summary>Raised after a player skill level changes. This happens as soon as they level up, not when the game notifies the player after their character goes to bed.</summary>
+ /// <inheritdoc cref="IPlayerEvents.LevelChanged" />
public readonly ManagedEvent<LevelChangedEventArgs> LevelChanged;
- /// <summary>Raised after a player warps to a new location.</summary>
+ /// <inheritdoc cref="IPlayerEvents.Warped" />
public readonly ManagedEvent<WarpedEventArgs> Warped;
/****
** World
****/
- /// <summary>Raised after a game location is added or removed.</summary>
+ /// <inheritdoc cref="IWorldEvents.LocationListChanged" />
public readonly ManagedEvent<LocationListChangedEventArgs> LocationListChanged;
- /// <summary>Raised after buildings are added or removed in a location.</summary>
+ /// <inheritdoc cref="IWorldEvents.BuildingListChanged" />
public readonly ManagedEvent<BuildingListChangedEventArgs> BuildingListChanged;
- /// <summary>Raised after debris are added or removed in a location.</summary>
+ /// <inheritdoc cref="IWorldEvents.DebrisListChanged" />
public readonly ManagedEvent<DebrisListChangedEventArgs> DebrisListChanged;
- /// <summary>Raised after large terrain features (like bushes) are added or removed in a location.</summary>
+ /// <inheritdoc cref="IWorldEvents.LargeTerrainFeatureListChanged" />
public readonly ManagedEvent<LargeTerrainFeatureListChangedEventArgs> LargeTerrainFeatureListChanged;
- /// <summary>Raised after NPCs are added or removed in a location.</summary>
+ /// <inheritdoc cref="IWorldEvents.NpcListChanged" />
public readonly ManagedEvent<NpcListChangedEventArgs> NpcListChanged;
- /// <summary>Raised after objects are added or removed in a location.</summary>
+ /// <inheritdoc cref="IWorldEvents.ObjectListChanged" />
public readonly ManagedEvent<ObjectListChangedEventArgs> ObjectListChanged;
- /// <summary>Raised after items are added or removed from a chest.</summary>
+ /// <inheritdoc cref="IWorldEvents.ChestInventoryChanged" />
public readonly ManagedEvent<ChestInventoryChangedEventArgs> ChestInventoryChanged;
- /// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary>
+ /// <inheritdoc cref="IWorldEvents.TerrainFeatureListChanged" />
public readonly ManagedEvent<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged;
- /// <summary>Raised after furniture are added or removed in a location.</summary>
+ /// <inheritdoc cref="IWorldEvents.FurnitureListChanged" />
public readonly ManagedEvent<FurnitureListChangedEventArgs> FurnitureListChanged;
/****
** Specialized
****/
- /// <summary>Raised when the low-level stage in the game's loading process has changed. See notes on <see cref="ISpecializedEvents.LoadStageChanged"/>.</summary>
+ /// <inheritdoc cref="ISpecializedEvents.LoadStageChanged" />
public readonly ManagedEvent<LoadStageChangedEventArgs> LoadStageChanged;
- /// <summary>Raised before the game performs its overall update tick (≈60 times per second). See notes on <see cref="ISpecializedEvents.UnvalidatedUpdateTicking"/>.</summary>
+ /// <inheritdoc cref="ISpecializedEvents.UnvalidatedUpdateTicking" />
public readonly ManagedEvent<UnvalidatedUpdateTickingEventArgs> UnvalidatedUpdateTicking;
- /// <summary>Raised after the game performs its overall update tick (≈60 times per second). See notes on <see cref="ISpecializedEvents.UnvalidatedUpdateTicked"/>.</summary>
+ /// <inheritdoc cref="ISpecializedEvents.UnvalidatedUpdateTicked" />
public readonly ManagedEvent<UnvalidatedUpdateTickedEventArgs> UnvalidatedUpdateTicked;
@@ -191,7 +203,12 @@ namespace StardewModdingAPI.Framework.Events
return new ManagedEvent<TEventArgs>($"{typeName}.{eventName}", modRegistry, isPerformanceCritical);
}
- // init events (new)
+ // init events
+ this.AssetRequested = ManageEventOf<AssetRequestedEventArgs>(nameof(IModEvents.Content), nameof(IContentEvents.AssetRequested));
+ this.AssetsInvalidated = ManageEventOf<AssetsInvalidatedEventArgs>(nameof(IModEvents.Content), nameof(IContentEvents.AssetsInvalidated));
+ this.AssetReady = ManageEventOf<AssetReadyEventArgs>(nameof(IModEvents.Content), nameof(IContentEvents.AssetReady));
+ this.LocaleChanged = ManageEventOf<LocaleChangedEventArgs>(nameof(IModEvents.Content), nameof(IContentEvents.LocaleChanged));
+
this.MenuChanged = ManageEventOf<MenuChangedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.MenuChanged));
this.Rendering = ManageEventOf<RenderingEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendering), isPerformanceCritical: true);
this.Rendered = ManageEventOf<RenderedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendered), isPerformanceCritical: true);
@@ -247,12 +264,5 @@ namespace StardewModdingAPI.Framework.Events
this.UnvalidatedUpdateTicking = ManageEventOf<UnvalidatedUpdateTickingEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicking), isPerformanceCritical: true);
this.UnvalidatedUpdateTicked = ManageEventOf<UnvalidatedUpdateTickedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicked), isPerformanceCritical: true);
}
-
- /// <summary>Get all managed events.</summary>
- public IEnumerable<IManagedEvent> GetAllEvents()
- {
- foreach (FieldInfo field in this.GetType().GetFields())
- yield return (IManagedEvent)field.GetValue(this);
- }
}
}
diff --git a/src/SMAPI/Framework/Events/ManagedEvent.cs b/src/SMAPI/Framework/Events/ManagedEvent.cs
index fa20a079..4b8a770d 100644
--- a/src/SMAPI/Framework/Events/ManagedEvent.cs
+++ b/src/SMAPI/Framework/Events/ManagedEvent.cs
@@ -18,10 +18,10 @@ namespace StardewModdingAPI.Framework.Events
protected readonly ModRegistry ModRegistry;
/// <summary>The underlying event handlers.</summary>
- private readonly List<ManagedEventHandler<TEventArgs>> Handlers = new List<ManagedEventHandler<TEventArgs>>();
+ private readonly List<ManagedEventHandler<TEventArgs>> Handlers = new();
/// <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;
@@ -33,10 +33,10 @@ namespace StardewModdingAPI.Framework.Events
/*********
** Accessors
*********/
- /// <summary>A human-readable name for the event.</summary>
+ /// <inheritdoc />
public string EventName { get; }
- /// <summary>Whether the event is typically called at least once per second.</summary>
+ /// <inheritdoc />
public bool IsPerformanceCritical { get; }
@@ -98,7 +98,15 @@ namespace StardewModdingAPI.Framework.Events
/// <summary>Raise the event and notify all handlers.</summary>
/// <param name="args">The event arguments to pass.</param>
/// <param name="match">A lambda which returns true if the event should be raised for the given mod.</param>
- public void Raise(TEventArgs args, Func<IModMetadata, bool> match = null)
+ public void Raise(TEventArgs args, Func<IModMetadata, bool>? match = null)
+ {
+ this.Raise((_, invoke) => invoke(args), match);
+ }
+
+ /// <summary>Raise the event and notify all handlers.</summary>
+ /// <param name="invoke">Invoke an event handler. This receives the mod which registered the handler, and should invoke the callback with the event arguments to pass it.</param>
+ /// <param name="match">A lambda which returns true if the event should be raised for the given mod.</param>
+ public void Raise(Action<IModMetadata, Action<TEventArgs>> invoke, Func<IModMetadata, bool>? match = null)
{
// skip if no handlers
if (this.Handlers.Count == 0)
@@ -128,7 +136,7 @@ namespace StardewModdingAPI.Framework.Events
try
{
- handler.Handler.Invoke(null, args);
+ invoke(handler.SourceMod, args => handler.Handler.Invoke(null, args));
}
catch (Exception ex)
{
diff --git a/src/SMAPI/Framework/Events/ManagedEventHandler.cs b/src/SMAPI/Framework/Events/ManagedEventHandler.cs
index b32a2a22..d32acdb9 100644
--- a/src/SMAPI/Framework/Events/ManagedEventHandler.cs
+++ b/src/SMAPI/Framework/Events/ManagedEventHandler.cs
@@ -39,12 +39,10 @@ namespace StardewModdingAPI.Framework.Events
this.SourceMod = sourceMod;
}
- /// <summary>Compares the current instance with another object of the same type and returns an integer that indicates whether the current instance precedes, follows, or occurs in the same position in the sort order as the other object.</summary>
- /// <param name="obj">An object to compare with this instance.</param>
- /// <exception cref="T:System.ArgumentException"><paramref name="obj" /> is not the same type as this instance.</exception>
- public int CompareTo(object obj)
+ /// <inheritdoc />
+ public int CompareTo(object? obj)
{
- if (!(obj is ManagedEventHandler<TEventArgs> other))
+ if (obj is not ManagedEventHandler<TEventArgs> other)
throw new ArgumentException("Can't compare to an unrelated object type.");
int priorityCompare = -this.Priority.CompareTo(other.Priority); // higher value = sort first
diff --git a/src/SMAPI/Framework/Events/ModContentEvents.cs b/src/SMAPI/Framework/Events/ModContentEvents.cs
new file mode 100644
index 00000000..beb96031
--- /dev/null
+++ b/src/SMAPI/Framework/Events/ModContentEvents.cs
@@ -0,0 +1,50 @@
+using System;
+using StardewModdingAPI.Events;
+
+namespace StardewModdingAPI.Framework.Events
+{
+ /// <inheritdoc cref="IContentEvents" />
+ internal class ModContentEvents : ModEventsBase, IContentEvents
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <inheritdoc />
+ public event EventHandler<AssetRequestedEventArgs> AssetRequested
+ {
+ add => this.EventManager.AssetRequested.Add(value, this.Mod);
+ remove => this.EventManager.AssetRequested.Remove(value);
+ }
+
+ /// <inheritdoc />
+ public event EventHandler<AssetsInvalidatedEventArgs> AssetsInvalidated
+ {
+ add => this.EventManager.AssetsInvalidated.Add(value, this.Mod);
+ remove => this.EventManager.AssetsInvalidated.Remove(value);
+ }
+
+ /// <inheritdoc />
+ public event EventHandler<AssetReadyEventArgs> AssetReady
+ {
+ add => this.EventManager.AssetReady.Add(value, this.Mod);
+ remove => this.EventManager.AssetReady.Remove(value);
+ }
+
+ /// <inheritdoc />
+ public event EventHandler<LocaleChangedEventArgs> LocaleChanged
+ {
+ add => this.EventManager.LocaleChanged.Add(value, this.Mod);
+ remove => this.EventManager.LocaleChanged.Remove(value);
+ }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="mod">The mod which uses this instance.</param>
+ /// <param name="eventManager">The underlying event manager.</param>
+ internal ModContentEvents(IModMetadata mod, EventManager eventManager)
+ : base(mod, eventManager) { }
+ }
+}
diff --git a/src/SMAPI/Framework/Events/ModDisplayEvents.cs b/src/SMAPI/Framework/Events/ModDisplayEvents.cs
index 54d40dee..48f55324 100644
--- a/src/SMAPI/Framework/Events/ModDisplayEvents.cs
+++ b/src/SMAPI/Framework/Events/ModDisplayEvents.cs
@@ -1,79 +1,78 @@
using System;
using StardewModdingAPI.Events;
-using StardewValley;
namespace StardewModdingAPI.Framework.Events
{
- /// <summary>Events related to UI and drawing to the screen.</summary>
+ /// <inheritdoc cref="IDisplayEvents" />
internal class ModDisplayEvents : ModEventsBase, IDisplayEvents
{
/*********
** Accessors
*********/
- /// <summary>Raised after a game menu is opened, closed, or replaced.</summary>
+ /// <inheritdoc />
public event EventHandler<MenuChangedEventArgs> MenuChanged
{
add => this.EventManager.MenuChanged.Add(value, this.Mod);
remove => this.EventManager.MenuChanged.Remove(value);
}
- /// <summary>Raised before the game draws anything to the screen in a draw tick, as soon as the sprite batch is opened. The sprite batch may be closed and reopened multiple times after this event is called, but it's only raised once per draw tick. This event isn't useful for drawing to the screen, since the game will draw over it.</summary>
+ /// <inheritdoc />
public event EventHandler<RenderingEventArgs> Rendering
{
add => this.EventManager.Rendering.Add(value, this.Mod);
remove => this.EventManager.Rendering.Remove(value);
}
- /// <summary>Raised after the game draws to the sprite patch in a draw tick, just before the final sprite batch is rendered to the screen. Since the game may open/close the sprite batch multiple times in a draw tick, the sprite batch may not contain everything being drawn and some things may already be rendered to the screen. Content drawn to the sprite batch at this point will be drawn over all vanilla content (including menus, HUD, and cursor).</summary>
+ /// <inheritdoc />
public event EventHandler<RenderedEventArgs> Rendered
{
add => this.EventManager.Rendered.Add(value, this.Mod);
remove => this.EventManager.Rendered.Remove(value);
}
- /// <summary>Raised before the game world is drawn to the screen. This event isn't useful for drawing to the screen, since the game will draw over it.</summary>
+ /// <inheritdoc />
public event EventHandler<RenderingWorldEventArgs> RenderingWorld
{
add => this.EventManager.RenderingWorld.Add(value, this.Mod);
remove => this.EventManager.RenderingWorld.Remove(value);
}
- /// <summary>Raised after the game world is drawn to the sprite patch, before it's rendered to the screen. Content drawn to the sprite batch at this point will be drawn over the world, but under any active menu, HUD elements, or cursor.</summary>
+ /// <inheritdoc />
public event EventHandler<RenderedWorldEventArgs> RenderedWorld
{
add => this.EventManager.RenderedWorld.Add(value, this.Mod);
remove => this.EventManager.RenderedWorld.Remove(value);
}
- /// <summary>When a menu is open (<see cref="Game1.activeClickableMenu"/> isn't null), raised before that menu is drawn to the screen. This includes the game's internal menus like the title screen. Content drawn to the sprite batch at this point will appear under the menu.</summary>
+ /// <inheritdoc />
public event EventHandler<RenderingActiveMenuEventArgs> RenderingActiveMenu
{
add => this.EventManager.RenderingActiveMenu.Add(value, this.Mod);
remove => this.EventManager.RenderingActiveMenu.Remove(value);
}
- /// <summary>When a menu is open (<see cref="Game1.activeClickableMenu"/> isn't null), raised after that menu is drawn to the sprite batch but before it's rendered to the screen. Content drawn to the sprite batch at this point will appear over the menu and menu cursor.</summary>
+ /// <inheritdoc />
public event EventHandler<RenderedActiveMenuEventArgs> RenderedActiveMenu
{
add => this.EventManager.RenderedActiveMenu.Add(value, this.Mod);
remove => this.EventManager.RenderedActiveMenu.Remove(value);
}
- /// <summary>Raised before drawing the HUD (item toolbar, clock, etc) to the screen. The vanilla HUD may be hidden at this point (e.g. because a menu is open). Content drawn to the sprite batch at this point will appear under the HUD.</summary>
+ /// <inheritdoc />
public event EventHandler<RenderingHudEventArgs> RenderingHud
{
add => this.EventManager.RenderingHud.Add(value, this.Mod);
remove => this.EventManager.RenderingHud.Remove(value);
}
- /// <summary>Raised after drawing the HUD (item toolbar, clock, etc) to the sprite batch, but before it's rendered to the screen. The vanilla HUD may be hidden at this point (e.g. because a menu is open). Content drawn to the sprite batch at this point will appear over the HUD.</summary>
+ /// <inheritdoc />
public event EventHandler<RenderedHudEventArgs> RenderedHud
{
add => this.EventManager.RenderedHud.Add(value, this.Mod);
remove => this.EventManager.RenderedHud.Remove(value);
}
- /// <summary>Raised after the game window is resized.</summary>
+ /// <inheritdoc />
public event EventHandler<WindowResizedEventArgs> WindowResized
{
add => this.EventManager.WindowResized.Add(value, this.Mod);
diff --git a/src/SMAPI/Framework/Events/ModEvents.cs b/src/SMAPI/Framework/Events/ModEvents.cs
index 1d1c92c6..1fb3482c 100644
--- a/src/SMAPI/Framework/Events/ModEvents.cs
+++ b/src/SMAPI/Framework/Events/ModEvents.cs
@@ -2,31 +2,34 @@ using StardewModdingAPI.Events;
namespace StardewModdingAPI.Framework.Events
{
- /// <summary>Manages access to events raised by SMAPI.</summary>
+ /// <inheritdoc />
internal class ModEvents : IModEvents
{
/*********
** Accessors
*********/
- /// <summary>Events related to UI and drawing to the screen.</summary>
+ /// <inheritdoc />
+ public IContentEvents Content { get; }
+
+ /// <inheritdoc />
public IDisplayEvents Display { get; }
- /// <summary>Events linked to the game's update loop. The update loop runs roughly ≈60 times/second to run game logic like state changes, action handling, etc. These can be useful, but you should consider more semantic events like <see cref="IModEvents.Input"/> if possible.</summary>
+ /// <inheritdoc />
public IGameLoopEvents GameLoop { get; }
- /// <summary>Events raised when the player provides input using a controller, keyboard, or mouse.</summary>
+ /// <inheritdoc />
public IInputEvents Input { get; }
- /// <summary>Events raised for multiplayer messages and connections.</summary>
+ /// <inheritdoc />
public IMultiplayerEvents Multiplayer { get; }
- /// <summary>Events raised when the player data changes.</summary>
+ /// <inheritdoc />
public IPlayerEvents Player { get; }
- /// <summary>Events raised when something changes in the world.</summary>
+ /// <inheritdoc />
public IWorldEvents World { get; }
- /// <summary>Events serving specialized edge cases that shouldn't be used by most mods.</summary>
+ /// <inheritdoc />
public ISpecializedEvents Specialized { get; }
@@ -38,6 +41,7 @@ namespace StardewModdingAPI.Framework.Events
/// <param name="eventManager">The underlying event manager.</param>
public ModEvents(IModMetadata mod, EventManager eventManager)
{
+ this.Content = new ModContentEvents(mod, eventManager);
this.Display = new ModDisplayEvents(mod, eventManager);
this.GameLoop = new ModGameLoopEvents(mod, eventManager);
this.Input = new ModInputEvents(mod, eventManager);
diff --git a/src/SMAPI/Framework/Events/ModGameLoopEvents.cs b/src/SMAPI/Framework/Events/ModGameLoopEvents.cs
index 1150d641..5f0db369 100644
--- a/src/SMAPI/Framework/Events/ModGameLoopEvents.cs
+++ b/src/SMAPI/Framework/Events/ModGameLoopEvents.cs
@@ -3,104 +3,104 @@ using StardewModdingAPI.Events;
namespace StardewModdingAPI.Framework.Events
{
- /// <summary>Events linked to the game's update loop. The update loop runs roughly ≈60 times/second to run game logic like state changes, action handling, etc. These can be useful, but you should consider more semantic events like <see cref="IInputEvents"/> if possible.</summary>
+ /// <inheritdoc cref="IGameLoopEvents" />
internal class ModGameLoopEvents : ModEventsBase, IGameLoopEvents
{
/*********
** Accessors
*********/
- /// <summary>Raised after the game is launched, right before the first update tick.</summary>
+ /// <inheritdoc />
public event EventHandler<GameLaunchedEventArgs> GameLaunched
{
add => this.EventManager.GameLaunched.Add(value, this.Mod);
remove => this.EventManager.GameLaunched.Remove(value);
}
- /// <summary>Raised before the game performs its overall update tick (≈60 times per second).</summary>
+ /// <inheritdoc />
public event EventHandler<UpdateTickingEventArgs> UpdateTicking
{
add => this.EventManager.UpdateTicking.Add(value, this.Mod);
remove => this.EventManager.UpdateTicking.Remove(value);
}
- /// <summary>Raised after the game performs its overall update tick (≈60 times per second).</summary>
+ /// <inheritdoc />
public event EventHandler<UpdateTickedEventArgs> UpdateTicked
{
add => this.EventManager.UpdateTicked.Add(value, this.Mod);
remove => this.EventManager.UpdateTicked.Remove(value);
}
- /// <summary>Raised once per second before the game state is updated.</summary>
+ /// <inheritdoc />
public event EventHandler<OneSecondUpdateTickingEventArgs> OneSecondUpdateTicking
{
add => this.EventManager.OneSecondUpdateTicking.Add(value, this.Mod);
remove => this.EventManager.OneSecondUpdateTicking.Remove(value);
}
- /// <summary>Raised once per second after the game state is updated.</summary>
+ /// <inheritdoc />
public event EventHandler<OneSecondUpdateTickedEventArgs> OneSecondUpdateTicked
{
add => this.EventManager.OneSecondUpdateTicked.Add(value, this.Mod);
remove => this.EventManager.OneSecondUpdateTicked.Remove(value);
}
- /// <summary>Raised before the game creates a new save file.</summary>
+ /// <inheritdoc />
public event EventHandler<SaveCreatingEventArgs> SaveCreating
{
add => this.EventManager.SaveCreating.Add(value, this.Mod);
remove => this.EventManager.SaveCreating.Remove(value);
}
- /// <summary>Raised after the game finishes creating the save file.</summary>
+ /// <inheritdoc />
public event EventHandler<SaveCreatedEventArgs> SaveCreated
{
add => this.EventManager.SaveCreated.Add(value, this.Mod);
remove => this.EventManager.SaveCreated.Remove(value);
}
- /// <summary>Raised before the game begins writes data to the save file.</summary>
+ /// <inheritdoc />
public event EventHandler<SavingEventArgs> Saving
{
add => this.EventManager.Saving.Add(value, this.Mod);
remove => this.EventManager.Saving.Remove(value);
}
- /// <summary>Raised after the game finishes writing data to the save file.</summary>
+ /// <inheritdoc />
public event EventHandler<SavedEventArgs> Saved
{
add => this.EventManager.Saved.Add(value, this.Mod);
remove => this.EventManager.Saved.Remove(value);
}
- /// <summary>Raised after the player loads a save slot and the world is initialized.</summary>
+ /// <inheritdoc />
public event EventHandler<SaveLoadedEventArgs> SaveLoaded
{
add => this.EventManager.SaveLoaded.Add(value, this.Mod);
remove => this.EventManager.SaveLoaded.Remove(value);
}
- /// <summary>Raised after the game begins a new day (including when the player loads a save).</summary>
+ /// <inheritdoc />
public event EventHandler<DayStartedEventArgs> DayStarted
{
add => this.EventManager.DayStarted.Add(value, this.Mod);
remove => this.EventManager.DayStarted.Remove(value);
}
- /// <summary>Raised before the game ends the current day. This happens before it starts setting up the next day and before <see cref="IGameLoopEvents.Saving"/>.</summary>
+ /// <inheritdoc />
public event EventHandler<DayEndingEventArgs> DayEnding
{
add => this.EventManager.DayEnding.Add(value, this.Mod);
remove => this.EventManager.DayEnding.Remove(value);
}
- /// <summary>Raised after the in-game clock time changes.</summary>
+ /// <inheritdoc />
public event EventHandler<TimeChangedEventArgs> TimeChanged
{
add => this.EventManager.TimeChanged.Add(value, this.Mod);
remove => this.EventManager.TimeChanged.Remove(value);
}
- /// <summary>Raised after the game returns to the title screen.</summary>
+ /// <inheritdoc />
public event EventHandler<ReturnedToTitleEventArgs> ReturnedToTitle
{
add => this.EventManager.ReturnedToTitle.Add(value, this.Mod);
diff --git a/src/SMAPI/Framework/Events/ModInputEvents.cs b/src/SMAPI/Framework/Events/ModInputEvents.cs
index 6f423e5d..40edf806 100644
--- a/src/SMAPI/Framework/Events/ModInputEvents.cs
+++ b/src/SMAPI/Framework/Events/ModInputEvents.cs
@@ -3,41 +3,41 @@ using StardewModdingAPI.Events;
namespace StardewModdingAPI.Framework.Events
{
- /// <summary>Events raised when the player provides input using a controller, keyboard, or mouse.</summary>
+ /// <inheritdoc cref="IInputEvents" />
internal class ModInputEvents : ModEventsBase, IInputEvents
{
/*********
** Accessors
*********/
- /// <summary>Raised after the player presses or releases any buttons on the keyboard, controller, or mouse.</summary>
+ /// <inheritdoc />
public event EventHandler<ButtonsChangedEventArgs> ButtonsChanged
{
add => this.EventManager.ButtonsChanged.Add(value, this.Mod);
remove => this.EventManager.ButtonsChanged.Remove(value);
}
- /// <summary>Raised after the player presses a button on the keyboard, controller, or mouse.</summary>
+ /// <inheritdoc />
public event EventHandler<ButtonPressedEventArgs> ButtonPressed
{
add => this.EventManager.ButtonPressed.Add(value, this.Mod);
remove => this.EventManager.ButtonPressed.Remove(value);
}
- /// <summary>Raised after the player releases a button on the keyboard, controller, or mouse.</summary>
+ /// <inheritdoc />
public event EventHandler<ButtonReleasedEventArgs> ButtonReleased
{
add => this.EventManager.ButtonReleased.Add(value, this.Mod);
remove => this.EventManager.ButtonReleased.Remove(value);
}
- /// <summary>Raised after the player moves the in-game cursor.</summary>
+ /// <inheritdoc />
public event EventHandler<CursorMovedEventArgs> CursorMoved
{
add => this.EventManager.CursorMoved.Add(value, this.Mod);
remove => this.EventManager.CursorMoved.Remove(value);
}
- /// <summary>Raised after the player scrolls the mouse wheel.</summary>
+ /// <inheritdoc />
public event EventHandler<MouseWheelScrolledEventArgs> MouseWheelScrolled
{
add => this.EventManager.MouseWheelScrolled.Add(value, this.Mod);
diff --git a/src/SMAPI/Framework/Events/ModMultiplayerEvents.cs b/src/SMAPI/Framework/Events/ModMultiplayerEvents.cs
index 2f9b9482..b90f64fa 100644
--- a/src/SMAPI/Framework/Events/ModMultiplayerEvents.cs
+++ b/src/SMAPI/Framework/Events/ModMultiplayerEvents.cs
@@ -3,34 +3,34 @@ using StardewModdingAPI.Events;
namespace StardewModdingAPI.Framework.Events
{
- /// <summary>Events raised for multiplayer messages and connections.</summary>
+ /// <inheritdoc cref="IMultiplayerEvents" />
internal class ModMultiplayerEvents : ModEventsBase, IMultiplayerEvents
{
/*********
** Accessors
*********/
- /// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection (<see cref="IMultiplayerEvents.PeerConnected"/>), so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary>
+ /// <inheritdoc />
public event EventHandler<PeerContextReceivedEventArgs> PeerContextReceived
{
add => this.EventManager.PeerContextReceived.Add(value, this.Mod);
remove => this.EventManager.PeerContextReceived.Remove(value);
}
- /// <summary>Raised after a peer connection is approved by the game.</summary>
+ /// <inheritdoc />
public event EventHandler<PeerConnectedEventArgs> PeerConnected
{
add => this.EventManager.PeerConnected.Add(value, this.Mod);
remove => this.EventManager.PeerConnected.Remove(value);
}
- /// <summary>Raised after a mod message is received over the network.</summary>
+ /// <inheritdoc />
public event EventHandler<ModMessageReceivedEventArgs> ModMessageReceived
{
add => this.EventManager.ModMessageReceived.Add(value, this.Mod);
remove => this.EventManager.ModMessageReceived.Remove(value);
}
- /// <summary>Raised after the connection with a peer is severed.</summary>
+ /// <inheritdoc />
public event EventHandler<PeerDisconnectedEventArgs> PeerDisconnected
{
add => this.EventManager.PeerDisconnected.Add(value, this.Mod);
diff --git a/src/SMAPI/Framework/Events/ModPlayerEvents.cs b/src/SMAPI/Framework/Events/ModPlayerEvents.cs
index 240beb8d..b2d89e9a 100644
--- a/src/SMAPI/Framework/Events/ModPlayerEvents.cs
+++ b/src/SMAPI/Framework/Events/ModPlayerEvents.cs
@@ -3,27 +3,27 @@ using StardewModdingAPI.Events;
namespace StardewModdingAPI.Framework.Events
{
- /// <summary>Events raised when the player data changes.</summary>
+ /// <inheritdoc cref="IPlayerEvents" />
internal class ModPlayerEvents : ModEventsBase, IPlayerEvents
{
/*********
** Accessors
*********/
- /// <summary>Raised after items are added or removed to a player's inventory. NOTE: this event is currently only raised for the local player.</summary>
+ /// <inheritdoc />
public event EventHandler<InventoryChangedEventArgs> InventoryChanged
{
add => this.EventManager.InventoryChanged.Add(value, this.Mod);
remove => this.EventManager.InventoryChanged.Remove(value);
}
- /// <summary>Raised after a player skill level changes. This happens as soon as they level up, not when the game notifies the player after their character goes to bed. NOTE: this event is currently only raised for the local player.</summary>
+ /// <inheritdoc />
public event EventHandler<LevelChangedEventArgs> LevelChanged
{
add => this.EventManager.LevelChanged.Add(value, this.Mod);
remove => this.EventManager.LevelChanged.Remove(value);
}
- /// <summary>Raised after a player warps to a new location. NOTE: this event is currently only raised for the local player.</summary>
+ /// <inheritdoc />
public event EventHandler<WarpedEventArgs> Warped
{
add => this.EventManager.Warped.Add(value, this.Mod);
diff --git a/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs b/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs
index 1d6788e1..7980208b 100644
--- a/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs
+++ b/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs
@@ -3,27 +3,27 @@ using StardewModdingAPI.Events;
namespace StardewModdingAPI.Framework.Events
{
- /// <summary>Events serving specialized edge cases that shouldn't be used by most mods.</summary>
+ /// <inheritdoc cref="ISpecializedEvents" />
internal class ModSpecializedEvents : ModEventsBase, ISpecializedEvents
{
/*********
** Accessors
*********/
- /// <summary>Raised when the low-level stage in the game's loading process has changed. This is an advanced event for mods which need to run code at specific points in the loading process. The available stages or when they happen might change without warning in future versions (e.g. due to changes in the game's load process), so mods using this event are more likely to break or have bugs. Most mods should use <see cref="IGameLoopEvents"/> instead.</summary>
+ /// <inheritdoc />
public event EventHandler<LoadStageChangedEventArgs> LoadStageChanged
{
add => this.EventManager.LoadStageChanged.Add(value, this.Mod);
remove => this.EventManager.LoadStageChanged.Remove(value);
}
- /// <summary>Raised before the game state is updated (≈60 times per second), regardless of normal SMAPI validation. This event is not thread-safe and may be invoked while game logic is running asynchronously. Changes to game state in this method may crash the game or corrupt an in-progress save. Do not use this event unless you're fully aware of the context in which your code will be run. Mods using this event will trigger a stability warning in the SMAPI console.</summary>
+ /// <inheritdoc />
public event EventHandler<UnvalidatedUpdateTickingEventArgs> UnvalidatedUpdateTicking
{
add => this.EventManager.UnvalidatedUpdateTicking.Add(value, this.Mod);
remove => this.EventManager.UnvalidatedUpdateTicking.Remove(value);
}
- /// <summary>Raised after the game state is updated (≈60 times per second), regardless of normal SMAPI validation. This event is not thread-safe and may be invoked while game logic is running asynchronously. Changes to game state in this method may crash the game or corrupt an in-progress save. Do not use this event unless you're fully aware of the context in which your code will be run. Mods using this event will trigger a stability warning in the SMAPI console.</summary>
+ /// <inheritdoc />
public event EventHandler<UnvalidatedUpdateTickedEventArgs> UnvalidatedUpdateTicked
{
add => this.EventManager.UnvalidatedUpdateTicked.Add(value, this.Mod);
diff --git a/src/SMAPI/Framework/Events/ModWorldEvents.cs b/src/SMAPI/Framework/Events/ModWorldEvents.cs
index f4c40abc..a7b7d799 100644
--- a/src/SMAPI/Framework/Events/ModWorldEvents.cs
+++ b/src/SMAPI/Framework/Events/ModWorldEvents.cs
@@ -3,69 +3,69 @@ using StardewModdingAPI.Events;
namespace StardewModdingAPI.Framework.Events
{
- /// <summary>Events raised when something changes in the world.</summary>
+ /// <inheritdoc cref="IWorldEvents" />
internal class ModWorldEvents : ModEventsBase, IWorldEvents
{
/*********
** Accessors
*********/
- /// <summary>Raised after a game location is added or removed.</summary>
+ /// <inheritdoc />
public event EventHandler<LocationListChangedEventArgs> LocationListChanged
{
add => this.EventManager.LocationListChanged.Add(value, this.Mod);
remove => this.EventManager.LocationListChanged.Remove(value);
}
- /// <summary>Raised after buildings are added or removed in a location.</summary>
+ /// <inheritdoc />
public event EventHandler<BuildingListChangedEventArgs> BuildingListChanged
{
add => this.EventManager.BuildingListChanged.Add(value, this.Mod);
remove => this.EventManager.BuildingListChanged.Remove(value);
}
- /// <summary>Raised after debris are added or removed in a location.</summary>
+ /// <inheritdoc />
public event EventHandler<DebrisListChangedEventArgs> DebrisListChanged
{
add => this.EventManager.DebrisListChanged.Add(value, this.Mod);
remove => this.EventManager.DebrisListChanged.Remove(value);
}
- /// <summary>Raised after large terrain features (like bushes) are added or removed in a location.</summary>
+ /// <inheritdoc />
public event EventHandler<LargeTerrainFeatureListChangedEventArgs> LargeTerrainFeatureListChanged
{
add => this.EventManager.LargeTerrainFeatureListChanged.Add(value, this.Mod);
remove => this.EventManager.LargeTerrainFeatureListChanged.Remove(value);
}
- /// <summary>Raised after NPCs are added or removed in a location.</summary>
+ /// <inheritdoc />
public event EventHandler<NpcListChangedEventArgs> NpcListChanged
{
add => this.EventManager.NpcListChanged.Add(value, this.Mod);
remove => this.EventManager.NpcListChanged.Remove(value);
}
- /// <summary>Raised after objects are added or removed in a location.</summary>
+ /// <inheritdoc />
public event EventHandler<ObjectListChangedEventArgs> ObjectListChanged
{
add => this.EventManager.ObjectListChanged.Add(value, this.Mod);
remove => this.EventManager.ObjectListChanged.Remove(value);
}
- /// <summary>Raised after items are added or removed from a chest.</summary>
+ /// <inheritdoc />
public event EventHandler<ChestInventoryChangedEventArgs> ChestInventoryChanged
{
add => this.EventManager.ChestInventoryChanged.Add(value, this.Mod);
remove => this.EventManager.ChestInventoryChanged.Remove(value);
}
- /// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary>
+ /// <inheritdoc />
public event EventHandler<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged
{
add => this.EventManager.TerrainFeatureListChanged.Add(value, this.Mod);
remove => this.EventManager.TerrainFeatureListChanged.Remove(value);
}
- /// <summary>Raised after furniture are added or removed in a location.</summary>
+ /// <inheritdoc />
public event EventHandler<FurnitureListChangedEventArgs> FurnitureListChanged
{
add => this.EventManager.FurnitureListChanged.Add(value, this.Mod);
diff --git a/src/SMAPI/Framework/Exceptions/SContentLoadException.cs b/src/SMAPI/Framework/Exceptions/SContentLoadException.cs
index 85d85e3d..be1fe748 100644
--- a/src/SMAPI/Framework/Exceptions/SContentLoadException.cs
+++ b/src/SMAPI/Framework/Exceptions/SContentLoadException.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using Microsoft.Xna.Framework.Content;
namespace StardewModdingAPI.Framework.Exceptions
@@ -12,7 +12,7 @@ namespace StardewModdingAPI.Framework.Exceptions
/// <summary>Construct an instance.</summary>
/// <param name="message">The error message.</param>
/// <param name="ex">The underlying exception, if any.</param>
- public SContentLoadException(string message, Exception ex = null)
+ public SContentLoadException(string message, Exception? ex = null)
: base(message, ex) { }
}
}
diff --git a/src/SMAPI/Framework/GameVersion.cs b/src/SMAPI/Framework/GameVersion.cs
index b69c6757..542c1345 100644
--- a/src/SMAPI/Framework/GameVersion.cs
+++ b/src/SMAPI/Framework/GameVersion.cs
@@ -53,7 +53,7 @@ namespace StardewModdingAPI.Framework
private static string GetSemanticVersionString(string gameVersion)
{
// mapped version
- return GameVersion.VersionMap.TryGetValue(gameVersion, out string semanticVersion)
+ return GameVersion.VersionMap.TryGetValue(gameVersion, out string? semanticVersion)
? semanticVersion
: gameVersion;
}
@@ -62,10 +62,10 @@ namespace StardewModdingAPI.Framework
/// <param name="semanticVersion">The semantic version string.</param>
private static string GetGameVersionString(string semanticVersion)
{
- foreach (var mapping in GameVersion.VersionMap)
+ foreach ((string gameVersion, string equivalentSemanticVersion) in GameVersion.VersionMap)
{
- if (mapping.Value.Equals(semanticVersion, StringComparison.OrdinalIgnoreCase))
- return mapping.Key;
+ if (equivalentSemanticVersion.Equals(semanticVersion, StringComparison.OrdinalIgnoreCase))
+ return gameVersion;
}
return semanticVersion;
diff --git a/src/SMAPI/Framework/IModMetadata.cs b/src/SMAPI/Framework/IModMetadata.cs
index cb876ee4..7cee20b9 100644
--- a/src/SMAPI/Framework/IModMetadata.cs
+++ b/src/SMAPI/Framework/IModMetadata.cs
@@ -27,7 +27,7 @@ namespace StardewModdingAPI.Framework
string RelativeDirectoryPath { get; }
/// <summary>Metadata about the mod from SMAPI's internal data (if any).</summary>
- ModDataRecordVersionedFields DataRecord { get; }
+ ModDataRecordVersionedFields? DataRecord { get; }
/// <summary>The metadata resolution status.</summary>
ModMetadataStatus Status { get; }
@@ -39,31 +39,31 @@ namespace StardewModdingAPI.Framework
ModWarning Warnings { get; }
/// <summary>The reason the metadata is invalid, if any.</summary>
- string Error { get; }
+ string? Error { get; }
/// <summary>A detailed technical message for <see cref="Error"/>, if any.</summary>
- public string ErrorDetails { get; }
+ string? ErrorDetails { get; }
/// <summary>Whether the mod folder should be ignored. This is <c>true</c> if it was found within a folder whose name starts with a dot.</summary>
bool IsIgnored { get; }
/// <summary>The mod instance (if loaded and <see cref="IModInfo.IsContentPack"/> is false).</summary>
- IMod Mod { get; }
+ IMod? Mod { get; }
/// <summary>The content pack instance (if loaded and <see cref="IModInfo.IsContentPack"/> is true).</summary>
- IContentPack ContentPack { get; }
+ IContentPack? ContentPack { get; }
/// <summary>The translations for this mod (if loaded).</summary>
- TranslationHelper Translations { get; }
+ TranslationHelper? Translations { get; }
/// <summary>Writes messages to the console and log file as this mod.</summary>
- IMonitor Monitor { get; }
+ IMonitor? Monitor { get; }
/// <summary>The mod-provided API (if any).</summary>
- object Api { get; }
+ object? Api { get; }
/// <summary>The update-check metadata for this mod (if any).</summary>
- ModEntryModel UpdateCheckData { get; }
+ ModEntryModel? UpdateCheckData { get; }
/// <summary>The fake content packs created by this mod, if any.</summary>
ISet<WeakReference<ContentPack>> FakeContentPacks { get; }
@@ -82,7 +82,7 @@ namespace StardewModdingAPI.Framework
/// <param name="error">The reason the metadata is invalid, if any.</param>
/// <param name="errorDetails">A detailed technical message, if any.</param>
/// <returns>Return the instance for chaining.</returns>
- IModMetadata SetStatus(ModMetadataStatus status, ModFailReason reason, string error, string errorDetails = null);
+ IModMetadata SetStatus(ModMetadataStatus status, ModFailReason reason, string? error, string? errorDetails = null);
/// <summary>Set a warning flag for the mod.</summary>
/// <param name="warning">The warning to set.</param>
@@ -101,7 +101,7 @@ namespace StardewModdingAPI.Framework
/// <summary>Set the mod-provided API instance.</summary>
/// <param name="api">The mod-provided API.</param>
- IModMetadata SetApi(object api);
+ IModMetadata SetApi(object? api);
/// <summary>Set the update-check metadata for this mod.</summary>
/// <param name="data">The update-check metadata.</param>
@@ -115,7 +115,7 @@ namespace StardewModdingAPI.Framework
/// <summary>Whether the mod has the given ID.</summary>
/// <param name="id">The mod ID to check.</param>
- bool HasID(string id);
+ bool HasID(string? id);
/// <summary>Get the defined update keys.</summary>
/// <param name="validOnly">Only return valid update keys.</param>
diff --git a/src/SMAPI/Framework/Input/GamePadStateBuilder.cs b/src/SMAPI/Framework/Input/GamePadStateBuilder.cs
index b0bb7f80..4ac3332c 100644
--- a/src/SMAPI/Framework/Input/GamePadStateBuilder.cs
+++ b/src/SMAPI/Framework/Input/GamePadStateBuilder.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;
@@ -21,7 +22,7 @@ namespace StardewModdingAPI.Framework.Input
private GamePadState? State;
/// <summary>The current button states.</summary>
- private readonly IDictionary<SButton, ButtonState> ButtonStates;
+ private readonly IDictionary<SButton, ButtonState>? ButtonStates;
/// <summary>The left trigger value.</summary>
private float LeftTrigger;
@@ -40,6 +41,7 @@ namespace StardewModdingAPI.Framework.Input
** Accessors
*********/
/// <summary>Whether the gamepad is currently connected.</summary>
+ [MemberNotNullWhen(true, nameof(GamePadStateBuilder.ButtonStates))]
public bool IsConnected { get; }
@@ -85,8 +87,7 @@ namespace StardewModdingAPI.Framework.Input
this.RightStickPos = sticks.Right;
}
- /// <summary>Override the states for a set of buttons.</summary>
- /// <param name="overrides">The button state overrides.</param>
+ /// <inheritdoc />
public GamePadStateBuilder OverrideButtons(IDictionary<SButton, SButtonState> overrides)
{
if (!this.IsConnected)
@@ -104,10 +105,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 +119,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;
@@ -151,7 +152,7 @@ namespace StardewModdingAPI.Framework.Input
return this;
}
- /// <summary>Get the currently pressed buttons.</summary>
+ /// <inheritdoc />
public IEnumerable<SButton> GetPressedButtons()
{
if (!this.IsConnected)
@@ -191,7 +192,7 @@ namespace StardewModdingAPI.Framework.Input
}
}
- /// <summary>Get the equivalent state.</summary>
+ /// <inheritdoc />
public GamePadState GetState()
{
this.State ??= new GamePadState(
@@ -212,6 +213,9 @@ namespace StardewModdingAPI.Framework.Input
/// <summary>Get the pressed gamepad buttons.</summary>
private IEnumerable<Buttons> GetPressedGamePadButtons()
{
+ if (!this.IsConnected)
+ yield break;
+
foreach (var pair in this.ButtonStates)
{
if (pair.Value == ButtonState.Pressed && pair.Key.TryGetController(out Buttons button))
diff --git a/src/SMAPI/Framework/Input/KeyboardStateBuilder.cs b/src/SMAPI/Framework/Input/KeyboardStateBuilder.cs
index 620ad442..f66fbd07 100644
--- a/src/SMAPI/Framework/Input/KeyboardStateBuilder.cs
+++ b/src/SMAPI/Framework/Input/KeyboardStateBuilder.cs
@@ -14,7 +14,7 @@ namespace StardewModdingAPI.Framework.Input
private KeyboardState? State;
/// <summary>The pressed buttons.</summary>
- private readonly HashSet<Keys> PressedButtons = new HashSet<Keys>();
+ private readonly HashSet<Keys> PressedButtons = new();
/*********
@@ -27,12 +27,11 @@ namespace StardewModdingAPI.Framework.Input
this.State = state;
this.PressedButtons.Clear();
- foreach (var button in state.GetPressedKeys())
+ foreach (Keys button in state.GetPressedKeys())
this.PressedButtons.Add(button);
}
- /// <summary>Override the states for a set of buttons.</summary>
- /// <param name="overrides">The button state overrides.</param>
+ /// <inheritdoc />
public KeyboardStateBuilder OverrideButtons(IDictionary<SButton, SButtonState> overrides)
{
foreach (var pair in overrides)
@@ -51,14 +50,14 @@ namespace StardewModdingAPI.Framework.Input
return this;
}
- /// <summary>Get the currently pressed buttons.</summary>
+ /// <inheritdoc />
public IEnumerable<SButton> GetPressedButtons()
{
foreach (Keys key in this.PressedButtons)
yield return key.ToSButton();
}
- /// <summary>Get the equivalent state.</summary>
+ /// <inheritdoc />
public KeyboardState GetState()
{
return
diff --git a/src/SMAPI/Framework/Input/MouseStateBuilder.cs b/src/SMAPI/Framework/Input/MouseStateBuilder.cs
index a1ac5492..c2a0891b 100644
--- a/src/SMAPI/Framework/Input/MouseStateBuilder.cs
+++ b/src/SMAPI/Framework/Input/MouseStateBuilder.cs
@@ -51,8 +51,7 @@ namespace StardewModdingAPI.Framework.Input
this.ScrollWheelValue = state.ScrollWheelValue;
}
- /// <summary>Override the states for a set of buttons.</summary>
- /// <param name="overrides">The button state overrides.</param>
+ /// <inheritdoc />
public MouseStateBuilder OverrideButtons(IDictionary<SButton, SButtonState> overrides)
{
foreach (var pair in overrides)
@@ -67,7 +66,7 @@ namespace StardewModdingAPI.Framework.Input
return this;
}
- /// <summary>Get the currently pressed buttons.</summary>
+ /// <inheritdoc />
public IEnumerable<SButton> GetPressedButtons()
{
foreach (var pair in this.ButtonStates)
@@ -77,7 +76,7 @@ namespace StardewModdingAPI.Framework.Input
}
}
- /// <summary>Get the equivalent state.</summary>
+ /// <inheritdoc />
public MouseState GetState()
{
this.State ??= new MouseState(
diff --git a/src/SMAPI/Framework/Input/SInputState.cs b/src/SMAPI/Framework/Input/SInputState.cs
index a8d1f371..fef83af7 100644
--- a/src/SMAPI/Framework/Input/SInputState.cs
+++ b/src/SMAPI/Framework/Input/SInputState.cs
@@ -15,16 +15,16 @@ namespace StardewModdingAPI.Framework.Input
** Accessors
*********/
/// <summary>The cursor position on the screen adjusted for the zoom level.</summary>
- private CursorPosition CursorPositionImpl;
+ private CursorPosition CursorPositionImpl = new(Vector2.Zero, Vector2.Zero, Vector2.Zero, Vector2.Zero);
/// <summary>The player's last known tile position.</summary>
private Vector2? LastPlayerTile;
/// <summary>The buttons to press until the game next handles input.</summary>
- private readonly HashSet<SButton> CustomPressedKeys = new HashSet<SButton>();
+ private readonly HashSet<SButton> CustomPressedKeys = new();
/// <summary>The buttons to consider released until the actual button is released.</summary>
- private readonly HashSet<SButton> CustomReleasedKeys = new HashSet<SButton>();
+ private readonly HashSet<SButton> CustomReleasedKeys = new();
/// <summary>Whether there are new overrides in <see cref="CustomPressedKeys"/> or <see cref="CustomReleasedKeys"/> that haven't been applied to the previous state.</summary>
private bool HasNewOverrides;
@@ -72,8 +72,8 @@ namespace StardewModdingAPI.Framework.Input
var controller = new GamePadStateBuilder(base.GetGamePadState());
var keyboard = new KeyboardStateBuilder(base.GetKeyboardState());
var mouse = new MouseStateBuilder(base.GetMouseState());
- Vector2 cursorAbsolutePos = new Vector2((mouse.X * zoomMultiplier) + Game1.viewport.X, (mouse.Y * zoomMultiplier) + Game1.viewport.Y);
- Vector2? playerTilePos = Context.IsPlayerFree ? Game1.player.getTileLocation() : (Vector2?)null;
+ Vector2 cursorAbsolutePos = new((mouse.X * zoomMultiplier) + Game1.viewport.X, (mouse.Y * zoomMultiplier) + Game1.viewport.Y);
+ Vector2? playerTilePos = Context.IsPlayerFree ? Game1.player.getTileLocation() : null;
HashSet<SButton> reallyDown = new HashSet<SButton>(this.GetPressedButtons(keyboard, mouse, controller));
// apply overrides
@@ -104,7 +104,7 @@ namespace StardewModdingAPI.Framework.Input
this.KeyboardState = keyboard.GetState();
this.MouseState = mouse.GetState();
this.ButtonStates = activeButtons;
- if (cursorAbsolutePos != this.CursorPositionImpl?.AbsolutePixels || playerTilePos != this.LastPlayerTile)
+ if (cursorAbsolutePos != this.CursorPositionImpl.AbsolutePixels || playerTilePos != this.LastPlayerTile)
{
this.LastPlayerTile = playerTilePos;
this.CursorPositionImpl = this.GetCursorPosition(this.MouseState, cursorAbsolutePos, zoomMultiplier);
@@ -203,8 +203,8 @@ namespace StardewModdingAPI.Framework.Input
/// <param name="zoomMultiplier">The multiplier applied to pixel coordinates to adjust them for pixel zoom.</param>
private CursorPosition GetCursorPosition(MouseState mouseState, Vector2 absolutePixels, float zoomMultiplier)
{
- Vector2 screenPixels = new Vector2(mouseState.X * zoomMultiplier, mouseState.Y * zoomMultiplier);
- Vector2 tile = new Vector2((int)((Game1.viewport.X + screenPixels.X) / Game1.tileSize), (int)((Game1.viewport.Y + screenPixels.Y) / Game1.tileSize));
+ Vector2 screenPixels = new(mouseState.X * zoomMultiplier, mouseState.Y * zoomMultiplier);
+ Vector2 tile = new((int)((Game1.viewport.X + screenPixels.X) / Game1.tileSize), (int)((Game1.viewport.Y + screenPixels.Y) / Game1.tileSize));
Vector2 grabTile = (Game1.mouseCursorTransparency > 0 && Utility.tileWithinRadiusOfPlayer((int)tile.X, (int)tile.Y, 1, Game1.player)) // derived from Game1.pressActionButton
? tile
: Game1.player.GetGrabTile();
@@ -234,7 +234,7 @@ namespace StardewModdingAPI.Framework.Input
isDown: pressed.Contains(button)
);
- if (button == SButton.MouseLeft || button == SButton.MouseMiddle || button == SButton.MouseRight || button == SButton.MouseX1 || button == SButton.MouseX2)
+ if (button is SButton.MouseLeft or SButton.MouseMiddle or SButton.MouseRight or SButton.MouseX1 or SButton.MouseX2)
mouseOverrides[button] = newState;
else if (button.TryGetKeyboard(out Keys _))
keyboardOverrides[button] = newState;
diff --git a/src/SMAPI/Framework/InternalExtensions.cs b/src/SMAPI/Framework/InternalExtensions.cs
index 4cb77a45..580651f3 100644
--- a/src/SMAPI/Framework/InternalExtensions.cs
+++ b/src/SMAPI/Framework/InternalExtensions.cs
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
-using System.Reflection;
using System.Threading;
using Microsoft.Xna.Framework.Graphics;
using StardewModdingAPI.Framework.Events;
@@ -42,9 +41,21 @@ namespace StardewModdingAPI.Framework
/// <param name="level">The log severity level.</param>
public static void LogAsMod(this IModMetadata metadata, string message, LogLevel level = LogLevel.Trace)
{
+ if (metadata.Monitor is null)
+ throw new InvalidOperationException($"Can't log as mod {metadata.DisplayName}: mod is broken or a content pack. Logged message:\n[{level}] {message}");
+
metadata.Monitor.Log(message, level);
}
+ /// <summary>Log a message using the mod's monitor, but only if it hasn't already been logged since the last game launch.</summary>
+ /// <param name="metadata">The mod whose monitor to use.</param>
+ /// <param name="message">The message to log.</param>
+ /// <param name="level">The log severity level.</param>
+ public static void LogAsModOnce(this IModMetadata metadata, string message, LogLevel level = LogLevel.Trace)
+ {
+ metadata.Monitor?.LogOnce(message, level);
+ }
+
/****
** ManagedEvent
****/
diff --git a/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs b/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs
index bad69a2a..9ecc1626 100644
--- a/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs
+++ b/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs
@@ -8,6 +8,13 @@ namespace StardewModdingAPI.Framework.Logging
internal class InterceptingTextWriter : TextWriter
{
/*********
+ ** Fields
+ *********/
+ /// <summary>The event raised when a message is written to the console directly.</summary>
+ private readonly Action<string> OnMessageIntercepted;
+
+
+ /*********
** Accessors
*********/
/// <summary>Prefixing a message with this character indicates that the console interceptor should write the string without intercepting it. (The character itself is not written.)</summary>
@@ -19,9 +26,6 @@ namespace StardewModdingAPI.Framework.Logging
/// <inheritdoc />
public override Encoding Encoding => this.Out.Encoding;
- /// <summary>The event raised when a message is written to the console directly.</summary>
- public event Action<string> OnMessageIntercepted;
-
/// <summary>Whether the text writer should ignore the next input if it's a newline.</summary>
/// <remarks>This is used when log output is suppressed from the console, since <c>Console.WriteLine</c> writes the trailing newline as a separate call.</remarks>
public bool IgnoreNextIfNewline { get; set; }
@@ -32,9 +36,11 @@ namespace StardewModdingAPI.Framework.Logging
*********/
/// <summary>Construct an instance.</summary>
/// <param name="output">The underlying output writer.</param>
- public InterceptingTextWriter(TextWriter output)
+ /// <param name="onMessageIntercepted">The event raised when a message is written to the console directly.</param>
+ public InterceptingTextWriter(TextWriter output, Action<string> onMessageIntercepted)
{
this.Out = output;
+ this.OnMessageIntercepted = onMessageIntercepted;
}
/// <inheritdoc />
@@ -63,7 +69,7 @@ namespace StardewModdingAPI.Framework.Logging
this.Out.Write(buffer, index, count);
}
else
- this.OnMessageIntercepted?.Invoke(new string(buffer, index, count));
+ this.OnMessageIntercepted(new string(buffer, index, count));
}
/// <inheritdoc />
@@ -72,12 +78,6 @@ namespace StardewModdingAPI.Framework.Logging
this.Out.Write(ch);
}
- /// <inheritdoc />
- protected override void Dispose(bool disposing)
- {
- this.OnMessageIntercepted = null;
- }
-
/*********
** Private methods
diff --git a/src/SMAPI/Framework/Logging/LogFileManager.cs b/src/SMAPI/Framework/Logging/LogFileManager.cs
index 6ab2bdfb..b396091a 100644
--- a/src/SMAPI/Framework/Logging/LogFileManager.cs
+++ b/src/SMAPI/Framework/Logging/LogFileManager.cs
@@ -30,7 +30,7 @@ namespace StardewModdingAPI.Framework.Logging
this.Path = path;
// create log directory if needed
- string logDir = System.IO.Path.GetDirectoryName(path);
+ string? logDir = System.IO.Path.GetDirectoryName(path);
if (logDir == null)
throw new ArgumentException($"The log path '{path}' is not valid.");
Directory.CreateDirectory(logDir);
diff --git a/src/SMAPI/Framework/Logging/LogManager.cs b/src/SMAPI/Framework/Logging/LogManager.cs
index a8a8b6ee..b94807b5 100644
--- a/src/SMAPI/Framework/Logging/LogManager.cs
+++ b/src/SMAPI/Framework/Logging/LogManager.cs
@@ -93,23 +93,26 @@ namespace StardewModdingAPI.Framework.Logging
/// <param name="getScreenIdForLog">Get the screen ID that should be logged to distinguish between players in split-screen mode, if any.</param>
public LogManager(string logPath, ColorSchemeConfig colorConfig, bool writeToConsole, bool isVerbose, bool isDeveloperMode, Func<int?> getScreenIdForLog)
{
- // init construction logic
+ // init log file
+ this.LogFile = new LogFileManager(logPath);
+
+ // init monitor
this.GetMonitorImpl = name => new Monitor(name, LogManager.IgnoreChar, this.LogFile, colorConfig, isVerbose, getScreenIdForLog)
{
WriteToConsole = writeToConsole,
ShowTraceInConsole = isDeveloperMode,
ShowFullStampInConsole = isDeveloperMode
};
-
- // init fields
- this.LogFile = new LogFileManager(logPath);
this.Monitor = this.GetMonitor("SMAPI");
this.MonitorForGame = this.GetMonitor("game");
// redirect direct console output
- this.ConsoleInterceptor = new InterceptingTextWriter(Console.Out);
- if (writeToConsole)
- this.ConsoleInterceptor.OnMessageIntercepted += message => this.HandleConsoleMessage(this.MonitorForGame, message);
+ this.ConsoleInterceptor = new InterceptingTextWriter(
+ output: Console.Out,
+ onMessageIntercepted: writeToConsole
+ ? message => this.HandleConsoleMessage(this.MonitorForGame, message)
+ : _ => { }
+ );
Console.SetOut(this.ConsoleInterceptor);
// enable Unicode handling on Windows
@@ -154,7 +157,7 @@ namespace StardewModdingAPI.Framework.Logging
while (true)
{
// get input
- string input = Console.ReadLine();
+ string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input))
continue;
@@ -220,7 +223,7 @@ namespace StardewModdingAPI.Framework.Logging
if (File.Exists(Constants.UpdateMarker))
{
string[] rawUpdateFound = File.ReadAllText(Constants.UpdateMarker).Split(new[] { '|' }, 2);
- if (SemanticVersion.TryParse(rawUpdateFound[0], out ISemanticVersion updateFound))
+ if (SemanticVersion.TryParse(rawUpdateFound[0], out ISemanticVersion? updateFound))
{
if (Constants.ApiVersion.IsPrerelease() && updateFound.IsNewerThan(Constants.ApiVersion))
{
@@ -262,7 +265,7 @@ namespace StardewModdingAPI.Framework.Logging
/// <summary>Log the initial header with general SMAPI and system details.</summary>
/// <param name="modsPath">The path from which mods will be loaded.</param>
/// <param name="customSettings">The custom SMAPI settings.</param>
- public void LogIntro(string modsPath, IDictionary<string, object> customSettings)
+ public void LogIntro(string modsPath, IDictionary<string, object?> customSettings)
{
// log platform
this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} (build {Constants.GetBuildVersionLabel()}) on {EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform)}", LogLevel.Info);
@@ -324,7 +327,7 @@ namespace StardewModdingAPI.Framework.Logging
// log loaded content packs
if (loadedContentPacks.Any())
{
- string GetModDisplayName(string id) => loadedMods.FirstOrDefault(p => p.HasID(id))?.DisplayName;
+ string? GetModDisplayName(string id) => loadedMods.FirstOrDefault(p => p.HasID(id))?.DisplayName;
this.Monitor.Log($"Loaded {loadedContentPacks.Length} content packs:", LogLevel.Info);
foreach (IModMetadata metadata in loadedContentPacks.OrderBy(p => p.DisplayName))
@@ -333,7 +336,7 @@ namespace StardewModdingAPI.Framework.Logging
this.Monitor.Log(
$" {metadata.DisplayName} {manifest.Version}"
+ (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "")
- + $" | for {GetModDisplayName(metadata.Manifest.ContentPackFor.UniqueID)}"
+ + $" | for {GetModDisplayName(metadata.Manifest.ContentPackFor!.UniqueID)}"
+ (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""),
LogLevel.Info
);
@@ -396,6 +399,7 @@ namespace StardewModdingAPI.Framework.Logging
/// <param name="mods">The loaded mods.</param>
/// <param name="skippedMods">The mods which could not be loaded.</param>
/// <param name="logParanoidWarnings">Whether to log issues for mods which directly use potentially sensitive .NET APIs like file or shell access.</param>
+ [SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "Manifests aren't guaranteed non-null at this point in the loading process.")]
private void LogModWarnings(IEnumerable<IModMetadata> mods, IModMetadata[] skippedMods, bool logParanoidWarnings)
{
// get mods with warnings
@@ -429,7 +433,7 @@ namespace StardewModdingAPI.Framework.Logging
// duplicate mod: log first one only, don't show redundant version
if (mod.FailReason == ModFailReason.Duplicate && mod.HasManifest())
{
- if (loggedDuplicateIds.Add(mod.Manifest.UniqueID))
+ if (loggedDuplicateIds.Add(mod.Manifest!.UniqueID))
continue; // already logged
message = $" - {mod.DisplayName} because {mod.Error}";
@@ -608,7 +612,7 @@ namespace StardewModdingAPI.Framework.Logging
/// <param name="heading">A brief heading label for the group.</param>
/// <param name="blurb">A detailed explanation of the warning, split into lines.</param>
/// <param name="modLabel">Formats the mod label, or <c>null</c> to use the <see cref="IModMetadata.DisplayName"/>.</param>
- private void LogModWarningGroup(IModMetadata[] mods, Func<IModMetadata, bool> match, LogLevel level, string heading, string[] blurb, Func<IModMetadata, string> modLabel = null)
+ private void LogModWarningGroup(IModMetadata[] mods, Func<IModMetadata, bool> match, LogLevel level, string heading, string[] blurb, Func<IModMetadata, string>? modLabel = null)
{
// get matching mods
string[] modLabels = mods
diff --git a/src/SMAPI/Framework/ModHelpers/BaseHelper.cs b/src/SMAPI/Framework/ModHelpers/BaseHelper.cs
index 5a3d4bed..12390976 100644
--- a/src/SMAPI/Framework/ModHelpers/BaseHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/BaseHelper.cs
@@ -4,20 +4,27 @@ namespace StardewModdingAPI.Framework.ModHelpers
internal abstract class BaseHelper : IModLinked
{
/*********
+ ** Fields
+ *********/
+ /// <summary>The mod using this instance.</summary>
+ protected readonly IModMetadata Mod;
+
+
+ /*********
** Accessors
*********/
/// <inheritdoc />
- public string ModID { get; }
+ public string ModID => this.Mod.Manifest.UniqueID;
/*********
** Protected methods
*********/
/// <summary>Construct an instance.</summary>
- /// <param name="modID">The unique ID of the relevant mod.</param>
- protected BaseHelper(string modID)
+ /// <param name="mod">The mod using this instance.</param>
+ protected BaseHelper(IModMetadata mod)
{
- this.ModID = modID;
+ this.Mod = mod;
}
}
}
diff --git a/src/SMAPI/Framework/ModHelpers/CommandHelper.cs b/src/SMAPI/Framework/ModHelpers/CommandHelper.cs
index 69382009..226a8d69 100644
--- a/src/SMAPI/Framework/ModHelpers/CommandHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/CommandHelper.cs
@@ -1,4 +1,5 @@
using System;
+using StardewModdingAPI.Framework.Deprecations;
namespace StardewModdingAPI.Framework.ModHelpers
{
@@ -8,9 +9,6 @@ namespace StardewModdingAPI.Framework.ModHelpers
/*********
** Fields
*********/
- /// <summary>The mod using this instance.</summary>
- private readonly IModMetadata Mod;
-
/// <summary>Manages console commands.</summary>
private readonly CommandManager CommandManager;
@@ -22,9 +20,8 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <param name="mod">The mod using this instance.</param>
/// <param name="commandManager">Manages console commands.</param>
public CommandHelper(IModMetadata mod, CommandManager commandManager)
- : base(mod?.Manifest?.UniqueID ?? "SMAPI")
+ : base(mod)
{
- this.Mod = mod;
this.CommandManager = commandManager;
}
@@ -40,7 +37,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
public bool Trigger(string name, string[] arguments)
{
SCore.DeprecationManager.Warn(
- source: SCore.DeprecationManager.GetSourceName(this.ModID),
+ source: SCore.DeprecationManager.GetMod(this.ModID),
nounPhrase: $"{nameof(IModHelper)}.{nameof(IModHelper.ConsoleCommands)}.{nameof(ICommandHelper.Trigger)}",
version: "3.8.1",
severity: DeprecationLevel.Notice
diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs
index bfca2264..3c2441e8 100644
--- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs
@@ -7,12 +7,15 @@ using System.IO;
using System.Linq;
using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.ContentManagers;
+using StardewModdingAPI.Framework.Deprecations;
using StardewModdingAPI.Framework.Exceptions;
+using StardewModdingAPI.Framework.Reflection;
using StardewValley;
namespace StardewModdingAPI.Framework.ModHelpers
{
/// <summary>Provides an API for loading content assets.</summary>
+ [Obsolete]
internal class ContentHelper : BaseHelper, IContentHelper
{
/*********
@@ -27,12 +30,12 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <summary>A content manager for this mod which manages files from the mod's folder.</summary>
private readonly ModContentManager ModContentManager;
- /// <summary>The friendly mod name for use in errors.</summary>
- private readonly string ModName;
-
/// <summary>Encapsulates monitoring and logging.</summary>
private readonly IMonitor Monitor;
+ /// <summary>Simplifies access to private code.</summary>
+ private readonly Reflector Reflection;
+
/*********
** Accessors
@@ -44,16 +47,42 @@ namespace StardewModdingAPI.Framework.ModHelpers
public LocalizedContentManager.LanguageCode CurrentLocaleConstant => this.GameContentManager.Language;
/// <summary>The observable implementation of <see cref="AssetEditors"/>.</summary>
- internal ObservableCollection<IAssetEditor> ObservableAssetEditors { get; } = new ObservableCollection<IAssetEditor>();
+ internal ObservableCollection<IAssetEditor> ObservableAssetEditors { get; } = new();
/// <summary>The observable implementation of <see cref="AssetLoaders"/>.</summary>
- internal ObservableCollection<IAssetLoader> ObservableAssetLoaders { get; } = new ObservableCollection<IAssetLoader>();
+ internal ObservableCollection<IAssetLoader> ObservableAssetLoaders { get; } = new();
/// <inheritdoc />
- public IList<IAssetLoader> AssetLoaders => this.ObservableAssetLoaders;
+ public IList<IAssetLoader> AssetLoaders
+ {
+ get
+ {
+ SCore.DeprecationManager.Warn(
+ source: this.Mod,
+ nounPhrase: $"{nameof(IContentHelper)}.{nameof(IContentHelper.AssetLoaders)}",
+ version: "3.14.0",
+ severity: DeprecationLevel.Notice
+ );
+
+ return this.ObservableAssetLoaders;
+ }
+ }
/// <inheritdoc />
- public IList<IAssetEditor> AssetEditors => this.ObservableAssetEditors;
+ public IList<IAssetEditor> AssetEditors
+ {
+ get
+ {
+ SCore.DeprecationManager.Warn(
+ source: this.Mod,
+ nounPhrase: $"{nameof(IContentHelper)}.{nameof(IContentHelper.AssetEditors)}",
+ version: "3.14.0",
+ severity: DeprecationLevel.Notice
+ );
+
+ return this.ObservableAssetEditors;
+ }
+ }
/*********
@@ -62,48 +91,62 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <summary>Construct an instance.</summary>
/// <param name="contentCore">SMAPI's core content logic.</param>
/// <param name="modFolderPath">The absolute path to the mod folder.</param>
- /// <param name="modID">The unique ID of the relevant mod.</param>
- /// <param name="modName">The friendly mod name for use in errors.</param>
+ /// <param name="mod">The mod using this instance.</param>
/// <param name="monitor">Encapsulates monitoring and logging.</param>
- public ContentHelper(ContentCoordinator contentCore, string modFolderPath, string modID, string modName, IMonitor monitor)
- : base(modID)
+ /// <param name="reflection">Simplifies access to private code.</param>
+ public ContentHelper(ContentCoordinator contentCore, string modFolderPath, IModMetadata mod, IMonitor monitor, Reflector reflection)
+ : base(mod)
{
- string managedAssetPrefix = contentCore.GetManagedAssetPrefix(modID);
+ string managedAssetPrefix = contentCore.GetManagedAssetPrefix(mod.Manifest.UniqueID);
this.ContentCore = contentCore;
this.GameContentManager = contentCore.CreateGameContentManager(managedAssetPrefix + ".content");
- this.ModContentManager = contentCore.CreateModContentManager(managedAssetPrefix, modName, modFolderPath, this.GameContentManager);
- this.ModName = modName;
+ this.ModContentManager = contentCore.CreateModContentManager(managedAssetPrefix, this.Mod.DisplayName, modFolderPath, this.GameContentManager);
this.Monitor = monitor;
+ this.Reflection = reflection;
}
/// <inheritdoc />
public T Load<T>(string key, ContentSource source = ContentSource.ModFolder)
+ where T : notnull
{
+ IAssetName assetName = this.ContentCore.ParseAssetName(key, allowLocales: source == ContentSource.GameContent);
+
try
{
this.AssertAndNormalizeAssetName(key);
switch (source)
{
case ContentSource.GameContent:
- return this.GameContentManager.Load<T>(key, this.CurrentLocaleConstant, useCache: false);
+ if (assetName.Name.EndsWith(".xnb", StringComparison.OrdinalIgnoreCase))
+ {
+ assetName = this.ContentCore.ParseAssetName(assetName.Name[..^4], allowLocales: true);
+ SCore.DeprecationManager.Warn(
+ this.Mod,
+ "loading assets from the Content folder with a .xnb file extension",
+ "3.14.0",
+ DeprecationLevel.Notice
+ );
+ }
+
+ return this.GameContentManager.LoadLocalized<T>(assetName, this.CurrentLocaleConstant, useCache: false);
case ContentSource.ModFolder:
- return this.ModContentManager.Load<T>(key, Constants.DefaultLanguage, useCache: false);
+ return this.ModContentManager.LoadExact<T>(assetName, useCache: false);
default:
- throw new SContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}: unknown content source '{source}'.");
+ throw new SContentLoadException($"{this.Mod.DisplayName} failed loading content asset '{key}' from {source}: unknown content source '{source}'.");
}
}
- catch (Exception ex) when (!(ex is SContentLoadException))
+ catch (Exception ex) when (ex is not SContentLoadException)
{
- throw new SContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}.", ex);
+ throw new SContentLoadException($"{this.Mod.DisplayName} failed loading content asset '{key}' from {source}.", ex);
}
}
/// <inheritdoc />
[Pure]
- public string NormalizeAssetName(string assetName)
+ public string NormalizeAssetName(string? assetName)
{
return this.ModContentManager.AssertAndNormalizeAssetName(assetName);
}
@@ -117,7 +160,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
return this.GameContentManager.AssertAndNormalizeAssetName(key);
case ContentSource.ModFolder:
- return this.ModContentManager.GetInternalAssetKey(key);
+ return this.ModContentManager.GetInternalAssetKey(key).Name;
default:
throw new NotSupportedException($"Unknown content source '{source}'.");
@@ -128,32 +171,41 @@ namespace StardewModdingAPI.Framework.ModHelpers
public bool InvalidateCache(string key)
{
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();
+ this.Monitor.Log($"Requested cache invalidation for '{actualKey}'.");
+ return this.ContentCore.InvalidateCache(asset => asset.Name.IsEquivalentTo(actualKey)).Any();
}
/// <inheritdoc />
public bool InvalidateCache<T>()
+ where T : notnull
{
- this.Monitor.Log($"Requested cache invalidation for all assets of type {typeof(T)}. This is an expensive operation and should be avoided if possible.", LogLevel.Trace);
- return this.ContentCore.InvalidateCache((contentManager, key, type) => typeof(T).IsAssignableFrom(type)).Any();
+ this.Monitor.Log($"Requested cache invalidation for all assets of type {typeof(T)}. This is an expensive operation and should be avoided if possible.");
+ return this.ContentCore.InvalidateCache((_, _, type) => typeof(T).IsAssignableFrom(type)).Any();
}
/// <inheritdoc />
public bool InvalidateCache(Func<IAssetInfo, bool> predicate)
{
- this.Monitor.Log("Requested cache invalidation for all assets matching a predicate.", LogLevel.Trace);
+ this.Monitor.Log("Requested cache invalidation for all assets matching a predicate.");
return this.ContentCore.InvalidateCache(predicate).Any();
}
/// <inheritdoc />
- public IAssetData GetPatchHelper<T>(T data, string assetName = null)
+ public IAssetData GetPatchHelper<T>(T data, string? assetName = null)
+ where T : notnull
{
if (data == null)
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(
+ locale: this.CurrentLocale,
+ assetName: this.ContentCore.ParseAssetName(assetName, allowLocales: true/* no way to know if it's a game or mod asset here*/),
+ data: data,
+ getNormalizedPath: this.NormalizeAssetName,
+ reflection: this.Reflection
+ );
}
diff --git a/src/SMAPI/Framework/ModHelpers/ContentPackHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentPackHelper.cs
index d39abc7d..9f4a7ceb 100644
--- a/src/SMAPI/Framework/ModHelpers/ContentPackHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/ContentPackHelper.cs
@@ -22,11 +22,11 @@ namespace StardewModdingAPI.Framework.ModHelpers
** Public methods
*********/
/// <summary>Construct an instance.</summary>
- /// <param name="modID">The unique ID of the relevant mod.</param>
+ /// <param name="mod">The mod using this instance.</param>
/// <param name="contentPacks">The content packs loaded for this mod.</param>
/// <param name="createContentPack">Create a temporary content pack.</param>
- public ContentPackHelper(string modID, Lazy<IContentPack[]> contentPacks, Func<string, IManifest, IContentPack> createContentPack)
- : base(modID)
+ public ContentPackHelper(IModMetadata mod, Lazy<IContentPack[]> contentPacks, Func<string, IManifest, IContentPack> createContentPack)
+ : base(mod)
{
this.ContentPacks = contentPacks;
this.CreateContentPack = createContentPack;
diff --git a/src/SMAPI/Framework/ModHelpers/DataHelper.cs b/src/SMAPI/Framework/ModHelpers/DataHelper.cs
index 4cbfd73f..2eaa940a 100644
--- a/src/SMAPI/Framework/ModHelpers/DataHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/DataHelper.cs
@@ -26,11 +26,11 @@ namespace StardewModdingAPI.Framework.ModHelpers
** Public methods
*********/
/// <summary>Construct an instance.</summary>
- /// <param name="modID">The unique ID of the relevant mod.</param>
+ /// <param name="mod">The mod using this instance.</param>
/// <param name="modFolderPath">The absolute path to the mod folder.</param>
/// <param name="jsonHelper">The absolute path to the mod folder.</param>
- public DataHelper(string modID, string modFolderPath, JsonHelper jsonHelper)
- : base(modID)
+ public DataHelper(IModMetadata mod, string modFolderPath, JsonHelper jsonHelper)
+ : base(mod)
{
this.ModFolderPath = modFolderPath;
this.JsonHelper = jsonHelper;
@@ -40,19 +40,21 @@ namespace StardewModdingAPI.Framework.ModHelpers
** JSON file
****/
/// <inheritdoc />
- public TModel ReadJsonFile<TModel>(string path) where TModel : class
+ public TModel? ReadJsonFile<TModel>(string path)
+ where TModel : class
{
if (!PathUtilities.IsSafeRelativePath(path))
throw new InvalidOperationException($"You must call {nameof(IModHelper.Data)}.{nameof(this.ReadJsonFile)} with a relative path.");
path = Path.Combine(this.ModFolderPath, PathUtilities.NormalizePath(path));
- return this.JsonHelper.ReadJsonFileIfExists(path, out TModel data)
+ return this.JsonHelper.ReadJsonFileIfExists(path, out TModel? data)
? data
: null;
}
/// <inheritdoc />
- public void WriteJsonFile<TModel>(string path, TModel data) where TModel : class
+ public void WriteJsonFile<TModel>(string path, TModel? data)
+ where TModel : class
{
if (!PathUtilities.IsSafeRelativePath(path))
throw new InvalidOperationException($"You must call {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteJsonFile)} with a relative path (without directory climbing).");
@@ -69,7 +71,8 @@ namespace StardewModdingAPI.Framework.ModHelpers
** Save file
****/
/// <inheritdoc />
- public TModel ReadSaveData<TModel>(string key) where TModel : class
+ public TModel? ReadSaveData<TModel>(string key)
+ where TModel : class
{
if (Context.LoadStage == LoadStage.None)
throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} when a save file isn't loaded.");
@@ -80,14 +83,15 @@ namespace StardewModdingAPI.Framework.ModHelpers
string internalKey = this.GetSaveFileKey(key);
foreach (IDictionary<string, string> dataField in this.GetDataFields(Context.LoadStage))
{
- if (dataField.TryGetValue(internalKey, out string value))
+ if (dataField.TryGetValue(internalKey, out string? value))
return this.JsonHelper.Deserialize<TModel>(value);
}
return null;
}
/// <inheritdoc />
- public void WriteSaveData<TModel>(string key, TModel model) where TModel : class
+ public void WriteSaveData<TModel>(string key, TModel? model)
+ where TModel : class
{
if (Context.LoadStage == LoadStage.None)
throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteSaveData)} when a save file isn't loaded.");
@@ -95,7 +99,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteSaveData)} when connected to a remote host. (Save files are stored on the main player's computer.)");
string internalKey = this.GetSaveFileKey(key);
- string data = model != null
+ string? data = model != null
? this.JsonHelper.Serialize(model, Formatting.None)
: null;
@@ -112,16 +116,18 @@ namespace StardewModdingAPI.Framework.ModHelpers
** Global app data
****/
/// <inheritdoc />
- public TModel ReadGlobalData<TModel>(string key) where TModel : class
+ public TModel? ReadGlobalData<TModel>(string key)
+ where TModel : class
{
string path = this.GetGlobalDataPath(key);
- return this.JsonHelper.ReadJsonFileIfExists(path, out TModel data)
+ return this.JsonHelper.ReadJsonFileIfExists(path, out TModel? data)
? data
: null;
}
/// <inheritdoc />
- public void WriteGlobalData<TModel>(string key, TModel data) where TModel : class
+ public void WriteGlobalData<TModel>(string key, TModel? data)
+ where TModel : class
{
string path = this.GetGlobalDataPath(key);
if (data != null)
diff --git a/src/SMAPI/Framework/ModHelpers/GameContentHelper.cs b/src/SMAPI/Framework/ModHelpers/GameContentHelper.cs
new file mode 100644
index 00000000..232e9287
--- /dev/null
+++ b/src/SMAPI/Framework/ModHelpers/GameContentHelper.cs
@@ -0,0 +1,145 @@
+using System;
+using System.Linq;
+using StardewModdingAPI.Framework.Content;
+using StardewModdingAPI.Framework.ContentManagers;
+using StardewModdingAPI.Framework.Exceptions;
+using StardewModdingAPI.Framework.Reflection;
+using StardewValley;
+
+namespace StardewModdingAPI.Framework.ModHelpers
+{
+ /// <inheritdoc cref="IGameContentHelper"/>
+ internal class GameContentHelper : BaseHelper, IGameContentHelper
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>SMAPI's core content logic.</summary>
+ private readonly ContentCoordinator ContentCore;
+
+ /// <summary>The underlying game content manager.</summary>
+ private readonly IContentManager GameContentManager;
+
+ /// <summary>The friendly mod name for use in errors.</summary>
+ private readonly string ModName;
+
+ /// <summary>Encapsulates monitoring and logging.</summary>
+ private readonly IMonitor Monitor;
+
+ /// <summary>Simplifies access to private code.</summary>
+ private readonly Reflector Reflection;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <inheritdoc />
+ public string CurrentLocale => this.GameContentManager.GetLocale();
+
+ /// <inheritdoc />
+ public LocalizedContentManager.LanguageCode CurrentLocaleConstant => this.GameContentManager.Language;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="contentCore">SMAPI's core content logic.</param>
+ /// <param name="mod">The mod using this instance.</param>
+ /// <param name="modName">The friendly mod name for use in errors.</param>
+ /// <param name="monitor">Encapsulates monitoring and logging.</param>
+ /// <param name="reflection">Simplifies access to private code.</param>
+ public GameContentHelper(ContentCoordinator contentCore, IModMetadata mod, string modName, IMonitor monitor, Reflector reflection)
+ : base(mod)
+ {
+ string managedAssetPrefix = contentCore.GetManagedAssetPrefix(mod.Manifest.UniqueID);
+
+ this.ContentCore = contentCore;
+ this.GameContentManager = contentCore.CreateGameContentManager(managedAssetPrefix + ".content");
+ this.ModName = modName;
+ this.Monitor = monitor;
+ this.Reflection = reflection;
+ }
+
+ /// <inheritdoc />
+ public IAssetName ParseAssetName(string rawName)
+ {
+ return this.ContentCore.ParseAssetName(rawName, allowLocales: true);
+ }
+
+ /// <inheritdoc />
+ public T Load<T>(string key)
+ where T : notnull
+ {
+ IAssetName assetName = this.ContentCore.ParseAssetName(key, allowLocales: true);
+ return this.Load<T>(assetName);
+ }
+
+ /// <inheritdoc />
+ public T Load<T>(IAssetName assetName)
+ where T : notnull
+ {
+ try
+ {
+ return this.GameContentManager.LoadLocalized<T>(assetName, this.CurrentLocaleConstant, useCache: true);
+ }
+ catch (Exception ex) when (ex is not SContentLoadException)
+ {
+ throw new SContentLoadException($"{this.ModName} failed loading content asset '{assetName}' from the game content.", ex);
+ }
+ }
+
+ /// <inheritdoc />
+ public bool InvalidateCache(string key)
+ {
+ IAssetName assetName = this.ParseAssetName(key);
+ return this.InvalidateCache(assetName);
+ }
+
+ /// <inheritdoc />
+ public bool InvalidateCache(IAssetName assetName)
+ {
+ this.Monitor.Log($"Requested cache invalidation for '{assetName}'.");
+ return this.ContentCore.InvalidateCache(asset => asset.Name.IsEquivalentTo(assetName)).Any();
+ }
+
+ /// <inheritdoc />
+ public bool InvalidateCache<T>()
+ where T : notnull
+ {
+ this.Monitor.Log($"Requested cache invalidation for all assets of type {typeof(T)}. This is an expensive operation and should be avoided if possible.");
+ return this.ContentCore.InvalidateCache((_, _, type) => typeof(T).IsAssignableFrom(type)).Any();
+ }
+
+ /// <inheritdoc />
+ public bool InvalidateCache(Func<IAssetInfo, bool> predicate)
+ {
+ this.Monitor.Log("Requested cache invalidation for all assets matching a predicate.");
+ return this.ContentCore.InvalidateCache(predicate).Any();
+ }
+
+ /// <inheritdoc />
+ public IAssetData GetPatchHelper<T>(T data, string? assetName = null)
+ where T : notnull
+ {
+ if (data == null)
+ throw new ArgumentNullException(nameof(data), "Can't get a patch helper for a null value.");
+
+ assetName ??= $"temp/{Guid.NewGuid():N}";
+
+ return new AssetDataForObject(
+ locale: this.CurrentLocale,
+ assetName: this.ContentCore.ParseAssetName(assetName, allowLocales: true),
+ data: data,
+ getNormalizedPath: key => this.ParseAssetName(key).Name,
+ reflection: this.Reflection
+ );
+ }
+
+ /// <summary>Get the underlying game content manager.</summary>
+ internal IContentManager GetUnderlyingContentManager()
+ {
+ return this.GameContentManager;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/ModHelpers/InputHelper.cs b/src/SMAPI/Framework/ModHelpers/InputHelper.cs
index 88caf4c3..6c158258 100644
--- a/src/SMAPI/Framework/ModHelpers/InputHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/InputHelper.cs
@@ -18,10 +18,10 @@ namespace StardewModdingAPI.Framework.ModHelpers
** Public methods
*********/
/// <summary>Construct an instance.</summary>
- /// <param name="modID">The unique ID of the relevant mod.</param>
+ /// <param name="mod">The mod using this instance.</param>
/// <param name="currentInputState">Manages the game's input state for the current player instance. That may not be the main player in split-screen mode.</param>
- public InputHelper(string modID, Func<SInputState> currentInputState)
- : base(modID)
+ public InputHelper(IModMetadata mod, Func<SInputState> currentInputState)
+ : base(mod)
{
this.CurrentInputState = currentInputState;
}
diff --git a/src/SMAPI/Framework/ModHelpers/ModContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ModContentHelper.cs
new file mode 100644
index 00000000..def0b728
--- /dev/null
+++ b/src/SMAPI/Framework/ModHelpers/ModContentHelper.cs
@@ -0,0 +1,101 @@
+using System;
+using Microsoft.Xna.Framework.Content;
+using StardewModdingAPI.Framework.Content;
+using StardewModdingAPI.Framework.ContentManagers;
+using StardewModdingAPI.Framework.Exceptions;
+using StardewModdingAPI.Framework.Reflection;
+using StardewModdingAPI.Toolkit.Utilities;
+
+namespace StardewModdingAPI.Framework.ModHelpers
+{
+ /// <inheritdoc cref="IModContentHelper"/>
+ internal class ModContentHelper : BaseHelper, IModContentHelper
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>SMAPI's core content logic.</summary>
+ private readonly ContentCoordinator ContentCore;
+
+ /// <summary>A content manager for this mod which manages files from the mod's folder.</summary>
+ private readonly ModContentManager ModContentManager;
+
+ /// <summary>The friendly mod name for use in errors.</summary>
+ private readonly string ModName;
+
+ /// <summary>A case-insensitive lookup of relative paths within the <see cref="ContentManager.RootDirectory"/>.</summary>
+ private readonly CaseInsensitivePathLookup RelativePathCache;
+
+ /// <summary>Simplifies access to private code.</summary>
+ private readonly Reflector Reflection;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="contentCore">SMAPI's core content logic.</param>
+ /// <param name="modFolderPath">The absolute path to the mod folder.</param>
+ /// <param name="mod">The mod using this instance.</param>
+ /// <param name="modName">The friendly mod name for use in errors.</param>
+ /// <param name="gameContentManager">The game content manager used for map tilesheets not provided by the mod.</param>
+ /// <param name="relativePathCache">A case-insensitive lookup of relative paths within the <paramref name="relativePathCache"/>.</param>
+ /// <param name="reflection">Simplifies access to private code.</param>
+ public ModContentHelper(ContentCoordinator contentCore, string modFolderPath, IModMetadata mod, string modName, IContentManager gameContentManager, CaseInsensitivePathLookup relativePathCache, Reflector reflection)
+ : base(mod)
+ {
+ string managedAssetPrefix = contentCore.GetManagedAssetPrefix(mod.Manifest.UniqueID);
+
+ this.ContentCore = contentCore;
+ this.ModContentManager = contentCore.CreateModContentManager(managedAssetPrefix, modName, modFolderPath, gameContentManager);
+ this.ModName = modName;
+ this.RelativePathCache = relativePathCache;
+ this.Reflection = reflection;
+ }
+
+ /// <inheritdoc />
+ public T Load<T>(string relativePath)
+ where T : notnull
+ {
+ relativePath = this.RelativePathCache.GetAssetName(relativePath);
+
+ IAssetName assetName = this.ContentCore.ParseAssetName(relativePath, allowLocales: false);
+
+ try
+ {
+ return this.ModContentManager.LoadExact<T>(assetName, useCache: false);
+ }
+ catch (Exception ex) when (ex is not SContentLoadException)
+ {
+ throw new SContentLoadException($"{this.ModName} failed loading content asset '{relativePath}' from its mod folder.", ex);
+ }
+ }
+
+ /// <inheritdoc />
+ public IAssetName GetInternalAssetName(string relativePath)
+ {
+ relativePath = this.RelativePathCache.GetAssetName(relativePath);
+ return this.ModContentManager.GetInternalAssetKey(relativePath);
+ }
+
+ /// <inheritdoc />
+ public IAssetData GetPatchHelper<T>(T data, string? relativePath = null)
+ where T : notnull
+ {
+ if (data == null)
+ throw new ArgumentNullException(nameof(data), "Can't get a patch helper for a null value.");
+
+ relativePath = relativePath != null
+ ? this.RelativePathCache.GetAssetName(relativePath)
+ : $"temp/{Guid.NewGuid():N}";
+
+ return new AssetDataForObject(
+ locale: this.ContentCore.GetLocale(),
+ assetName: this.ContentCore.ParseAssetName(relativePath, allowLocales: false),
+ data: data,
+ getNormalizedPath: key => this.ContentCore.ParseAssetName(key, allowLocales: false).Name,
+ reflection: this.Reflection
+ );
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/ModHelpers/ModHelper.cs b/src/SMAPI/Framework/ModHelpers/ModHelper.cs
index 058bff83..a23a9beb 100644
--- a/src/SMAPI/Framework/ModHelpers/ModHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/ModHelper.cs
@@ -1,6 +1,7 @@
using System;
using System.IO;
using StardewModdingAPI.Events;
+using StardewModdingAPI.Framework.Deprecations;
using StardewModdingAPI.Framework.Input;
namespace StardewModdingAPI.Framework.ModHelpers
@@ -9,6 +10,14 @@ namespace StardewModdingAPI.Framework.ModHelpers
internal class ModHelper : BaseHelper, IModHelper, IDisposable
{
/*********
+ ** Fields
+ *********/
+ /// <summary>The backing field for <see cref="Content"/>.</summary>
+ [Obsolete]
+ private readonly ContentHelper ContentImpl;
+
+
+ /*********
** Accessors
*********/
/// <inheritdoc />
@@ -18,7 +27,27 @@ namespace StardewModdingAPI.Framework.ModHelpers
public IModEvents Events { get; }
/// <inheritdoc />
- public IContentHelper Content { get; }
+ [Obsolete]
+ public IContentHelper Content
+ {
+ get
+ {
+ SCore.DeprecationManager.Warn(
+ source: SCore.DeprecationManager.GetMod(this.ModID),
+ nounPhrase: $"{nameof(IModHelper)}.{nameof(IModHelper.Content)}",
+ version: "3.14.0",
+ severity: DeprecationLevel.Notice
+ );
+
+ return this.ContentImpl;
+ }
+ }
+
+ /// <inheritdoc />
+ public IGameContentHelper GameContent { get; }
+
+ /// <inheritdoc />
+ public IModContentHelper ModContent { get; }
/// <inheritdoc />
public IContentPackHelper ContentPacks { get; }
@@ -49,11 +78,13 @@ namespace StardewModdingAPI.Framework.ModHelpers
** Public methods
*********/
/// <summary>Construct an instance.</summary>
- /// <param name="modID">The mod's unique ID.</param>
+ /// <param name="mod">The mod using this instance.</param>
/// <param name="modDirectory">The full path to the mod's folder.</param>
/// <param name="currentInputState">Manages the game's input state for the current player instance. That may not be the main player in split-screen mode.</param>
/// <param name="events">Manages access to events raised by SMAPI.</param>
/// <param name="contentHelper">An API for loading content assets.</param>
+ /// <param name="gameContentHelper">An API for loading content assets from the game's <c>Content</c> folder or via <see cref="IModEvents.Content"/>.</param>
+ /// <param name="modContentHelper">An API for loading content assets from your mod's files.</param>
/// <param name="contentPackHelper">An API for managing content packs.</param>
/// <param name="commandHelper">An API for managing console commands.</param>
/// <param name="dataHelper">An API for reading and writing persistent mod data.</param>
@@ -63,8 +94,14 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <param name="translationHelper">An API for reading translations stored in the mod's <c>i18n</c> folder.</param>
/// <exception cref="ArgumentNullException">An argument is null or empty.</exception>
/// <exception cref="InvalidOperationException">The <paramref name="modDirectory"/> path does not exist on disk.</exception>
- public ModHelper(string modID, string modDirectory, Func<SInputState> currentInputState, IModEvents events, IContentHelper contentHelper, IContentPackHelper contentPackHelper, ICommandHelper commandHelper, IDataHelper dataHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, IMultiplayerHelper multiplayer, ITranslationHelper translationHelper)
- : base(modID)
+ public ModHelper(
+ IModMetadata mod, string modDirectory, Func<SInputState> currentInputState, IModEvents events,
+#pragma warning disable CS0612 // deprecated code
+ ContentHelper contentHelper,
+#pragma warning restore CS0612
+ IGameContentHelper gameContentHelper, IModContentHelper modContentHelper, IContentPackHelper contentPackHelper, ICommandHelper commandHelper, IDataHelper dataHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, IMultiplayerHelper multiplayer, ITranslationHelper translationHelper
+ )
+ : base(mod)
{
// validate directory
if (string.IsNullOrWhiteSpace(modDirectory))
@@ -74,10 +111,14 @@ namespace StardewModdingAPI.Framework.ModHelpers
// initialize
this.DirectoryPath = modDirectory;
- this.Content = contentHelper ?? throw new ArgumentNullException(nameof(contentHelper));
+#pragma warning disable CS0612 // deprecated code
+ this.ContentImpl = contentHelper ?? throw new ArgumentNullException(nameof(contentHelper));
+#pragma warning restore CS0612
+ this.GameContent = gameContentHelper ?? throw new ArgumentNullException(nameof(gameContentHelper));
+ this.ModContent = modContentHelper ?? throw new ArgumentNullException(nameof(modContentHelper));
this.ContentPacks = contentPackHelper ?? throw new ArgumentNullException(nameof(contentPackHelper));
this.Data = dataHelper ?? throw new ArgumentNullException(nameof(dataHelper));
- this.Input = new InputHelper(modID, currentInputState);
+ this.Input = new InputHelper(mod, currentInputState);
this.ModRegistry = modRegistry ?? throw new ArgumentNullException(nameof(modRegistry));
this.ConsoleCommands = commandHelper ?? throw new ArgumentNullException(nameof(commandHelper));
this.Reflection = reflectionHelper ?? throw new ArgumentNullException(nameof(reflectionHelper));
@@ -86,6 +127,13 @@ namespace StardewModdingAPI.Framework.ModHelpers
this.Events = events;
}
+ /// <summary>Get the underlying instance for <see cref="IContentHelper"/>.</summary>
+ [Obsolete]
+ public ContentHelper GetLegacyContentHelper()
+ {
+ return this.ContentImpl;
+ }
+
/****
** Mod config file
****/
diff --git a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs
index ef1ad30c..348ba225 100644
--- a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs
@@ -16,22 +16,22 @@ namespace StardewModdingAPI.Framework.ModHelpers
private readonly IMonitor Monitor;
/// <summary>The mod IDs for APIs accessed by this instanced.</summary>
- private readonly HashSet<string> AccessedModApis = new HashSet<string>();
+ private readonly HashSet<string> AccessedModApis = new();
/// <summary>Generates proxy classes to access mod APIs through an arbitrary interface.</summary>
- private readonly InterfaceProxyFactory ProxyFactory;
+ private readonly IInterfaceProxyFactory ProxyFactory;
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
- /// <param name="modID">The unique ID of the relevant mod.</param>
+ /// <param name="mod">The mod using this instance.</param>
/// <param name="registry">The underlying mod registry.</param>
/// <param name="proxyFactory">Generates proxy classes to access mod APIs through an arbitrary interface.</param>
/// <param name="monitor">Encapsulates monitoring and logging for the mod.</param>
- public ModRegistryHelper(string modID, ModRegistry registry, InterfaceProxyFactory proxyFactory, IMonitor monitor)
- : base(modID)
+ public ModRegistryHelper(IModMetadata mod, ModRegistry registry, IInterfaceProxyFactory proxyFactory, IMonitor monitor)
+ : base(mod)
{
this.Registry = registry;
this.ProxyFactory = proxyFactory;
@@ -45,7 +45,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
}
/// <inheritdoc />
- public IModInfo Get(string uniqueID)
+ public IModInfo? Get(string uniqueID)
{
return this.Registry.Get(uniqueID);
}
@@ -57,7 +57,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
}
/// <inheritdoc />
- public object GetApi(string uniqueID)
+ public object? GetApi(string uniqueID)
{
// validate ready
if (!this.Registry.AreAllModsInitialized)
@@ -67,17 +67,18 @@ namespace StardewModdingAPI.Framework.ModHelpers
}
// get raw API
- IModMetadata mod = this.Registry.Get(uniqueID);
+ IModMetadata? mod = this.Registry.Get(uniqueID);
if (mod?.Api != null && this.AccessedModApis.Add(mod.Manifest.UniqueID))
- this.Monitor.Log($"Accessed mod-provided API for {mod.DisplayName}.", LogLevel.Trace);
+ this.Monitor.Log($"Accessed mod-provided API for {mod.DisplayName}.");
return mod?.Api;
}
/// <inheritdoc />
- public TInterface GetApi<TInterface>(string uniqueID) where TInterface : class
+ public TInterface? GetApi<TInterface>(string uniqueID)
+ where TInterface : class
{
// get raw API
- object api = this.GetApi(uniqueID);
+ object? api = this.GetApi(uniqueID);
if (api == null)
return null;
@@ -94,9 +95,9 @@ namespace StardewModdingAPI.Framework.ModHelpers
}
// get API of type
- if (api is TInterface castApi)
- return castApi;
- return this.ProxyFactory.CreateProxy<TInterface>(api, this.ModID, uniqueID);
+ return api is TInterface castApi
+ ? castApi
+ : this.ProxyFactory.CreateProxy<TInterface>(api, sourceModID: this.ModID, targetModID: uniqueID);
}
}
}
diff --git a/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs b/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs
index a7ce8692..6900a1d2 100644
--- a/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs
@@ -18,10 +18,10 @@ namespace StardewModdingAPI.Framework.ModHelpers
** Public methods
*********/
/// <summary>Construct an instance.</summary>
- /// <param name="modID">The unique ID of the relevant mod.</param>
+ /// <param name="mod">The mod using this instance.</param>
/// <param name="multiplayer">SMAPI's core multiplayer utility.</param>
- public MultiplayerHelper(string modID, SMultiplayer multiplayer)
- : base(modID)
+ public MultiplayerHelper(IModMetadata mod, SMultiplayer multiplayer)
+ : base(mod)
{
this.Multiplayer = multiplayer;
}
@@ -39,9 +39,9 @@ namespace StardewModdingAPI.Framework.ModHelpers
}
/// <inheritdoc />
- public IMultiplayerPeer GetConnectedPlayer(long id)
+ public IMultiplayerPeer? GetConnectedPlayer(long id)
{
- return this.Multiplayer.Peers.TryGetValue(id, out MultiplayerPeer peer)
+ return this.Multiplayer.Peers.TryGetValue(id, out MultiplayerPeer? peer)
? peer
: null;
}
@@ -53,7 +53,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
}
/// <inheritdoc />
- public void SendMessage<TMessage>(TMessage message, string messageType, string[] modIDs = null, long[] playerIDs = null)
+ public void SendMessage<TMessage>(TMessage message, string messageType, string[]? modIDs = null, long[]? playerIDs = null)
{
this.Multiplayer.BroadcastModMessage(
message: message,
diff --git a/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs b/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs
index 5a4ea742..a559906b 100644
--- a/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs
@@ -22,11 +22,11 @@ namespace StardewModdingAPI.Framework.ModHelpers
** Public methods
*********/
/// <summary>Construct an instance.</summary>
- /// <param name="modID">The unique ID of the relevant mod.</param>
+ /// <param name="mod">The mod using this instance.</param>
/// <param name="modName">The mod name for error messages.</param>
/// <param name="reflector">The underlying reflection helper.</param>
- public ReflectionHelper(string modID, string modName, Reflector reflector)
- : base(modID)
+ public ReflectionHelper(IModMetadata mod, string modName, Reflector reflector)
+ : base(mod)
{
this.ModName = modName;
this.Reflector = reflector;
@@ -37,7 +37,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
{
return this.AssertAccessAllowed(
this.Reflector.GetField<TValue>(obj, name, required)
- );
+ )!;
}
/// <inheritdoc />
@@ -45,7 +45,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
{
return this.AssertAccessAllowed(
this.Reflector.GetField<TValue>(type, name, required)
- );
+ )!;
}
/// <inheritdoc />
@@ -53,7 +53,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
{
return this.AssertAccessAllowed(
this.Reflector.GetProperty<TValue>(obj, name, required)
- );
+ )!;
}
/// <inheritdoc />
@@ -61,7 +61,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
{
return this.AssertAccessAllowed(
this.Reflector.GetProperty<TValue>(type, name, required)
- );
+ )!;
}
/// <inheritdoc />
@@ -69,7 +69,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
{
return this.AssertAccessAllowed(
this.Reflector.GetMethod(obj, name, required)
- );
+ )!;
}
/// <inheritdoc />
@@ -77,7 +77,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
{
return this.AssertAccessAllowed(
this.Reflector.GetMethod(type, name, required)
- );
+ )!;
}
@@ -88,7 +88,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <typeparam name="T">The field value type.</typeparam>
/// <param name="field">The field being accessed.</param>
/// <returns>Returns the same field instance for convenience.</returns>
- private IReflectedField<T> AssertAccessAllowed<T>(IReflectedField<T> field)
+ private IReflectedField<T>? AssertAccessAllowed<T>(IReflectedField<T>? field)
{
this.AssertAccessAllowed(field?.FieldInfo);
return field;
@@ -98,7 +98,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <typeparam name="T">The property value type.</typeparam>
/// <param name="property">The property being accessed.</param>
/// <returns>Returns the same property instance for convenience.</returns>
- private IReflectedProperty<T> AssertAccessAllowed<T>(IReflectedProperty<T> property)
+ private IReflectedProperty<T>? AssertAccessAllowed<T>(IReflectedProperty<T>? property)
{
this.AssertAccessAllowed(property?.PropertyInfo.GetMethod?.GetBaseDefinition());
this.AssertAccessAllowed(property?.PropertyInfo.SetMethod?.GetBaseDefinition());
@@ -108,7 +108,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <summary>Assert that mods can use the reflection helper to access the given member.</summary>
/// <param name="method">The method being accessed.</param>
/// <returns>Returns the same method instance for convenience.</returns>
- private IReflectedMethod AssertAccessAllowed(IReflectedMethod method)
+ private IReflectedMethod? AssertAccessAllowed(IReflectedMethod? method)
{
this.AssertAccessAllowed(method?.MethodInfo.GetBaseDefinition());
return method;
@@ -116,18 +116,18 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <summary>Assert that mods can use the reflection helper to access the given member.</summary>
/// <param name="member">The member being accessed.</param>
- private void AssertAccessAllowed(MemberInfo member)
+ private void AssertAccessAllowed(MemberInfo? member)
{
if (member == null)
return;
// get type which defines the member
- Type declaringType = member.DeclaringType;
+ Type? declaringType = member.DeclaringType;
if (declaringType == null)
throw new InvalidOperationException($"Can't validate access to {member.MemberType} {member.Name} because it has no declaring type."); // should never happen
// validate access
- string rootNamespace = typeof(Program).Namespace;
+ string? rootNamespace = typeof(Program).Namespace;
if (declaringType.Namespace == rootNamespace || declaringType.Namespace?.StartsWith(rootNamespace + ".") == true)
throw new InvalidOperationException($"SMAPI blocked access by {this.ModName} to its internals through the reflection API. Accessing the SMAPI internals is strongly discouraged since they're subject to change, which means the mod can break without warning. (Detected access to {declaringType.FullName}.{member.Name}.)");
}
diff --git a/src/SMAPI/Framework/ModHelpers/TranslationHelper.cs b/src/SMAPI/Framework/ModHelpers/TranslationHelper.cs
index 869664fe..ae49d651 100644
--- a/src/SMAPI/Framework/ModHelpers/TranslationHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/TranslationHelper.cs
@@ -27,11 +27,11 @@ namespace StardewModdingAPI.Framework.ModHelpers
** Public methods
*********/
/// <summary>Construct an instance.</summary>
- /// <param name="modID">The unique ID of the relevant mod.</param>
+ /// <param name="mod">The mod using this instance.</param>
/// <param name="locale">The initial locale.</param>
/// <param name="languageCode">The game's current language code.</param>
- public TranslationHelper(string modID, string locale, LocalizedContentManager.LanguageCode languageCode)
- : base(modID)
+ public TranslationHelper(IModMetadata mod, string locale, LocalizedContentManager.LanguageCode languageCode)
+ : base(mod)
{
this.Translator = new Translator();
this.Translator.SetLocale(locale, languageCode);
@@ -50,7 +50,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
}
/// <inheritdoc />
- public Translation Get(string key, object tokens)
+ public Translation Get(string key, object? tokens)
{
return this.Translator.Get(key, tokens);
}
@@ -69,7 +69,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
return this;
}
- /// <summary>Set the current locale and precache translations.</summary>
+ /// <summary>Set the current locale and pre-cache translations.</summary>
/// <param name="locale">The current locale.</param>
/// <param name="localeEnum">The game's current language code.</param>
internal void SetLocale(string locale, LocalizedContentManager.LanguageCode localeEnum)
diff --git a/src/SMAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs b/src/SMAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs
index 8e2f5ef3..b3378ad1 100644
--- a/src/SMAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs
+++ b/src/SMAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs
@@ -36,6 +36,7 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <summary>Resolve an assembly reference.</summary>
/// <param name="name">The assembly name.</param>
+ /// <exception cref="AssemblyResolutionException">The assembly can't be resolved.</exception>
public override AssemblyDefinition Resolve(AssemblyNameReference name)
{
return this.ResolveName(name.Name) ?? base.Resolve(name);
@@ -44,6 +45,7 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <summary>Resolve an assembly reference.</summary>
/// <param name="name">The assembly name.</param>
/// <param name="parameters">The assembly reader parameters.</param>
+ /// <exception cref="AssemblyResolutionException">The assembly can't be resolved.</exception>
public override AssemblyDefinition Resolve(AssemblyNameReference name, ReaderParameters parameters)
{
return this.ResolveName(name.Name) ?? base.Resolve(name, parameters);
@@ -55,9 +57,9 @@ namespace StardewModdingAPI.Framework.ModLoading
*********/
/// <summary>Resolve a known assembly definition based on its short or full name.</summary>
/// <param name="name">The assembly's short or full name.</param>
- private AssemblyDefinition ResolveName(string name)
+ private AssemblyDefinition? ResolveName(string name)
{
- return this.Lookup.TryGetValue(name, out AssemblyDefinition match)
+ return this.Lookup.TryGetValue(name, out AssemblyDefinition? match)
? match
: null;
}
diff --git a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
index cb5fa2ae..72b547b1 100644
--- a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
+++ b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
@@ -36,13 +36,13 @@ namespace StardewModdingAPI.Framework.ModLoading
private readonly AssemblyDefinitionResolver AssemblyDefinitionResolver;
/// <summary>Provides assembly symbol readers for Mono.Cecil.</summary>
- private readonly SymbolReaderProvider SymbolReaderProvider = new SymbolReaderProvider();
+ private readonly SymbolReaderProvider SymbolReaderProvider = new();
/// <summary>Provides assembly symbol writers for Mono.Cecil.</summary>
- private readonly SymbolWriterProvider SymbolWriterProvider = new SymbolWriterProvider();
+ private readonly SymbolWriterProvider SymbolWriterProvider = new();
/// <summary>The objects to dispose as part of this instance.</summary>
- private readonly HashSet<IDisposable> Disposables = new HashSet<IDisposable>();
+ private readonly HashSet<IDisposable> Disposables = new();
/// <summary>Whether to rewrite mods for compatibility.</summary>
private readonly bool RewriteMods;
@@ -94,7 +94,12 @@ namespace StardewModdingAPI.Framework.ModLoading
// get referenced local assemblies
AssemblyParseResult[] assemblies;
{
- HashSet<string> visitedAssemblyNames = new HashSet<string>(AppDomain.CurrentDomain.GetAssemblies().Select(p => p.GetName().Name)); // don't try loading assemblies that are already loaded
+ HashSet<string> visitedAssemblyNames = new HashSet<string>( // don't try loading assemblies that are already loaded
+ from assembly in AppDomain.CurrentDomain.GetAssemblies()
+ let name = assembly.GetName().Name
+ where name != null
+ select name
+ );
assemblies = this.GetReferencedLocalAssemblies(new FileInfo(assemblyPath), visitedAssemblyNames, this.AssemblyDefinitionResolver).ToArray();
}
@@ -111,11 +116,11 @@ namespace StardewModdingAPI.Framework.ModLoading
// rewrite & load assemblies in leaf-to-root order
bool oneAssembly = assemblies.Length == 1;
- Assembly lastAssembly = null;
+ Assembly? lastAssembly = null;
HashSet<string> loggedMessages = new HashSet<string>();
foreach (AssemblyParseResult assembly in assemblies)
{
- if (assembly.Status == AssemblyLoadStatus.AlreadyLoaded)
+ if (!assembly.HasDefinition)
continue;
// rewrite assembly
@@ -138,11 +143,11 @@ namespace StardewModdingAPI.Framework.ModLoading
if (changed)
{
if (!oneAssembly)
- this.Monitor.Log($" Loading {assembly.File.Name} (rewritten)...", LogLevel.Trace);
+ this.Monitor.Log($" Loading {assembly.File.Name} (rewritten)...");
// load assembly
- using MemoryStream outAssemblyStream = new MemoryStream();
- using MemoryStream outSymbolStream = new MemoryStream();
+ using MemoryStream outAssemblyStream = new();
+ using MemoryStream outSymbolStream = new();
assembly.Definition.Write(outAssemblyStream, new WriterParameters { WriteSymbols = true, SymbolStream = outSymbolStream, SymbolWriterProvider = this.SymbolWriterProvider });
byte[] bytes = outAssemblyStream.ToArray();
lastAssembly = Assembly.Load(bytes, outSymbolStream.ToArray());
@@ -150,7 +155,7 @@ namespace StardewModdingAPI.Framework.ModLoading
else
{
if (!oneAssembly)
- this.Monitor.Log($" Loading {assembly.File.Name}...", LogLevel.Trace);
+ this.Monitor.Log($" Loading {assembly.File.Name}...");
lastAssembly = Assembly.UnsafeLoadFrom(assembly.File.FullName);
}
@@ -163,7 +168,7 @@ namespace StardewModdingAPI.Framework.ModLoading
throw new IncompatibleInstructionException();
// last assembly loaded is the root
- return lastAssembly;
+ return lastAssembly!;
}
/// <summary>Get whether an assembly is loaded.</summary>
@@ -172,7 +177,8 @@ namespace StardewModdingAPI.Framework.ModLoading
{
try
{
- return this.AssemblyDefinitionResolver.Resolve(reference) != null;
+ _ = this.AssemblyDefinitionResolver.Resolve(reference);
+ return true;
}
catch (AssemblyResolutionException)
{
@@ -188,7 +194,7 @@ namespace StardewModdingAPI.Framework.ModLoading
/// assemblies (especially with Mono). Since this is meant to be called on <see cref="AppDomain.AssemblyResolve"/>,
/// the implicit assumption is that loading the exact assembly failed.
/// </remarks>
- public static Assembly ResolveAssembly(string name)
+ public static Assembly? ResolveAssembly(string name)
{
string shortName = name.Split(new[] { ',' }, 2).First(); // get simple name (without version and culture)
return AppDomain.CurrentDomain
@@ -210,7 +216,8 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <summary>Track an object for disposal as part of the assembly loader.</summary>
/// <typeparam name="T">The instance type.</typeparam>
/// <param name="instance">The disposable instance.</param>
- private T TrackForDisposal<T>(T instance) where T : IDisposable
+ private T TrackForDisposal<T>(T instance)
+ where T : IDisposable
{
this.Disposables.Add(instance);
return instance;
@@ -241,7 +248,7 @@ namespace StardewModdingAPI.Framework.ModLoading
try
{
// read assembly with symbols
- FileInfo symbolsFile = new FileInfo(Path.Combine(Path.GetDirectoryName(file.FullName)!, Path.GetFileNameWithoutExtension(file.FullName)) + ".pdb");
+ FileInfo symbolsFile = new(Path.Combine(Path.GetDirectoryName(file.FullName)!, Path.GetFileNameWithoutExtension(file.FullName)) + ".pdb");
if (symbolsFile.Exists)
this.SymbolReaderProvider.TryAddSymbolData(file.Name, () => this.TrackForDisposal(symbolsFile.OpenRead()));
assembly = this.TrackForDisposal(AssemblyDefinition.ReadAssembly(readStream, new ReaderParameters(ReadingMode.Immediate) { AssemblyResolver = assemblyResolver, InMemory = true, ReadSymbols = true, SymbolReaderProvider = this.SymbolReaderProvider }));
@@ -266,7 +273,7 @@ namespace StardewModdingAPI.Framework.ModLoading
// yield referenced assemblies
foreach (AssemblyNameReference dependency in assembly.MainModule.AssemblyReferences)
{
- FileInfo dependencyFile = new FileInfo(Path.Combine(file.Directory.FullName, $"{dependency.Name}.dll"));
+ FileInfo dependencyFile = new(Path.Combine(file.Directory.FullName, $"{dependency.Name}.dll"));
foreach (AssemblyParseResult result in this.GetReferencedLocalAssemblies(dependencyFile, visitedAssemblyNames, assemblyResolver))
yield return result;
}
@@ -319,9 +326,9 @@ namespace StardewModdingAPI.Framework.ModLoading
// rewrite types using custom attributes
foreach (TypeDefinition type in module.GetTypes())
{
- foreach (var attr in type.CustomAttributes)
+ foreach (CustomAttribute attr in type.CustomAttributes)
{
- foreach (var conField in attr.ConstructorArguments)
+ foreach (CustomAttributeArgument conField in attr.ConstructorArguments)
{
if (conField.Value is TypeReference typeRef)
this.ChangeTypeScope(typeRef);
@@ -333,7 +340,7 @@ namespace StardewModdingAPI.Framework.ModLoading
// find or rewrite code
IInstructionHandler[] handlers = new InstructionMetadata().GetHandlers(this.ParanoidMode, platformChanged, this.RewriteMods).ToArray();
- RecursiveRewriter rewriter = new RecursiveRewriter(
+ RecursiveRewriter rewriter = new(
module: module,
rewriteModule: curModule =>
{
@@ -380,7 +387,7 @@ namespace StardewModdingAPI.Framework.ModLoading
{
// get message template
// ($phrase is replaced with the noun phrase or messages)
- string template = null;
+ string? template = null;
switch (result)
{
case InstructionHandleResult.Rewritten:
@@ -439,20 +446,20 @@ namespace StardewModdingAPI.Framework.ModLoading
// format messages
string phrase = handler.Phrases.Any()
? string.Join(", ", handler.Phrases)
- : handler.DefaultPhrase ?? handler.GetType().Name;
+ : handler.DefaultPhrase;
this.Monitor.LogOnce(loggedMessages, template.Replace("$phrase", phrase));
}
/// <summary>Get the correct reference to use for compatibility with the current platform.</summary>
/// <param name="type">The type reference to rewrite.</param>
- private void ChangeTypeScope(TypeReference type)
+ private void ChangeTypeScope(TypeReference? type)
{
// check skip conditions
if (type == null || type.FullName.StartsWith("System."))
return;
// get assembly
- if (!this.TypeAssemblies.TryGetValue(type.FullName, out Assembly assembly))
+ if (!this.TypeAssemblies.TryGetValue(type.FullName, out Assembly? assembly))
return;
// replace scope
diff --git a/src/SMAPI/Framework/ModLoading/AssemblyParseResult.cs b/src/SMAPI/Framework/ModLoading/AssemblyParseResult.cs
index b56a776c..b133f8d6 100644
--- a/src/SMAPI/Framework/ModLoading/AssemblyParseResult.cs
+++ b/src/SMAPI/Framework/ModLoading/AssemblyParseResult.cs
@@ -1,3 +1,5 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
using System.IO;
using Mono.Cecil;
@@ -13,11 +15,15 @@ namespace StardewModdingAPI.Framework.ModLoading
public readonly FileInfo File;
/// <summary>The assembly definition.</summary>
- public readonly AssemblyDefinition Definition;
+ public readonly AssemblyDefinition? Definition;
/// <summary>The result of the assembly load.</summary>
public AssemblyLoadStatus Status;
+ /// <summary>Whether the <see cref="Definition"/> is loaded and ready (i.e. the <see cref="Status"/> is not <see cref="AssemblyLoadStatus.AlreadyLoaded"/> or <see cref="AssemblyLoadStatus.Failed"/>).</summary>
+ [MemberNotNullWhen(true, nameof(AssemblyParseResult.Definition))]
+ public bool HasDefinition => this.Status == AssemblyLoadStatus.Okay;
+
/*********
** Public methods
@@ -26,11 +32,14 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <param name="file">The original assembly file.</param>
/// <param name="assembly">The assembly definition.</param>
/// <param name="status">The result of the assembly load.</param>
- public AssemblyParseResult(FileInfo file, AssemblyDefinition assembly, AssemblyLoadStatus status)
+ public AssemblyParseResult(FileInfo file, AssemblyDefinition? assembly, AssemblyLoadStatus status)
{
this.File = file;
this.Definition = assembly;
this.Status = status;
+
+ if (status == AssemblyLoadStatus.Okay && assembly == null)
+ throw new InvalidOperationException($"Invalid assembly parse result: load status {status} with a null assembly.");
}
}
}
diff --git a/src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs
index 124951a5..f5d449c5 100644
--- a/src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs
+++ b/src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs
@@ -55,7 +55,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
{
if (this.MethodNames.Any())
{
- MethodReference methodRef = RewriteHelper.AsMethodReference(instruction);
+ MethodReference? methodRef = RewriteHelper.AsMethodReference(instruction);
if (methodRef != null && methodRef.DeclaringType.FullName == this.FullTypeName && this.MethodNames.Contains(methodRef.Name))
{
string eventName = methodRef.Name.Split(new[] { '_' }, 2)[1];
diff --git a/src/SMAPI/Framework/ModLoading/Finders/FieldFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/FieldFinder.cs
index 68415123..7fe4abec 100644
--- a/src/SMAPI/Framework/ModLoading/Finders/FieldFinder.cs
+++ b/src/SMAPI/Framework/ModLoading/Finders/FieldFinder.cs
@@ -49,7 +49,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
{
if (this.FieldNames.Any())
{
- FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction);
+ FieldReference? fieldRef = RewriteHelper.AsFieldReference(instruction);
if (fieldRef != null && fieldRef.DeclaringType.FullName == this.FullTypeName && this.FieldNames.Contains(fieldRef.Name))
{
this.FieldNames.Remove(fieldRef.Name);
diff --git a/src/SMAPI/Framework/ModLoading/Finders/MethodFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/MethodFinder.cs
index d2340f01..e8fdc8c7 100644
--- a/src/SMAPI/Framework/ModLoading/Finders/MethodFinder.cs
+++ b/src/SMAPI/Framework/ModLoading/Finders/MethodFinder.cs
@@ -52,7 +52,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
/// <param name="instruction">The IL instruction.</param>
protected bool IsMatch(Instruction instruction)
{
- MethodReference methodRef = RewriteHelper.AsMethodReference(instruction);
+ MethodReference? methodRef = RewriteHelper.AsMethodReference(instruction);
return
methodRef != null
&& methodRef.DeclaringType.FullName == this.FullTypeName
diff --git a/src/SMAPI/Framework/ModLoading/Finders/PropertyFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/PropertyFinder.cs
index 99344848..2af76f55 100644
--- a/src/SMAPI/Framework/ModLoading/Finders/PropertyFinder.cs
+++ b/src/SMAPI/Framework/ModLoading/Finders/PropertyFinder.cs
@@ -52,7 +52,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
/// <param name="instruction">The IL instruction.</param>
protected bool IsMatch(Instruction instruction)
{
- MethodReference methodRef = RewriteHelper.AsMethodReference(instruction);
+ MethodReference? methodRef = RewriteHelper.AsMethodReference(instruction);
return
methodRef != null
&& methodRef.DeclaringType.FullName == this.FullTypeName
diff --git a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs
index 8c1cae2b..f34542c3 100644
--- a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs
+++ b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Mono.Cecil;
using Mono.Cecil.Cil;
@@ -32,11 +33,11 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction)
{
// field reference
- FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction);
+ FieldReference? fieldRef = RewriteHelper.AsFieldReference(instruction);
if (fieldRef != null && this.ShouldValidate(fieldRef.DeclaringType))
{
// get target field
- FieldDefinition targetField = fieldRef.DeclaringType.Resolve()?.Fields.FirstOrDefault(p => p.Name == fieldRef.Name);
+ FieldDefinition? targetField = fieldRef.DeclaringType.Resolve()?.Fields.FirstOrDefault(p => p.Name == fieldRef.Name);
if (targetField == null)
return false;
@@ -49,16 +50,16 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
}
// method reference
- MethodReference methodReference = RewriteHelper.AsMethodReference(instruction);
+ MethodReference? methodReference = RewriteHelper.AsMethodReference(instruction);
if (methodReference != null && !this.IsUnsupported(methodReference) && this.ShouldValidate(methodReference.DeclaringType))
{
// get potential targets
- MethodDefinition[] candidateMethods = methodReference.DeclaringType.Resolve()?.Methods.Where(found => found.Name == methodReference.Name).ToArray();
+ MethodDefinition[]? candidateMethods = methodReference.DeclaringType.Resolve()?.Methods.Where(found => found.Name == methodReference.Name).ToArray();
if (candidateMethods == null || !candidateMethods.Any())
return false;
// compare return types
- MethodDefinition methodDef = methodReference.Resolve();
+ MethodDefinition? methodDef = methodReference.Resolve();
if (methodDef == null)
return false; // validated by ReferenceToMissingMemberFinder
@@ -78,7 +79,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
*********/
/// <summary>Whether references to the given type should be validated.</summary>
/// <param name="type">The type reference.</param>
- private bool ShouldValidate(TypeReference type)
+ private bool ShouldValidate([NotNullWhen(true)] TypeReference? type)
{
return type != null && this.ValidateReferencesToAssemblies.Contains(type.Scope.Name);
}
diff --git a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs
index d305daf4..fae7fb12 100644
--- a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs
+++ b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using Mono.Cecil;
using Mono.Cecil.Cil;
using StardewModdingAPI.Framework.ModLoading.Framework;
@@ -31,10 +32,10 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction)
{
// field reference
- FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction);
+ FieldReference? fieldRef = RewriteHelper.AsFieldReference(instruction);
if (fieldRef != null && this.ShouldValidate(fieldRef.DeclaringType))
{
- FieldDefinition target = fieldRef.Resolve();
+ FieldDefinition? target = fieldRef.Resolve();
if (target == null || target.HasConstant)
{
this.MarkFlag(InstructionHandleResult.NotCompatible, $"reference to {fieldRef.DeclaringType.FullName}.{fieldRef.Name} (no such field)");
@@ -43,10 +44,10 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
}
// method reference
- MethodReference methodRef = RewriteHelper.AsMethodReference(instruction);
+ MethodReference? methodRef = RewriteHelper.AsMethodReference(instruction);
if (methodRef != null && this.ShouldValidate(methodRef.DeclaringType) && !this.IsUnsupported(methodRef))
{
- MethodDefinition target = methodRef.Resolve();
+ MethodDefinition? target = methodRef.Resolve();
if (target == null)
{
string phrase;
@@ -71,7 +72,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
*********/
/// <summary>Whether references to the given type should be validated.</summary>
/// <param name="type">The type reference.</param>
- private bool ShouldValidate(TypeReference type)
+ private bool ShouldValidate([NotNullWhen(true)] TypeReference? type)
{
return type != null && this.ValidateReferencesToAssemblies.Contains(type.Scope.Name);
}
diff --git a/src/SMAPI/Framework/ModLoading/Finders/TypeAssemblyFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/TypeAssemblyFinder.cs
index 24ab2eca..17acbf9a 100644
--- a/src/SMAPI/Framework/ModLoading/Finders/TypeAssemblyFinder.cs
+++ b/src/SMAPI/Framework/ModLoading/Finders/TypeAssemblyFinder.cs
@@ -17,7 +17,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
private readonly InstructionHandleResult Result;
/// <summary>Get whether a matched type should be ignored.</summary>
- private readonly Func<TypeReference, bool> ShouldIgnore;
+ private readonly Func<TypeReference, bool>? ShouldIgnore;
/*********
@@ -27,7 +27,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
/// <param name="assemblyName">The full assembly name to which to find references.</param>
/// <param name="result">The result to return for matching instructions.</param>
/// <param name="shouldIgnore">Get whether a matched type should be ignored.</param>
- public TypeAssemblyFinder(string assemblyName, InstructionHandleResult result, Func<TypeReference, bool> shouldIgnore = null)
+ public TypeAssemblyFinder(string assemblyName, InstructionHandleResult result, Func<TypeReference, bool>? shouldIgnore = null)
: base(defaultPhrase: $"{assemblyName} assembly")
{
this.AssemblyName = assemblyName;
diff --git a/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs
index 260a8df8..77762f41 100644
--- a/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs
+++ b/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs
@@ -18,7 +18,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
private readonly InstructionHandleResult Result;
/// <summary>Get whether a matched type should be ignored.</summary>
- private readonly Func<TypeReference, bool> ShouldIgnore;
+ private readonly Func<TypeReference, bool>? ShouldIgnore;
/*********
@@ -28,7 +28,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
/// <param name="fullTypeNames">The full type names to match.</param>
/// <param name="result">The result to return for matching instructions.</param>
/// <param name="shouldIgnore">Get whether a matched type should be ignored.</param>
- public TypeFinder(string[] fullTypeNames, InstructionHandleResult result, Func<TypeReference, bool> shouldIgnore = null)
+ public TypeFinder(string[] fullTypeNames, InstructionHandleResult result, Func<TypeReference, bool>? shouldIgnore = null)
: base(defaultPhrase: $"{string.Join(", ", fullTypeNames)} type{(fullTypeNames.Length != 1 ? "s" : "")}") // default phrase should never be used
{
this.FullTypeNames = new HashSet<string>(fullTypeNames);
@@ -40,7 +40,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
/// <param name="fullTypeName">The full type name to match.</param>
/// <param name="result">The result to return for matching instructions.</param>
/// <param name="shouldIgnore">Get whether a matched type should be ignored.</param>
- public TypeFinder(string fullTypeName, InstructionHandleResult result, Func<TypeReference, bool> shouldIgnore = null)
+ public TypeFinder(string fullTypeName, InstructionHandleResult result, Func<TypeReference, bool>? shouldIgnore = null)
: this(new[] { fullTypeName }, result, shouldIgnore) { }
/// <inheritdoc />
diff --git a/src/SMAPI/Framework/ModLoading/Framework/BaseInstructionHandler.cs b/src/SMAPI/Framework/ModLoading/Framework/BaseInstructionHandler.cs
index d5d1b38e..865bf076 100644
--- a/src/SMAPI/Framework/ModLoading/Framework/BaseInstructionHandler.cs
+++ b/src/SMAPI/Framework/ModLoading/Framework/BaseInstructionHandler.cs
@@ -57,7 +57,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework
/// <param name="flag">The result flag to set.</param>
/// <param name="resultMessage">The result message to add.</param>
/// <returns>Returns true for convenience.</returns>
- protected bool MarkFlag(InstructionHandleResult flag, string resultMessage = null)
+ protected bool MarkFlag(InstructionHandleResult flag, string? resultMessage = null)
{
this.Flags.Add(flag);
if (resultMessage != null)
diff --git a/src/SMAPI/Framework/ModLoading/Framework/RecursiveRewriter.cs b/src/SMAPI/Framework/ModLoading/Framework/RecursiveRewriter.cs
index 4f14a579..55369602 100644
--- a/src/SMAPI/Framework/ModLoading/Framework/RecursiveRewriter.cs
+++ b/src/SMAPI/Framework/ModLoading/Framework/RecursiveRewriter.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Mono.Cecil;
using Mono.Cecil.Cil;
@@ -8,6 +9,7 @@ using Mono.Collections.Generic;
namespace StardewModdingAPI.Framework.ModLoading.Framework
{
/// <summary>Handles recursively rewriting loaded assembly code.</summary>
+ [SuppressMessage("ReSharper", "AccessToModifiedClosure", Justification = "Rewrite callbacks are invoked immediately.")]
internal class RecursiveRewriter
{
/*********
@@ -75,7 +77,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework
{
changed |= this.RewriteModuleImpl(this.Module);
- foreach (var type in types)
+ foreach (TypeDefinition type in types)
changed |= this.RewriteTypeDefinition(type);
}
catch (Exception ex)
@@ -127,9 +129,10 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework
ILProcessor cil = method.Body.GetILProcessor();
Collection<Instruction> instructions = cil.Body.Instructions;
bool addedInstructions = false;
+ // ReSharper disable once ForCanBeConvertedToForeach -- deliberate to allow changing the collection
for (int i = 0; i < instructions.Count; i++)
{
- var instruction = instructions[i];
+ Instruction instruction = instructions[i];
if (instruction.OpCode.Code == Code.Nop)
continue;
@@ -172,7 +175,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework
bool rewritten = false;
// field reference
- FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction);
+ FieldReference? fieldRef = RewriteHelper.AsFieldReference(instruction);
if (fieldRef != null)
{
rewritten |= this.RewriteTypeReference(fieldRef.DeclaringType, newType => fieldRef.DeclaringType = newType);
@@ -180,7 +183,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework
}
// method reference
- MethodReference methodRef = RewriteHelper.AsMethodReference(instruction);
+ MethodReference? methodRef = RewriteHelper.AsMethodReference(instruction);
if (methodRef != null)
this.RewriteMethodReference(methodRef);
@@ -210,7 +213,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework
});
rewritten |= this.RewriteTypeReference(methodRef.ReturnType, newType => methodRef.ReturnType = newType);
- foreach (var parameter in methodRef.Parameters)
+ foreach (ParameterDefinition parameter in methodRef.Parameters)
rewritten |= this.RewriteTypeReference(parameter.ParameterType, newType => parameter.ParameterType = newType);
if (methodRef is GenericInstanceMethod genericRef)
@@ -262,7 +265,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework
bool curChanged = false;
// attribute type
- TypeReference newAttrType = null;
+ TypeReference? newAttrType = null;
rewritten |= this.RewriteTypeReference(attribute.AttributeType, newType =>
{
newAttrType = newType;
@@ -287,9 +290,9 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework
if (curChanged)
{
// get constructor
- MethodDefinition constructor = (newAttrType ?? attribute.AttributeType)
+ MethodDefinition? constructor = (newAttrType ?? attribute.AttributeType)
.Resolve()
- .Methods
+ ?.Methods
.Where(method => method.IsConstructor)
.FirstOrDefault(ctor => RewriteHelper.HasMatchingSignature(ctor, attribute.Constructor));
if (constructor == null)
@@ -299,9 +302,9 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework
var newAttr = new CustomAttribute(this.Module.ImportReference(constructor));
for (int i = 0; i < argTypes.Length; i++)
newAttr.ConstructorArguments.Add(new CustomAttributeArgument(argTypes[i], attribute.ConstructorArguments[i].Value));
- foreach (var prop in attribute.Properties)
+ foreach (CustomAttributeNamedArgument prop in attribute.Properties)
newAttr.Properties.Add(new CustomAttributeNamedArgument(prop.Name, prop.Argument));
- foreach (var field in attribute.Fields)
+ foreach (CustomAttributeNamedArgument field in attribute.Fields)
newAttr.Fields.Add(new CustomAttributeNamedArgument(field.Name, field.Argument));
// swap attribute
diff --git a/src/SMAPI/Framework/ModLoading/Framework/RewriteHelper.cs b/src/SMAPI/Framework/ModLoading/Framework/RewriteHelper.cs
index d7cb2471..15f71251 100644
--- a/src/SMAPI/Framework/ModLoading/Framework/RewriteHelper.cs
+++ b/src/SMAPI/Framework/ModLoading/Framework/RewriteHelper.cs
@@ -21,7 +21,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework
*********/
/// <summary>Get the field reference from an instruction if it matches.</summary>
/// <param name="instruction">The IL instruction.</param>
- public static FieldReference AsFieldReference(Instruction instruction)
+ public static FieldReference? AsFieldReference(Instruction instruction)
{
return instruction.OpCode == OpCodes.Ldfld || instruction.OpCode == OpCodes.Ldsfld || instruction.OpCode == OpCodes.Stfld || instruction.OpCode == OpCodes.Stsfld
? (FieldReference)instruction.Operand
@@ -30,7 +30,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework
/// <summary>Get the method reference from an instruction if it matches.</summary>
/// <param name="instruction">The IL instruction.</param>
- public static MethodReference AsMethodReference(Instruction instruction)
+ public static MethodReference? AsMethodReference(Instruction instruction)
{
return instruction.OpCode == OpCodes.Call || instruction.OpCode == OpCodes.Callvirt || instruction.OpCode == OpCodes.Newobj
? (MethodReference)instruction.Operand
@@ -40,7 +40,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework
/// <summary>Get the CIL instruction to load a value onto the stack.</summary>
/// <param name="rawValue">The constant value to inject.</param>
/// <returns>Returns the instruction, or <c>null</c> if the value type isn't supported.</returns>
- public static Instruction GetLoadValueInstruction(object rawValue)
+ public static Instruction? GetLoadValueInstruction(object? rawValue)
{
return rawValue switch
{
@@ -149,7 +149,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework
/// <param name="typeA">The type ID to compare.</param>
/// <param name="typeB">The other type ID to compare.</param>
/// <returns>true if the type IDs look like the same type, false if not.</returns>
- public static bool LooksLikeSameType(TypeReference typeA, TypeReference typeB)
+ public static bool LooksLikeSameType(TypeReference? typeA, TypeReference? typeB)
{
return RewriteHelper.TypeDefinitionComparer.Equals(typeA, typeB);
}
diff --git a/src/SMAPI/Framework/ModLoading/InvalidModStateException.cs b/src/SMAPI/Framework/ModLoading/InvalidModStateException.cs
index 075e237a..b53a9886 100644
--- a/src/SMAPI/Framework/ModLoading/InvalidModStateException.cs
+++ b/src/SMAPI/Framework/ModLoading/InvalidModStateException.cs
@@ -8,7 +8,7 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <summary>Construct an instance.</summary>
/// <param name="message">The error message.</param>
/// <param name="ex">The underlying exception, if any.</param>
- public InvalidModStateException(string message, Exception ex = null)
+ public InvalidModStateException(string message, Exception? ex = null)
: base(message, ex) { }
}
}
diff --git a/src/SMAPI/Framework/ModLoading/ModMetadata.cs b/src/SMAPI/Framework/ModLoading/ModMetadata.cs
index 9e6bc61f..fe54634b 100644
--- a/src/SMAPI/Framework/ModLoading/ModMetadata.cs
+++ b/src/SMAPI/Framework/ModLoading/ModMetadata.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using StardewModdingAPI.Framework.ModHelpers;
@@ -42,7 +43,7 @@ namespace StardewModdingAPI.Framework.ModLoading
public IManifest Manifest { get; }
/// <inheritdoc />
- public ModDataRecordVersionedFields DataRecord { get; }
+ public ModDataRecordVersionedFields? DataRecord { get; }
/// <inheritdoc />
public ModMetadataStatus Status { get; private set; }
@@ -54,33 +55,35 @@ namespace StardewModdingAPI.Framework.ModLoading
public ModWarning Warnings => this.ActualWarnings & ~(this.DataRecord?.DataRecord.SuppressWarnings ?? ModWarning.None);
/// <inheritdoc />
- public string Error { get; private set; }
+ public string? Error { get; private set; }
/// <inheritdoc />
- public string ErrorDetails { get; private set; }
+ public string? ErrorDetails { get; private set; }
/// <inheritdoc />
public bool IsIgnored { get; }
/// <inheritdoc />
- public IMod Mod { get; private set; }
+ public IMod? Mod { get; private set; }
/// <inheritdoc />
- public IContentPack ContentPack { get; private set; }
+ public IContentPack? ContentPack { get; private set; }
/// <inheritdoc />
- public TranslationHelper Translations { get; private set; }
+ public TranslationHelper? Translations { get; private set; }
/// <inheritdoc />
- public IMonitor Monitor { get; private set; }
+ public IMonitor? Monitor { get; private set; }
/// <inheritdoc />
- public object Api { get; private set; }
+ public object? Api { get; private set; }
/// <inheritdoc />
- public ModEntryModel UpdateCheckData { get; private set; }
+ public ModEntryModel? UpdateCheckData { get; private set; }
/// <inheritdoc />
+ [MemberNotNullWhen(true, nameof(ModMetadata.ContentPack))]
+ [SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "The manifest may be null for broken mods while loading.")]
public bool IsContentPack => this.Manifest?.ContentPackFor != null;
/// <summary>The fake content packs created by this mod, if any.</summary>
@@ -97,13 +100,13 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <param name="manifest">The mod manifest.</param>
/// <param name="dataRecord">Metadata about the mod from SMAPI's internal data (if any).</param>
/// <param name="isIgnored">Whether the mod folder should be ignored. This should be <c>true</c> if it was found within a folder whose name starts with a dot.</param>
- public ModMetadata(string displayName, string directoryPath, string rootPath, IManifest manifest, ModDataRecordVersionedFields dataRecord, bool isIgnored)
+ public ModMetadata(string displayName, string directoryPath, string rootPath, IManifest? manifest, ModDataRecordVersionedFields? dataRecord, bool isIgnored)
{
this.DisplayName = displayName;
this.DirectoryPath = directoryPath;
this.RootPath = rootPath;
this.RelativeDirectoryPath = PathUtilities.GetRelativePath(this.RootPath, this.DirectoryPath);
- this.Manifest = manifest;
+ this.Manifest = manifest!; // manifest may be null in low-level SMAPI code, but won't be null once it's received by mods via IModInfo
this.DataRecord = dataRecord;
this.IsIgnored = isIgnored;
@@ -119,7 +122,7 @@ namespace StardewModdingAPI.Framework.ModLoading
}
/// <inheritdoc />
- public IModMetadata SetStatus(ModMetadataStatus status, ModFailReason reason, string error, string errorDetails = null)
+ public IModMetadata SetStatus(ModMetadataStatus status, ModFailReason reason, string? error, string? errorDetails = null)
{
this.Status = status;
this.FailReason = reason;
@@ -160,7 +163,7 @@ namespace StardewModdingAPI.Framework.ModLoading
}
/// <inheritdoc />
- public IModMetadata SetApi(object api)
+ public IModMetadata SetApi(object? api)
{
this.Api = api;
return this;
@@ -174,6 +177,7 @@ namespace StardewModdingAPI.Framework.ModLoading
}
/// <inheritdoc />
+ [MemberNotNullWhen(true, nameof(IModInfo.Manifest))]
public bool HasManifest()
{
return this.Manifest != null;
@@ -188,7 +192,7 @@ namespace StardewModdingAPI.Framework.ModLoading
}
/// <inheritdoc />
- public bool HasID(string id)
+ public bool HasID(string? id)
{
return
this.HasID()
@@ -243,7 +247,7 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <inheritdoc />
public string GetRelativePathWithRoot()
{
- string rootFolderName = Path.GetFileName(this.RootPath) ?? "";
+ string rootFolderName = Path.GetFileName(this.RootPath);
return Path.Combine(rootFolderName, this.RelativeDirectoryPath);
}
@@ -252,7 +256,7 @@ namespace StardewModdingAPI.Framework.ModLoading
{
foreach (var reference in this.FakeContentPacks.ToArray())
{
- if (!reference.TryGetTarget(out ContentPack pack))
+ if (!reference.TryGetTarget(out ContentPack? pack))
{
this.FakeContentPacks.Remove(reference);
continue;
diff --git a/src/SMAPI/Framework/ModLoading/ModResolver.cs b/src/SMAPI/Framework/ModLoading/ModResolver.cs
index 4b05d1e5..74e7cb32 100644
--- a/src/SMAPI/Framework/ModLoading/ModResolver.cs
+++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using StardewModdingAPI.Toolkit;
@@ -26,17 +27,14 @@ namespace StardewModdingAPI.Framework.ModLoading
{
foreach (ModFolder folder in toolkit.GetModFolders(rootPath))
{
- Manifest manifest = folder.Manifest;
+ Manifest? manifest = folder.Manifest;
// parse internal data record (if any)
- ModDataRecordVersionedFields dataRecord = modDatabase.Get(manifest?.UniqueID)?.GetVersionedFields(manifest);
+ ModDataRecordVersionedFields? dataRecord = modDatabase.Get(manifest?.UniqueID)?.GetVersionedFields(manifest);
// apply defaults
- if (manifest != null && dataRecord != null)
- {
- if (dataRecord.UpdateKey != null)
- manifest.UpdateKeys = new[] { dataRecord.UpdateKey };
- }
+ if (manifest != null && dataRecord?.UpdateKey is not null)
+ manifest.OverrideUpdateKeys(dataRecord.UpdateKey);
// build metadata
bool shouldIgnore = folder.Type == ModType.Ignored;
@@ -44,7 +42,7 @@ namespace StardewModdingAPI.Framework.ModLoading
? ModMetadataStatus.Found
: ModMetadataStatus.Failed;
- var metadata = new ModMetadata(folder.DisplayName, folder.Directory.FullName, rootPath, manifest, dataRecord, isIgnored: shouldIgnore);
+ IModMetadata metadata = new ModMetadata(folder.DisplayName, folder.Directory.FullName, rootPath, manifest, dataRecord, isIgnored: shouldIgnore);
if (shouldIgnore)
metadata.SetStatus(status, ModFailReason.DisabledByDotConvention, "disabled by dot convention");
else
@@ -58,7 +56,10 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <param name="mods">The mod manifests to validate.</param>
/// <param name="apiVersion">The current SMAPI version.</param>
/// <param name="getUpdateUrl">Get an update URL for an update key (if valid).</param>
- public void ValidateManifests(IEnumerable<IModMetadata> mods, ISemanticVersion apiVersion, Func<string, string> getUpdateUrl)
+ /// <param name="validateFilesExist">Whether to validate that files referenced in the manifest (like <see cref="IManifest.EntryDll"/>) exist on disk. This can be disabled to only validate the manifest itself.</param>
+ [SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "Manifest values may be null before they're validated.")]
+ [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalse", Justification = "Manifest values may be null before they're validated.")]
+ public void ValidateManifests(IEnumerable<IModMetadata> mods, ISemanticVersion apiVersion, Func<string, string?> getUpdateUrl, bool validateFilesExist = true)
{
mods = mods.ToArray();
@@ -85,7 +86,7 @@ namespace StardewModdingAPI.Framework.ModLoading
List<string> updateUrls = new List<string>();
foreach (UpdateKey key in mod.GetUpdateKeys(validOnly: true))
{
- string url = getUpdateUrl(key.ToString());
+ string? url = getUpdateUrl(key.ToString());
if (url != null)
updateUrls.Add(url);
}
@@ -95,7 +96,7 @@ namespace StardewModdingAPI.Framework.ModLoading
// build error
string error = $"{reasonPhrase}. Please check for a ";
- if (mod.DataRecord.StatusUpperVersion == null || mod.Manifest.Version.Equals(mod.DataRecord.StatusUpperVersion))
+ if (mod.DataRecord.StatusUpperVersion == null || mod.Manifest.Version?.Equals(mod.DataRecord.StatusUpperVersion) == true)
error += "newer version";
else
error += $"version newer than {mod.DataRecord.StatusUpperVersion}";
@@ -134,25 +135,21 @@ namespace StardewModdingAPI.Framework.ModLoading
if (hasDll)
{
// invalid filename format
- if (mod.Manifest.EntryDll.Intersect(Path.GetInvalidFileNameChars()).Any())
+ if (mod.Manifest.EntryDll!.Intersect(Path.GetInvalidFileNameChars()).Any())
{
mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has invalid filename '{mod.Manifest.EntryDll}' for the EntryDLL field.");
continue;
}
- // invalid path
- if (!File.Exists(Path.Combine(mod.DirectoryPath, mod.Manifest.EntryDll)))
- {
- mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist.");
- continue;
- }
-
- // invalid capitalization
- string actualFilename = new DirectoryInfo(mod.DirectoryPath).GetFiles(mod.Manifest.EntryDll).FirstOrDefault()?.Name;
- if (actualFilename != mod.Manifest.EntryDll)
+ // file doesn't exist
+ if (validateFilesExist)
{
- mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its {nameof(IManifest.EntryDll)} value '{mod.Manifest.EntryDll}' doesn't match the actual file capitalization '{actualFilename}'. The capitalization must match for crossplatform compatibility.");
- continue;
+ string fileName = CaseInsensitivePathLookup.GetCachedFor(mod.DirectoryPath).GetFilePath(mod.Manifest.EntryDll!);
+ if (!File.Exists(Path.Combine(mod.DirectoryPath, fileName)))
+ {
+ mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist.");
+ continue;
+ }
}
}
@@ -160,7 +157,7 @@ namespace StardewModdingAPI.Framework.ModLoading
else
{
// invalid content pack ID
- if (string.IsNullOrWhiteSpace(mod.Manifest.ContentPackFor.UniqueID))
+ if (string.IsNullOrWhiteSpace(mod.Manifest.ContentPackFor!.UniqueID))
{
mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest declares {nameof(IManifest.ContentPackFor)} without its required {nameof(IManifestContentPackFor.UniqueID)} field.");
continue;
@@ -191,7 +188,7 @@ namespace StardewModdingAPI.Framework.ModLoading
mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, "its manifest specifies an invalid ID (IDs must only contain letters, numbers, underscores, periods, or hyphens).");
// validate dependencies
- foreach (var dependency in mod.Manifest.Dependencies)
+ foreach (IManifestDependency? dependency in mod.Manifest.Dependencies)
{
// null dependency
if (dependency == null)
@@ -240,7 +237,7 @@ namespace StardewModdingAPI.Framework.ModLoading
// initialize metadata
mods = mods.ToArray();
var sortedMods = new Stack<IModMetadata>();
- var states = mods.ToDictionary(mod => mod, mod => ModDependencyStatus.Queued);
+ var states = mods.ToDictionary(mod => mod, _ => ModDependencyStatus.Queued);
// handle failed mods
foreach (IModMetadata mod in mods.Where(m => m.Status == ModMetadataStatus.Failed))
@@ -329,8 +326,11 @@ namespace StardewModdingAPI.Framework.ModLoading
string[] failedLabels =
(
from entry in dependencies
- where entry.Mod != null && entry.MinVersion != null && entry.MinVersion.IsNewerThan(entry.Mod.Manifest.Version)
- select $"{entry.Mod.DisplayName} (needs {entry.MinVersion} or later)"
+ where
+ entry.Mod != null
+ && entry.MinVersion != null
+ && entry.MinVersion.IsNewerThan(entry.Mod.Manifest.Version)
+ select $"{entry.Mod!.DisplayName} (needs {entry.MinVersion} or later)"
)
.ToArray();
if (failedLabels.Any())
@@ -346,16 +346,14 @@ namespace StardewModdingAPI.Framework.ModLoading
states[mod] = ModDependencyStatus.Checking;
// recursively sort dependencies
- foreach (var dependency in dependencies)
+ foreach (ModDependency dependency in dependencies)
{
- IModMetadata requiredMod = dependency.Mod;
- var subchain = new List<IModMetadata>(currentChain) { mod };
-
- // ignore missing optional dependency
- if (!dependency.IsRequired && requiredMod == null)
- continue;
+ IModMetadata? requiredMod = dependency.Mod;
+ if (requiredMod == null)
+ continue; // missing dependencies are handled earlier
// detect dependency loop
+ var subchain = new List<IModMetadata>(currentChain) { mod };
if (states[requiredMod] == ModDependencyStatus.Checking)
{
sortedMods.Push(mod);
@@ -364,8 +362,8 @@ namespace StardewModdingAPI.Framework.ModLoading
}
// recursively process each dependency
- var substatus = this.ProcessDependencies(mods, modDatabase, requiredMod, states, sortedMods, subchain);
- switch (substatus)
+ var subStatus = this.ProcessDependencies(mods, modDatabase, requiredMod, states, sortedMods, subchain);
+ switch (subStatus)
{
// sorted successfully
case ModDependencyStatus.Sorted:
@@ -381,7 +379,7 @@ namespace StardewModdingAPI.Framework.ModLoading
// unexpected status
case ModDependencyStatus.Queued:
case ModDependencyStatus.Checking:
- throw new InvalidModStateException($"Something went wrong sorting dependencies: mod '{requiredMod.DisplayName}' unexpectedly stayed in the '{substatus}' status.");
+ throw new InvalidModStateException($"Something went wrong sorting dependencies: mod '{requiredMod.DisplayName}' unexpectedly stayed in the '{subStatus}' status.");
// sanity check
default:
@@ -395,35 +393,16 @@ namespace StardewModdingAPI.Framework.ModLoading
}
}
- /// <summary>Get all mod folders in a root folder, passing through empty folders as needed.</summary>
- /// <param name="rootPath">The root folder path to search.</param>
- private IEnumerable<DirectoryInfo> GetModFolders(string rootPath)
- {
- foreach (string modRootPath in Directory.GetDirectories(rootPath))
- {
- DirectoryInfo directory = new DirectoryInfo(modRootPath);
-
- // if a folder only contains another folder, check the inner folder instead
- while (!directory.GetFiles().Any() && directory.GetDirectories().Length == 1)
- directory = directory.GetDirectories().First();
-
- yield return directory;
- }
- }
-
/// <summary>Get the dependencies declared in a manifest.</summary>
/// <param name="manifest">The mod manifest.</param>
/// <param name="loadedMods">The loaded mods.</param>
private IEnumerable<ModDependency> GetDependenciesFrom(IManifest manifest, IModMetadata[] loadedMods)
{
- IModMetadata FindMod(string id) => loadedMods.FirstOrDefault(m => m.HasID(id));
+ IModMetadata? FindMod(string id) => loadedMods.FirstOrDefault(m => m.HasID(id));
// yield dependencies
- if (manifest.Dependencies != null)
- {
- foreach (var entry in manifest.Dependencies)
- yield return new ModDependency(entry.UniqueID, entry.MinimumVersion, FindMod(entry.UniqueID), entry.IsRequired);
- }
+ foreach (IManifestDependency entry in manifest.Dependencies)
+ yield return new ModDependency(entry.UniqueID, entry.MinimumVersion, FindMod(entry.UniqueID), entry.IsRequired);
// yield content pack parent
if (manifest.ContentPackFor != null)
@@ -432,10 +411,10 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <summary>Get a technical message indicating why a mod's compatibility status was overridden, if applicable.</summary>
/// <param name="mod">The mod metadata.</param>
- private string GetTechnicalReasonForStatusOverride(IModMetadata mod)
+ private string? GetTechnicalReasonForStatusOverride(IModMetadata mod)
{
// get compatibility list record
- var data = mod.DataRecord;
+ ModDataRecordVersionedFields? data = mod.DataRecord;
if (data == null)
return null;
@@ -449,14 +428,14 @@ namespace StardewModdingAPI.Framework.ModLoading
};
// get reason
- string[] reasons = new[] { mod.DataRecord.StatusReasonPhrase, mod.DataRecord.StatusReasonDetails }
+ string?[] reasons = new[] { data.StatusReasonPhrase, data.StatusReasonDetails }
.Where(p => !string.IsNullOrWhiteSpace(p))
.ToArray();
// build message
return
$"marked {statusLabel} in SMAPI's internal compatibility list for "
- + (mod.DataRecord.StatusUpperVersion != null ? $"versions up to {mod.DataRecord.StatusUpperVersion}" : "all versions")
+ + (data.StatusUpperVersion != null ? $"versions up to {data.StatusUpperVersion}" : "all versions")
+ ": "
+ (reasons.Any() ? string.Join(": ", reasons) : "no reason given")
+ ".";
@@ -476,13 +455,13 @@ namespace StardewModdingAPI.Framework.ModLoading
public string ID { get; }
/// <summary>The minimum required version (if any).</summary>
- public ISemanticVersion MinVersion { get; }
+ public ISemanticVersion? MinVersion { get; }
/// <summary>Whether the mod shouldn't be loaded if the dependency isn't available.</summary>
public bool IsRequired { get; }
/// <summary>The loaded mod that fulfills the dependency (if available).</summary>
- public IModMetadata Mod { get; }
+ public IModMetadata? Mod { get; }
/*********
@@ -493,7 +472,7 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <param name="minVersion">The minimum required version (if any).</param>
/// <param name="mod">The loaded mod that fulfills the dependency (if available).</param>
/// <param name="isRequired">Whether the mod shouldn't be loaded if the dependency isn't available.</param>
- public ModDependency(string id, ISemanticVersion minVersion, IModMetadata mod, bool isRequired)
+ public ModDependency(string id, ISemanticVersion? minVersion, IModMetadata? mod, bool isRequired)
{
this.ID = id;
this.MinVersion = minVersion;
diff --git a/src/SMAPI/Framework/ModLoading/RewriteFacades/AccessToolsFacade.cs b/src/SMAPI/Framework/ModLoading/RewriteFacades/AccessToolsFacade.cs
index be2a1c58..afe38bfd 100644
--- a/src/SMAPI/Framework/ModLoading/RewriteFacades/AccessToolsFacade.cs
+++ b/src/SMAPI/Framework/ModLoading/RewriteFacades/AccessToolsFacade.cs
@@ -17,7 +17,7 @@ namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades
/*********
** Public methods
*********/
- public static ConstructorInfo DeclaredConstructor(Type type, Type[] parameters = null)
+ public static ConstructorInfo DeclaredConstructor(Type type, Type[]? parameters = null)
{
// Harmony 1.x matched both static and instance constructors
return
@@ -25,7 +25,7 @@ namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades
?? AccessTools.DeclaredConstructor(type, parameters, searchForStatic: true);
}
- public static ConstructorInfo Constructor(Type type, Type[] parameters = null)
+ public static ConstructorInfo Constructor(Type type, Type[]? parameters = null)
{
// Harmony 1.x matched both static and instance constructors
return
diff --git a/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyInstanceFacade.cs b/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyInstanceFacade.cs
index 135bd218..9c8ba2b0 100644
--- a/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyInstanceFacade.cs
+++ b/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyInstanceFacade.cs
@@ -28,7 +28,8 @@ namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades
return new Harmony(id);
}
- public DynamicMethod Patch(MethodBase original, HarmonyMethod prefix = null, HarmonyMethod postfix = null, HarmonyMethod transpiler = null)
+ [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalse", Justification = "If the user passes a null original method, we let it fail in the underlying Harmony instance instead of handling it here.")]
+ public DynamicMethod Patch(MethodBase original, HarmonyMethod? prefix = null, HarmonyMethod? postfix = null, HarmonyMethod? transpiler = null)
{
// In Harmony 1.x you could target a virtual method that's not implemented by the
// target type, but in Harmony 2.0 you need to target the concrete implementation.
@@ -58,7 +59,7 @@ namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades
/// <param name="prefix">The prefix method, if any.</param>
/// <param name="postfix">The postfix method, if any.</param>
/// <param name="transpiler">The transpiler method, if any.</param>
- private string GetPatchTypesLabel(HarmonyMethod prefix = null, HarmonyMethod postfix = null, HarmonyMethod transpiler = null)
+ private string GetPatchTypesLabel(HarmonyMethod? prefix = null, HarmonyMethod? postfix = null, HarmonyMethod? transpiler = null)
{
var patchTypes = new List<string>();
@@ -74,7 +75,7 @@ namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades
/// <summary>Get a human-readable label for the method being patched.</summary>
/// <param name="method">The method being patched.</param>
- private string GetMethodLabel(MethodBase method)
+ private string GetMethodLabel(MethodBase? method)
{
return method != null
? $"method {method.DeclaringType?.FullName}.{method.Name}"
diff --git a/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyMethodFacade.cs b/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyMethodFacade.cs
index 5162dda4..2b1ca54b 100644
--- a/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyMethodFacade.cs
+++ b/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyMethodFacade.cs
@@ -21,7 +21,7 @@ namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades
this.ImportMethodImpl(method);
}
- public HarmonyMethodFacade(Type type, string name, Type[] parameters = null)
+ public HarmonyMethodFacade(Type type, string name, Type[]? parameters = null)
{
this.ImportMethodImpl(AccessTools.Method(type, name, parameters));
}
@@ -38,7 +38,7 @@ namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades
// internal code still handles null fine. For backwards compatibility, this bypasses
// the new restriction when the mod hasn't been updated for Harmony 2.0 yet.
- MethodInfo importMethod = typeof(HarmonyMethod).GetMethod("ImportMethod", BindingFlags.Instance | BindingFlags.NonPublic);
+ MethodInfo? importMethod = typeof(HarmonyMethod).GetMethod("ImportMethod", BindingFlags.Instance | BindingFlags.NonPublic);
if (importMethod == null)
throw new InvalidOperationException("Can't find 'HarmonyMethod.ImportMethod' method");
importMethod.Invoke(this, new object[] { methodInfo });
diff --git a/src/SMAPI/Framework/ModLoading/RewriteFacades/SpriteBatchFacade.cs b/src/SMAPI/Framework/ModLoading/RewriteFacades/SpriteBatchFacade.cs
index 5f68f8d9..67569424 100644
--- a/src/SMAPI/Framework/ModLoading/RewriteFacades/SpriteBatchFacade.cs
+++ b/src/SMAPI/Framework/ModLoading/RewriteFacades/SpriteBatchFacade.cs
@@ -18,7 +18,8 @@ namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades
** Public methods
*********/
/// <summary>Construct an instance.</summary>
- public SpriteBatchFacade(GraphicsDevice graphicsDevice) : base(graphicsDevice) { }
+ public SpriteBatchFacade(GraphicsDevice graphicsDevice)
+ : base(graphicsDevice) { }
/****
diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs
index 857a2230..d5f4cf4a 100644
--- a/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs
+++ b/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs
@@ -31,13 +31,19 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
/// <param name="toFieldName">The new field name to reference.</param>
public FieldReplaceRewriter AddField(Type fromType, string fromFieldName, Type toType, string toFieldName)
{
+ // validate parameters
+ if (fromType == null)
+ throw new InvalidOperationException("Can't replace a field on a null source type.");
+ if (toType == null)
+ throw new InvalidOperationException("Can't replace a field on a null target type.");
+
// get full type name
- string fromTypeName = fromType?.FullName;
+ string? fromTypeName = fromType.FullName;
if (fromTypeName == null)
throw new InvalidOperationException($"Can't replace field for invalid type reference {toType}.");
// get target field
- FieldInfo toField = toType.GetField(toFieldName);
+ FieldInfo? toField = toType.GetField(toFieldName);
if (toField == null)
throw new InvalidOperationException($"The {toType.FullName} class doesn't have a {toFieldName} field.");
@@ -52,15 +58,15 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
/// <inheritdoc />
public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction)
{
- FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction);
- string declaringType = fieldRef?.DeclaringType?.FullName;
+ FieldReference? fieldRef = RewriteHelper.AsFieldReference(instruction);
+ string? declaringType = fieldRef?.DeclaringType?.FullName;
// get mapped field
- if (declaringType == null || !this.FieldMaps.TryGetValue(declaringType, out var fieldMap) || !fieldMap.TryGetValue(fieldRef.Name, out FieldInfo toField))
+ if (declaringType == null || !this.FieldMaps.TryGetValue(declaringType, out var fieldMap) || !fieldMap.TryGetValue(fieldRef!.Name, out FieldInfo? toField))
return false;
// replace with new field
- this.Phrases.Add($"{fieldRef.DeclaringType.Name}.{fieldRef.Name} field");
+ this.Phrases.Add($"{fieldRef.DeclaringType!.Name}.{fieldRef.Name} field");
instruction.Operand = module.ImportReference(toField);
return this.MarkRewritten();
}
diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/HarmonyRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/HarmonyRewriter.cs
index 922d4bc4..aea490c8 100644
--- a/src/SMAPI/Framework/ModLoading/Rewriters/HarmonyRewriter.cs
+++ b/src/SMAPI/Framework/ModLoading/Rewriters/HarmonyRewriter.cs
@@ -34,7 +34,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
public override bool Handle(ModuleDefinition module, TypeReference type, Action<TypeReference> replaceWith)
{
// detect Harmony
- if (!(type.Scope is AssemblyNameReference scope) || scope.Name != "0Harmony")
+ if (type.Scope is not AssemblyNameReference scope || scope.Name != "0Harmony")
return false;
// rewrite Harmony 1.x type to Harmony 2.0 type
@@ -57,7 +57,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
if (this.ShouldRewrite)
{
// rewrite Harmony 1.x methods to Harmony 2.0
- MethodReference methodRef = RewriteHelper.AsMethodReference(instruction);
+ MethodReference? methodRef = RewriteHelper.AsMethodReference(instruction);
if (this.TryRewriteMethodsToFacade(module, methodRef))
{
this.OnChanged();
@@ -65,7 +65,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
}
// rewrite renamed fields
- FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction);
+ FieldReference? fieldRef = RewriteHelper.AsFieldReference(instruction);
if (fieldRef != null)
{
if (fieldRef.DeclaringType.FullName == "HarmonyLib.HarmonyMethod" && fieldRef.Name == "prioritiy")
@@ -93,13 +93,13 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
/// <summary>Rewrite methods to use Harmony facades if needed.</summary>
/// <param name="module">The assembly module containing the method reference.</param>
/// <param name="methodRef">The method reference to map.</param>
- private bool TryRewriteMethodsToFacade(ModuleDefinition module, MethodReference methodRef)
+ private bool TryRewriteMethodsToFacade(ModuleDefinition module, MethodReference? methodRef)
{
if (!this.ReplacedTypes)
return false; // not Harmony (or already using Harmony 2.0)
// get facade type
- Type toType = methodRef?.DeclaringType.FullName switch
+ Type? toType = methodRef?.DeclaringType.FullName switch
{
"HarmonyLib.Harmony" => typeof(HarmonyInstanceFacade),
"HarmonyLib.AccessTools" => typeof(AccessToolsFacade),
@@ -110,9 +110,9 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
return false;
// map if there's a matching method
- if (RewriteHelper.HasMatchingSignature(toType, methodRef))
+ if (RewriteHelper.HasMatchingSignature(toType, methodRef!))
{
- methodRef.DeclaringType = module.ImportReference(toType);
+ methodRef!.DeclaringType = module.ImportReference(toType);
return true;
}
@@ -137,7 +137,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
{
string fullName = type.FullName.Replace("Harmony.", "HarmonyLib.");
string targetName = typeof(Harmony).AssemblyQualifiedName!.Replace(typeof(Harmony).FullName!, fullName);
- return Type.GetType(targetName, throwOnError: true);
+ return Type.GetType(targetName, throwOnError: true)!;
}
}
}
diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicFieldRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicFieldRewriter.cs
index 57f1dd17..9c6a3980 100644
--- a/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicFieldRewriter.cs
+++ b/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicFieldRewriter.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Mono.Cecil;
using Mono.Cecil.Cil;
@@ -31,17 +32,17 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction)
{
// get field ref
- FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction);
+ FieldReference? fieldRef = RewriteHelper.AsFieldReference(instruction);
if (fieldRef == null || !this.ShouldValidate(fieldRef.DeclaringType))
return false;
// skip if not broken
- FieldDefinition fieldDefinition = fieldRef.Resolve();
- if (fieldDefinition != null && !fieldDefinition.HasConstant)
+ FieldDefinition? fieldDefinition = fieldRef.Resolve();
+ if (fieldDefinition?.HasConstant == false)
return false;
// rewrite if possible
- TypeDefinition declaringType = fieldRef.DeclaringType.Resolve();
+ TypeDefinition? declaringType = fieldRef.DeclaringType.Resolve();
bool isRead = instruction.OpCode == OpCodes.Ldsfld || instruction.OpCode == OpCodes.Ldfld;
return
this.TryRewriteToProperty(module, instruction, fieldRef, declaringType, isRead)
@@ -54,7 +55,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
*********/
/// <summary>Whether references to the given type should be validated.</summary>
/// <param name="type">The type reference.</param>
- private bool ShouldValidate(TypeReference type)
+ private bool ShouldValidate([NotNullWhen(true)] TypeReference? type)
{
return type != null && this.RewriteReferencesToAssemblies.Contains(type.Scope.Name);
}
@@ -68,8 +69,8 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
private bool TryRewriteToProperty(ModuleDefinition module, Instruction instruction, FieldReference fieldRef, TypeDefinition declaringType, bool isRead)
{
// get equivalent property
- PropertyDefinition property = declaringType?.Properties.FirstOrDefault(p => p.Name == fieldRef.Name);
- MethodDefinition method = isRead ? property?.GetMethod : property?.SetMethod;
+ PropertyDefinition? property = declaringType?.Properties.FirstOrDefault(p => p.Name == fieldRef.Name);
+ MethodDefinition? method = isRead ? property?.GetMethod : property?.SetMethod;
if (method == null)
return false;
@@ -84,14 +85,14 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
/// <summary>Try rewriting the field into a matching const field.</summary>
/// <param name="instruction">The CIL instruction to rewrite.</param>
/// <param name="field">The field definition.</param>
- private bool TryRewriteToConstField(Instruction instruction, FieldDefinition field)
+ private bool TryRewriteToConstField(Instruction instruction, FieldDefinition? field)
{
// must have been a static field read, and the new field must be const
if (instruction.OpCode != OpCodes.Ldsfld || field?.HasConstant != true)
return false;
// get opcode for value type
- Instruction loadInstruction = RewriteHelper.GetLoadValueInstruction(field.Constant);
+ Instruction? loadInstruction = RewriteHelper.GetLoadValueInstruction(field.Constant);
if (loadInstruction == null)
return false;
diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicMethodRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicMethodRewriter.cs
index 89de437e..601ecbbc 100644
--- a/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicMethodRewriter.cs
+++ b/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicMethodRewriter.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Mono.Cecil;
using Mono.Cecil.Cil;
@@ -31,7 +32,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction)
{
// get method ref
- MethodReference methodRef = RewriteHelper.AsMethodReference(instruction);
+ MethodReference? methodRef = RewriteHelper.AsMethodReference(instruction);
if (methodRef == null || !this.ShouldValidate(methodRef.DeclaringType))
return false;
@@ -40,13 +41,13 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
return false;
// get type
- var type = methodRef.DeclaringType.Resolve();
+ TypeDefinition? type = methodRef.DeclaringType.Resolve();
if (type == null)
return false;
// get method definition
- MethodDefinition method = null;
- foreach (var match in type.Methods.Where(p => p.Name == methodRef.Name))
+ MethodDefinition? method = null;
+ foreach (MethodDefinition match in type.Methods.Where(p => p.Name == methodRef.Name))
{
// reference matches initial parameters of definition
if (methodRef.Parameters.Count >= match.Parameters.Count || !this.InitialParametersMatch(methodRef, match))
@@ -70,7 +71,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
return false; // SMAPI needs to load the value onto the stack before the method call, but the optional parameter type wasn't recognized
// rewrite method reference
- foreach (Instruction loadInstruction in loadInstructions)
+ foreach (Instruction? loadInstruction in loadInstructions)
cil.InsertBefore(instruction, loadInstruction);
instruction.Operand = module.ImportReference(method);
@@ -84,7 +85,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
*********/
/// <summary>Whether references to the given type should be validated.</summary>
/// <param name="type">The type reference.</param>
- private bool ShouldValidate(TypeReference type)
+ private bool ShouldValidate([NotNullWhen(true)] TypeReference? type)
{
return type != null && this.RewriteReferencesToAssemblies.Contains(type.Scope.Name);
}
diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs
index 9933e2ca..2e2f6316 100644
--- a/src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs
+++ b/src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs
@@ -1,4 +1,5 @@
using System;
+using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Mono.Cecil;
using Mono.Cecil.Cil;
@@ -26,7 +27,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
/// <param name="fromType">The type whose methods to remap.</param>
/// <param name="toType">The type with methods to map to.</param>
/// <param name="nounPhrase">A brief noun phrase indicating what the instruction finder matches (or <c>null</c> to generate one).</param>
- public MethodParentRewriter(string fromType, Type toType, string nounPhrase = null)
+ public MethodParentRewriter(string fromType, Type toType, string? nounPhrase = null)
: base(nounPhrase ?? $"{fromType.Split('.').Last()} methods")
{
this.FromType = fromType;
@@ -37,14 +38,14 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
/// <param name="fromType">The type whose methods to remap.</param>
/// <param name="toType">The type with methods to map to.</param>
/// <param name="nounPhrase">A brief noun phrase indicating what the instruction finder matches (or <c>null</c> to generate one).</param>
- public MethodParentRewriter(Type fromType, Type toType, string nounPhrase = null)
- : this(fromType.FullName, toType, nounPhrase) { }
+ public MethodParentRewriter(Type fromType, Type toType, string? nounPhrase = null)
+ : this(fromType.FullName!, toType, nounPhrase) { }
/// <inheritdoc />
public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction)
{
// get method ref
- MethodReference methodRef = RewriteHelper.AsMethodReference(instruction);
+ MethodReference? methodRef = RewriteHelper.AsMethodReference(instruction);
if (!this.IsMatch(methodRef))
return false;
@@ -59,7 +60,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
*********/
/// <summary>Get whether a CIL instruction matches.</summary>
/// <param name="methodRef">The method reference.</param>
- private bool IsMatch(MethodReference methodRef)
+ private bool IsMatch([NotNullWhen(true)] MethodReference? methodRef)
{
return
methodRef != null
diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs
index ad5cb96f..a81cb5be 100644
--- a/src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs
+++ b/src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs
@@ -17,7 +17,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
private readonly Type ToType;
/// <summary>Get whether a matched type should be ignored.</summary>
- private readonly Func<TypeReference, bool> ShouldIgnore;
+ private readonly Func<TypeReference, bool>? ShouldIgnore;
/*********
@@ -27,7 +27,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
/// <param name="fromTypeFullName">The full type name to which to find references.</param>
/// <param name="toType">The new type to reference.</param>
/// <param name="shouldIgnore">Get whether a matched type should be ignored.</param>
- public TypeReferenceRewriter(string fromTypeFullName, Type toType, Func<TypeReference, bool> shouldIgnore = null)
+ public TypeReferenceRewriter(string fromTypeFullName, Type toType, Func<TypeReference, bool>? shouldIgnore = null)
: base($"{fromTypeFullName} type")
{
this.FromTypeName = fromTypeFullName;
diff --git a/src/SMAPI/Framework/ModLoading/Symbols/SymbolReaderProvider.cs b/src/SMAPI/Framework/ModLoading/Symbols/SymbolReaderProvider.cs
index 44074337..0d3aff9f 100644
--- a/src/SMAPI/Framework/ModLoading/Symbols/SymbolReaderProvider.cs
+++ b/src/SMAPI/Framework/ModLoading/Symbols/SymbolReaderProvider.cs
@@ -16,7 +16,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Symbols
private readonly ISymbolReaderProvider BaseProvider = new DefaultSymbolReaderProvider(throwIfNoSymbol: false);
/// <summary>The symbol data loaded by absolute assembly path.</summary>
- private readonly Dictionary<string, Stream> SymbolsByAssemblyPath = new Dictionary<string, Stream>(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary<string, Stream> SymbolsByAssemblyPath = new(StringComparer.OrdinalIgnoreCase);
/*********
@@ -36,7 +36,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Symbols
/// <param name="fileName">The assembly file name.</param>
public ISymbolReader GetSymbolReader(ModuleDefinition module, string fileName)
{
- return this.SymbolsByAssemblyPath.TryGetValue(module.Name, out Stream symbolData)
+ return this.SymbolsByAssemblyPath.TryGetValue(module.Name, out Stream? symbolData)
? new SymbolReader(module, symbolData)
: this.BaseProvider.GetSymbolReader(module, fileName);
}
@@ -46,7 +46,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Symbols
/// <param name="symbolStream">The loaded symbol file stream.</param>
public ISymbolReader GetSymbolReader(ModuleDefinition module, Stream symbolStream)
{
- return this.SymbolsByAssemblyPath.TryGetValue(module.Name, out Stream symbolData)
+ return this.SymbolsByAssemblyPath.TryGetValue(module.Name, out Stream? symbolData)
? new SymbolReader(module, symbolData)
: this.BaseProvider.GetSymbolReader(module, symbolStream);
}
diff --git a/src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs b/src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs
index a4ac54e2..d81d763e 100644
--- a/src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs
+++ b/src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs
@@ -16,7 +16,7 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <c>TKey</c> in the above example). If all components are equal after substitution, and the
/// tokens can all be mapped to the same generic type, the types are considered equal.
/// </remarks>
- internal class TypeReferenceComparer : IEqualityComparer<TypeReference>
+ internal class TypeReferenceComparer : IEqualityComparer<TypeReference?>
{
/*********
** Public methods
@@ -24,7 +24,7 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <summary>Get whether the specified objects are equal.</summary>
/// <param name="a">The first object to compare.</param>
/// <param name="b">The second object to compare.</param>
- public bool Equals(TypeReference a, TypeReference b)
+ public bool Equals(TypeReference? a, TypeReference? b)
{
if (a == null || b == null)
return a == b;
@@ -52,7 +52,7 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <param name="typeB">The second type to compare.</param>
private bool HeuristicallyEquals(TypeReference typeA, TypeReference typeB)
{
- bool HeuristicallyEquals(string typeNameA, string typeNameB, IDictionary<string, string> tokenMap)
+ bool HeuristicallyEqualsImpl(string typeNameA, string typeNameB, IDictionary<string, string> tokenMap)
{
// analyze type names
bool hasTokensA = typeNameA.Contains("!");
@@ -80,14 +80,14 @@ namespace StardewModdingAPI.Framework.ModLoading
for (int i = 0; i < symbolsA.Length; i++)
{
- if (!HeuristicallyEquals(symbolsA[i], symbolsB[i], tokenMap))
+ if (!HeuristicallyEqualsImpl(symbolsA[i], symbolsB[i], tokenMap))
return false;
}
return true;
}
- return HeuristicallyEquals(typeA.FullName, typeB.FullName, new Dictionary<string, string>());
+ return HeuristicallyEqualsImpl(typeA.FullName, typeB.FullName, new Dictionary<string, string>());
}
/// <summary>Map a generic type placeholder (like <c>!0</c>) to its actual type.</summary>
@@ -97,7 +97,7 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <returns>Returns the previously-mapped type if applicable, else the <paramref name="type"/>.</returns>
private string MapPlaceholder(string placeholder, string type, IDictionary<string, string> map)
{
- if (map.TryGetValue(placeholder, out string result))
+ if (map.TryGetValue(placeholder, out string? result))
return result;
map[placeholder] = type;
diff --git a/src/SMAPI/Framework/ModRegistry.cs b/src/SMAPI/Framework/ModRegistry.cs
index ef389337..1ae5643f 100644
--- a/src/SMAPI/Framework/ModRegistry.cs
+++ b/src/SMAPI/Framework/ModRegistry.cs
@@ -13,7 +13,7 @@ namespace StardewModdingAPI.Framework
** Fields
*********/
/// <summary>The registered mod data.</summary>
- private readonly List<IModMetadata> Mods = new List<IModMetadata>();
+ private readonly List<IModMetadata> Mods = new();
/// <summary>An assembly full name => mod lookup.</summary>
private readonly IDictionary<string, IModMetadata> ModNamesByAssembly = new Dictionary<string, IModMetadata>();
@@ -35,12 +35,12 @@ namespace StardewModdingAPI.Framework
this.Mods.Add(metadata);
}
- /// <summary>Track a mod's assembly for use via <see cref="GetFrom"/>.</summary>
+ /// <summary>Track a mod's assembly for use via <see cref="GetFrom(Type?)"/>.</summary>
/// <param name="metadata">The mod metadata.</param>
/// <param name="modAssembly">The mod assembly.</param>
public void TrackAssemblies(IModMetadata metadata, Assembly modAssembly)
{
- this.ModNamesByAssembly[modAssembly.FullName] = metadata;
+ this.ModNamesByAssembly[modAssembly.FullName!] = metadata;
}
/// <summary>Get metadata for all loaded mods.</summary>
@@ -59,8 +59,8 @@ namespace StardewModdingAPI.Framework
/// <summary>Get metadata for a loaded mod.</summary>
/// <param name="uniqueID">The mod's unique ID.</param>
- /// <returns>Returns the matching mod's metadata, or <c>null</c> if not found.</returns>
- public IModMetadata Get(string uniqueID)
+ /// <returns>Returns the mod's metadata, or <c>null</c> if not found.</returns>
+ public IModMetadata? Get(string uniqueID)
{
// normalize search ID
if (string.IsNullOrWhiteSpace(uniqueID))
@@ -73,15 +73,15 @@ namespace StardewModdingAPI.Framework
/// <summary>Get the mod metadata from one of its assemblies.</summary>
/// <param name="type">The type to check.</param>
- /// <returns>Returns the mod name, or <c>null</c> if the type isn't part of a known mod.</returns>
- public IModMetadata GetFrom(Type type)
+ /// <returns>Returns the mod's metadata, or <c>null</c> if the type isn't part of a known mod.</returns>
+ public IModMetadata? GetFrom(Type? type)
{
// null
if (type == null)
return null;
// known type
- string assemblyName = type.Assembly.FullName;
+ string assemblyName = type.Assembly.FullName!;
if (this.ModNamesByAssembly.ContainsKey(assemblyName))
return this.ModNamesByAssembly[assemblyName];
@@ -89,21 +89,27 @@ namespace StardewModdingAPI.Framework
return null;
}
- /// <summary>Get the friendly name for the closest assembly registered as a source of deprecation warnings.</summary>
- /// <returns>Returns the source name, or <c>null</c> if no registered assemblies were found.</returns>
- public IModMetadata GetFromStack()
+ /// <summary>Get the mod metadata from a stack frame, if any.</summary>
+ /// <param name="frame">The stack frame to check.</param>
+ /// <returns>Returns the mod's metadata, or <c>null</c> if the frame isn't part of a known mod.</returns>
+ public IModMetadata? GetFrom(StackFrame frame)
+ {
+ MethodBase? method = frame.GetMethod();
+ return this.GetFrom(method?.ReflectedType);
+ }
+
+ /// <summary>Get the mod metadata from the closest assembly registered as a source of deprecation warnings.</summary>
+ /// <returns>Returns the mod's metadata, or <c>null</c> if no registered assemblies were found.</returns>
+ public IModMetadata? GetFromStack()
{
// get stack frames
- StackTrace stack = new StackTrace();
+ StackTrace stack = new();
StackFrame[] frames = stack.GetFrames();
- if (frames == null)
- return null;
// search stack for a source assembly
foreach (StackFrame frame in frames)
{
- MethodBase method = frame.GetMethod();
- IModMetadata mod = this.GetFrom(method.ReflectedType);
+ IModMetadata? mod = this.GetFrom(frame);
if (mod != null)
return mod;
}
diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs
index 10bf9f94..1a43c1fc 100644
--- a/src/SMAPI/Framework/Models/SConfig.cs
+++ b/src/SMAPI/Framework/Models/SConfig.cs
@@ -22,11 +22,12 @@ namespace StardewModdingAPI.Framework.Models
[nameof(VerboseLogging)] = false,
[nameof(LogNetworkTraffic)] = false,
[nameof(RewriteMods)] = true,
- [nameof(AggressiveMemoryOptimizations)] = false
+ [nameof(AggressiveMemoryOptimizations)] = false,
+ [nameof(UsePintail)] = true
};
/// <summary>The default values for <see cref="SuppressUpdateChecks"/>, to log changes if different.</summary>
- private static readonly HashSet<string> DefaultSuppressUpdateChecks = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
+ private static readonly HashSet<string> DefaultSuppressUpdateChecks = new(StringComparer.OrdinalIgnoreCase)
{
"SMAPI.ConsoleCommands",
"SMAPI.ErrorHandler",
@@ -38,63 +39,101 @@ namespace StardewModdingAPI.Framework.Models
** Accessors
********/
/// <summary>Whether to enable development features.</summary>
- public bool DeveloperMode { get; set; }
+ public bool DeveloperMode { get; private set; }
/// <summary>Whether to check for newer versions of SMAPI and mods on startup.</summary>
- public bool CheckForUpdates { get; set; }
+ public bool CheckForUpdates { get; }
/// <summary>Whether to add a section to the 'mod issues' list for mods which which directly use potentially sensitive .NET APIs like file or shell access.</summary>
- public bool ParanoidWarnings { get; set; } = (bool)SConfig.DefaultValues[nameof(SConfig.ParanoidWarnings)];
+ public bool ParanoidWarnings { get; }
/// <summary>Whether to show beta versions as valid updates.</summary>
- public bool UseBetaChannel { get; set; } = (bool)SConfig.DefaultValues[nameof(SConfig.UseBetaChannel)];
+ public bool UseBetaChannel { get; }
/// <summary>SMAPI's GitHub project name, used to perform update checks.</summary>
- public string GitHubProjectName { get; set; }
-
- /// <summary>Stardew64Installer's GitHub project name, used to perform update checks.</summary>
- public string Stardew64InstallerGitHubProjectName { get; set; }
+ public string GitHubProjectName { get; }
/// <summary>The base URL for SMAPI's web API, used to perform update checks.</summary>
- public string WebApiBaseUrl { get; set; }
+ public string WebApiBaseUrl { get; }
/// <summary>Whether SMAPI should log more information about the game context.</summary>
- public bool VerboseLogging { get; set; }
+ public bool VerboseLogging { get; }
/// <summary>Whether SMAPI should rewrite mods for compatibility.</summary>
- public bool RewriteMods { get; set; } = (bool)SConfig.DefaultValues[nameof(SConfig.RewriteMods)];
+ public bool RewriteMods { get; }
/// <summary>Whether to enable more aggressive memory optimizations.</summary>
- public bool AggressiveMemoryOptimizations { get; set; } = (bool)SConfig.DefaultValues[nameof(SConfig.AggressiveMemoryOptimizations)];
+ public bool AggressiveMemoryOptimizations { get; }
+
+ /// <summary>Whether to use the experimental Pintail API proxying library, instead of the original proxying built into SMAPI itself.</summary>
+ public bool UsePintail { get; }
/// <summary>Whether SMAPI should log network traffic. Best combined with <see cref="VerboseLogging"/>, which includes network metadata.</summary>
- public bool LogNetworkTraffic { get; set; }
+ public bool LogNetworkTraffic { get; }
/// <summary>The colors to use for text written to the SMAPI console.</summary>
- public ColorSchemeConfig ConsoleColors { get; set; }
+ public ColorSchemeConfig ConsoleColors { get; }
/// <summary>The mod IDs SMAPI should ignore when performing update checks or validating update keys.</summary>
- public string[] SuppressUpdateChecks { get; set; }
+ public string[] SuppressUpdateChecks { get; }
/********
** Public methods
********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="developerMode">Whether to enable development features.</param>
+ /// <param name="checkForUpdates">Whether to check for newer versions of SMAPI and mods on startup.</param>
+ /// <param name="paranoidWarnings">Whether to add a section to the 'mod issues' list for mods which which directly use potentially sensitive .NET APIs like file or shell access.</param>
+ /// <param name="useBetaChannel">Whether to show beta versions as valid updates.</param>
+ /// <param name="gitHubProjectName">SMAPI's GitHub project name, used to perform update checks.</param>
+ /// <param name="webApiBaseUrl">The base URL for SMAPI's web API, used to perform update checks.</param>
+ /// <param name="verboseLogging">Whether SMAPI should log more information about the game context.</param>
+ /// <param name="rewriteMods">Whether SMAPI should rewrite mods for compatibility.</param>
+ /// <param name="aggressiveMemoryOptimizations">Whether to enable more aggressive memory optimizations.</param>
+ /// <param name="usePintail">Whether to use the experimental Pintail API proxying library, instead of the original proxying built into SMAPI itself.</param>
+ /// <param name="logNetworkTraffic">Whether SMAPI should log network traffic.</param>
+ /// <param name="consoleColors">The colors to use for text written to the SMAPI console.</param>
+ /// <param name="suppressUpdateChecks">The mod IDs SMAPI should ignore when performing update checks or validating update keys.</param>
+ public SConfig(bool developerMode, bool checkForUpdates, bool? paranoidWarnings, bool? useBetaChannel, string gitHubProjectName, string webApiBaseUrl, bool verboseLogging, bool? rewriteMods, bool? aggressiveMemoryOptimizations, bool usePintail, bool logNetworkTraffic, ColorSchemeConfig consoleColors, string[]? suppressUpdateChecks)
+ {
+ this.DeveloperMode = developerMode;
+ this.CheckForUpdates = checkForUpdates;
+ this.ParanoidWarnings = paranoidWarnings ?? (bool)SConfig.DefaultValues[nameof(SConfig.ParanoidWarnings)];
+ this.UseBetaChannel = useBetaChannel ?? (bool)SConfig.DefaultValues[nameof(SConfig.UseBetaChannel)];
+ this.GitHubProjectName = gitHubProjectName;
+ this.WebApiBaseUrl = webApiBaseUrl;
+ this.VerboseLogging = verboseLogging;
+ this.RewriteMods = rewriteMods ?? (bool)SConfig.DefaultValues[nameof(SConfig.RewriteMods)];
+ this.AggressiveMemoryOptimizations = aggressiveMemoryOptimizations ?? (bool)SConfig.DefaultValues[nameof(SConfig.AggressiveMemoryOptimizations)];
+ this.UsePintail = usePintail;
+ this.LogNetworkTraffic = logNetworkTraffic;
+ this.ConsoleColors = consoleColors;
+ this.SuppressUpdateChecks = suppressUpdateChecks ?? Array.Empty<string>();
+ }
+
+ /// <summary>Override the value of <see cref="DeveloperMode"/>.</summary>
+ /// <param name="value">The value to set.</param>
+ public void OverrideDeveloperMode(bool value)
+ {
+ this.DeveloperMode = value;
+ }
+
/// <summary>Get the settings which have been customized by the player.</summary>
- public IDictionary<string, object> GetCustomSettings()
+ public IDictionary<string, object?> GetCustomSettings()
{
- IDictionary<string, object> custom = new Dictionary<string, object>();
+ Dictionary<string, object?> custom = new();
- foreach (var pair in SConfig.DefaultValues)
+ foreach ((string? name, object defaultValue) in SConfig.DefaultValues)
{
- object value = typeof(SConfig).GetProperty(pair.Key)?.GetValue(this);
- if (!pair.Value.Equals(value))
- custom[pair.Key] = value;
+ object? value = typeof(SConfig).GetProperty(name)?.GetValue(this);
+ if (!defaultValue.Equals(value))
+ custom[name] = value;
}
- HashSet<string> curSuppressUpdateChecks = new HashSet<string>(this.SuppressUpdateChecks ?? new string[0], StringComparer.OrdinalIgnoreCase);
+ HashSet<string> curSuppressUpdateChecks = new(this.SuppressUpdateChecks, 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) + "]";
return custom;
}
diff --git a/src/SMAPI/Framework/Monitor.cs b/src/SMAPI/Framework/Monitor.cs
index ab76e7c0..6b53daff 100644
--- a/src/SMAPI/Framework/Monitor.cs
+++ b/src/SMAPI/Framework/Monitor.cs
@@ -92,7 +92,7 @@ namespace StardewModdingAPI.Framework
public void VerboseLog(string message)
{
if (this.IsVerbose)
- this.Log(message, LogLevel.Trace);
+ this.Log(message);
}
/// <summary>Write a newline to the console and log file.</summary>
diff --git a/src/SMAPI/Framework/Networking/ModMessageModel.cs b/src/SMAPI/Framework/Networking/ModMessageModel.cs
index 4f694f9c..01672714 100644
--- a/src/SMAPI/Framework/Networking/ModMessageModel.cs
+++ b/src/SMAPI/Framework/Networking/ModMessageModel.cs
@@ -1,4 +1,5 @@
using System.Linq;
+using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace StardewModdingAPI.Framework.Networking
@@ -13,41 +14,39 @@ namespace StardewModdingAPI.Framework.Networking
** Origin
****/
/// <summary>The unique ID of the player who broadcast the message.</summary>
- public long FromPlayerID { get; set; }
+ public long FromPlayerID { get; }
/// <summary>The unique ID of the mod which broadcast the message.</summary>
- public string FromModID { get; set; }
+ public string FromModID { get; }
/****
** Destination
****/
/// <summary>The players who should receive the message.</summary>
- public long[] ToPlayerIDs { get; set; }
+ public long[]? ToPlayerIDs { get; init; }
/// <summary>The mods which should receive the message, or <c>null</c> for all mods.</summary>
- public string[] ToModIDs { get; set; }
+ public string[]? ToModIDs { get; }
/// <summary>A message type which receiving mods can use to decide whether it's the one they want to handle, like <c>SetPlayerLocation</c>. This doesn't need to be globally unique, since mods should check the originating mod ID.</summary>
- public string Type { get; set; }
+ public string Type { get; }
/// <summary>The custom mod data being broadcast.</summary>
- public JToken Data { get; set; }
+ public JToken Data { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
- public ModMessageModel() { }
-
- /// <summary>Construct an instance.</summary>
/// <param name="fromPlayerID">The unique ID of the player who broadcast the message.</param>
/// <param name="fromModID">The unique ID of the mod which broadcast the message.</param>
/// <param name="toPlayerIDs">The players who should receive the message, or <c>null</c> for all players.</param>
/// <param name="toModIDs">The mods which should receive the message, or <c>null</c> for all mods.</param>
/// <param name="type">A message type which receiving mods can use to decide whether it's the one they want to handle, like <c>SetPlayerLocation</c>. This doesn't need to be globally unique, since mods should check the originating mod ID.</param>
/// <param name="data">The custom mod data being broadcast.</param>
- public ModMessageModel(long fromPlayerID, string fromModID, long[] toPlayerIDs, string[] toModIDs, string type, JToken data)
+ [JsonConstructor]
+ public ModMessageModel(long fromPlayerID, string fromModID, long[]? toPlayerIDs, string[]? toModIDs, string type, JToken data)
{
this.FromPlayerID = fromPlayerID;
this.FromModID = fromModID;
diff --git a/src/SMAPI/Framework/Networking/MultiplayerPeer.cs b/src/SMAPI/Framework/Networking/MultiplayerPeer.cs
index 3923700f..b37c1e89 100644
--- a/src/SMAPI/Framework/Networking/MultiplayerPeer.cs
+++ b/src/SMAPI/Framework/Networking/MultiplayerPeer.cs
@@ -37,10 +37,10 @@ namespace StardewModdingAPI.Framework.Networking
public GamePlatform? Platform { get; }
/// <inheritdoc />
- public ISemanticVersion GameVersion { get; }
+ public ISemanticVersion? GameVersion { get; }
/// <inheritdoc />
- public ISemanticVersion ApiVersion { get; }
+ public ISemanticVersion? ApiVersion { get; }
/// <inheritdoc />
public IEnumerable<IMultiplayerPeerMod> Mods { get; }
@@ -55,11 +55,12 @@ namespace StardewModdingAPI.Framework.Networking
/// <param name="model">The metadata to copy.</param>
/// <param name="sendMessage">A method which sends a message to the peer.</param>
/// <param name="isHost">Whether this is a connection to the host player.</param>
- public MultiplayerPeer(long playerID, int? screenID, RemoteContextModel model, Action<OutgoingMessage> sendMessage, bool isHost)
+ public MultiplayerPeer(long playerID, int? screenID, RemoteContextModel? model, Action<OutgoingMessage> sendMessage, bool isHost)
{
this.PlayerID = playerID;
this.ScreenID = screenID;
this.IsHost = isHost;
+
if (model != null)
{
this.Platform = model.Platform;
@@ -67,13 +68,16 @@ namespace StardewModdingAPI.Framework.Networking
this.ApiVersion = model.ApiVersion;
this.Mods = model.Mods.Select(mod => new MultiplayerPeerMod(mod)).ToArray();
}
+ else
+ this.Mods = Array.Empty<IMultiplayerPeerMod>();
+
this.SendMessageImpl = sendMessage;
}
/// <inheritdoc />
- public IMultiplayerPeerMod GetMod(string id)
+ public IMultiplayerPeerMod? GetMod(string? id)
{
- if (string.IsNullOrWhiteSpace(id) || this.Mods == null || !this.Mods.Any())
+ if (string.IsNullOrWhiteSpace(id) || !this.Mods.Any())
return null;
id = id.Trim();
diff --git a/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs b/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs
index 8087dc7e..1e150508 100644
--- a/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs
+++ b/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs
@@ -1,3 +1,5 @@
+using System.Diagnostics.CodeAnalysis;
+
namespace StardewModdingAPI.Framework.Networking
{
internal class MultiplayerPeerMod : IMultiplayerPeerMod
@@ -20,10 +22,11 @@ namespace StardewModdingAPI.Framework.Networking
*********/
/// <summary>Construct an instance.</summary>
/// <param name="mod">The mod metadata.</param>
+ [SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "The ID shouldn't be null, but we should handle it to avoid an error just in case.")]
public MultiplayerPeerMod(RemoteContextModModel mod)
{
this.Name = mod.Name;
- this.ID = mod.ID?.Trim();
+ this.ID = mod.ID?.Trim() ?? string.Empty;
this.Version = mod.Version;
}
}
diff --git a/src/SMAPI/Framework/Networking/RemoteContextModModel.cs b/src/SMAPI/Framework/Networking/RemoteContextModModel.cs
index 9795d971..7571acba 100644
--- a/src/SMAPI/Framework/Networking/RemoteContextModModel.cs
+++ b/src/SMAPI/Framework/Networking/RemoteContextModModel.cs
@@ -3,13 +3,31 @@ namespace StardewModdingAPI.Framework.Networking
/// <summary>Metadata about an installed mod exchanged with connected computers.</summary>
public class RemoteContextModModel
{
- /// <summary>The mod's display name.</summary>
- public string Name { get; set; }
-
+ /*********
+ ** Accessors
+ *********/
/// <summary>The unique mod ID.</summary>
- public string ID { get; set; }
+ public string ID { get; }
+
+ /// <summary>The mod's display name.</summary>
+ public string Name { get; }
/// <summary>The mod version.</summary>
- public ISemanticVersion Version { get; set; }
+ public ISemanticVersion Version { get; }
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="id">The unique mod ID.</param>
+ /// <param name="name">The mod's display name.</param>
+ /// <param name="version">The mod version.</param>
+ public RemoteContextModModel(string id, string name, ISemanticVersion version)
+ {
+ this.ID = id;
+ this.Name = name;
+ this.Version = version;
+ }
}
}
diff --git a/src/SMAPI/Framework/Networking/RemoteContextModel.cs b/src/SMAPI/Framework/Networking/RemoteContextModel.cs
index 7befb151..7d53e732 100644
--- a/src/SMAPI/Framework/Networking/RemoteContextModel.cs
+++ b/src/SMAPI/Framework/Networking/RemoteContextModel.cs
@@ -1,3 +1,5 @@
+using System;
+
namespace StardewModdingAPI.Framework.Networking
{
/// <summary>Metadata about the game, SMAPI, and installed mods exchanged with connected computers.</summary>
@@ -7,18 +9,37 @@ namespace StardewModdingAPI.Framework.Networking
** Accessors
*********/
/// <summary>Whether this player is the host player.</summary>
- public bool IsHost { get; set; }
+ public bool IsHost { get; }
- /// <summary>The game's platform version.</summary>
- public GamePlatform Platform { get; set; }
+ /// <summary>The game's platform.</summary>
+ public GamePlatform Platform { get; }
/// <summary>The installed version of Stardew Valley.</summary>
- public ISemanticVersion GameVersion { get; set; }
+ public ISemanticVersion? GameVersion { get; }
/// <summary>The installed version of SMAPI.</summary>
- public ISemanticVersion ApiVersion { get; set; }
+ public ISemanticVersion? ApiVersion { get; }
/// <summary>The installed mods.</summary>
- public RemoteContextModModel[] Mods { get; set; }
+ public RemoteContextModModel[] Mods { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="isHost">Whether this player is the host player.</param>
+ /// <param name="platform">The game's platform.</param>
+ /// <param name="gameVersion">The installed version of Stardew Valley.</param>
+ /// <param name="apiVersion">The installed version of SMAPI.</param>
+ /// <param name="mods">The installed mods.</param>
+ public RemoteContextModel(bool isHost, GamePlatform platform, ISemanticVersion gameVersion, ISemanticVersion apiVersion, RemoteContextModModel[]? mods)
+ {
+ this.IsHost = isHost;
+ this.Platform = platform;
+ this.GameVersion = gameVersion;
+ this.ApiVersion = apiVersion;
+ this.Mods = mods ?? Array.Empty<RemoteContextModModel>();
+ }
}
}
diff --git a/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs b/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs
index ac9cf313..71e11576 100644
--- a/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs
+++ b/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs
@@ -45,8 +45,8 @@ namespace StardewModdingAPI.Framework.Networking
[SuppressMessage("ReSharper", "AccessToDisposedClosure", Justification = "The callback is invoked synchronously.")]
protected override void onReceiveMessage(GalaxyID peer, Stream messageStream)
{
- using IncomingMessage message = new IncomingMessage();
- using BinaryReader reader = new BinaryReader(messageStream);
+ using IncomingMessage message = new();
+ using BinaryReader reader = new(messageStream);
message.Read(reader);
ulong peerID = peer.ToUint64(); // note: GalaxyID instances get reused, so need to store the underlying ID instead
@@ -57,7 +57,7 @@ namespace StardewModdingAPI.Framework.Networking
else if (message.MessageType == StardewValley.Multiplayer.playerIntroduction)
{
NetFarmerRoot farmer = this.Multiplayer.readFarmer(message.Reader);
- GalaxyID capturedPeer = new GalaxyID(peerID);
+ GalaxyID capturedPeer = new(peerID);
this.gameServer.checkFarmhandRequest(Convert.ToString(peerID), this.getConnectionId(peer), farmer, msg => this.sendMessage(capturedPeer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = capturedPeer.ToUint64());
}
});
diff --git a/src/SMAPI/Framework/Networking/SLidgrenServer.cs b/src/SMAPI/Framework/Networking/SLidgrenServer.cs
index 05c8b872..ff871e64 100644
--- a/src/SMAPI/Framework/Networking/SLidgrenServer.cs
+++ b/src/SMAPI/Framework/Networking/SLidgrenServer.cs
@@ -44,9 +44,9 @@ namespace StardewModdingAPI.Framework.Networking
{
// add hook to call multiplayer core
NetConnection peer = rawMessage.SenderConnection;
- using IncomingMessage message = new IncomingMessage();
+ using IncomingMessage message = new();
using Stream readStream = new NetBufferReadStream(rawMessage);
- using BinaryReader reader = new BinaryReader(readStream);
+ using BinaryReader reader = new(readStream);
while (rawMessage.LengthBits - rawMessage.Position >= 8)
{
diff --git a/src/SMAPI/Framework/Reflection/CacheEntry.cs b/src/SMAPI/Framework/Reflection/CacheEntry.cs
index 912662e3..27f48a1f 100644
--- a/src/SMAPI/Framework/Reflection/CacheEntry.cs
+++ b/src/SMAPI/Framework/Reflection/CacheEntry.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics.CodeAnalysis;
using System.Reflection;
namespace StardewModdingAPI.Framework.Reflection
@@ -9,21 +10,20 @@ namespace StardewModdingAPI.Framework.Reflection
** Accessors
*********/
/// <summary>Whether the lookup found a valid match.</summary>
- public bool IsValid { get; }
+ [MemberNotNullWhen(true, nameof(CacheEntry.MemberInfo))]
+ public bool IsValid => this.MemberInfo != null;
/// <summary>The reflection data for this member (or <c>null</c> if invalid).</summary>
- public MemberInfo MemberInfo { get; }
+ public MemberInfo? MemberInfo { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
- /// <param name="isValid">Whether the lookup found a valid match.</param>
/// <param name="memberInfo">The reflection data for this member (or <c>null</c> if invalid).</param>
- public CacheEntry(bool isValid, MemberInfo memberInfo)
+ public CacheEntry(MemberInfo? memberInfo)
{
- this.IsValid = isValid;
this.MemberInfo = memberInfo;
}
}
diff --git a/src/SMAPI/Framework/Reflection/IInterfaceProxyFactory.cs b/src/SMAPI/Framework/Reflection/IInterfaceProxyFactory.cs
new file mode 100644
index 00000000..6429db58
--- /dev/null
+++ b/src/SMAPI/Framework/Reflection/IInterfaceProxyFactory.cs
@@ -0,0 +1,17 @@
+namespace StardewModdingAPI.Framework.Reflection
+{
+ /// <summary>Generates proxy classes to access mod APIs through an arbitrary interface.</summary>
+ internal interface IInterfaceProxyFactory
+ {
+ /*********
+ ** Methods
+ *********/
+ /// <summary>Create an API proxy.</summary>
+ /// <typeparam name="TInterface">The interface through which to access the API.</typeparam>
+ /// <param name="instance">The API instance to access.</param>
+ /// <param name="sourceModID">The unique ID of the mod consuming the API.</param>
+ /// <param name="targetModID">The unique ID of the mod providing the API.</param>
+ TInterface CreateProxy<TInterface>(object instance, string sourceModID, string targetModID)
+ where TInterface : class;
+ }
+}
diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs
index 5acba569..694c563d 100644
--- a/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs
+++ b/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs
@@ -1,21 +1,17 @@
-using System;
-using System.Collections.Generic;
using System.Reflection;
using System.Reflection.Emit;
+using Nanoray.Pintail;
namespace StardewModdingAPI.Framework.Reflection
{
- /// <summary>Generates proxy classes to access mod APIs through an arbitrary interface.</summary>
- internal class InterfaceProxyFactory
+ /// <inheritdoc />
+ internal class InterfaceProxyFactory : IInterfaceProxyFactory
{
/*********
** Fields
*********/
- /// <summary>The CLR module in which to create proxy classes.</summary>
- private readonly ModuleBuilder ModuleBuilder;
-
- /// <summary>The generated proxy types.</summary>
- private readonly IDictionary<string, InterfaceProxyBuilder> Builders = new Dictionary<string, InterfaceProxyBuilder>();
+ /// <summary>The underlying proxy type builder.</summary>
+ private readonly IProxyManager<string> ProxyManager;
/*********
@@ -25,37 +21,18 @@ namespace StardewModdingAPI.Framework.Reflection
public InterfaceProxyFactory()
{
AssemblyBuilder assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName($"StardewModdingAPI.Proxies, Version={this.GetType().Assembly.GetName().Version}, Culture=neutral"), AssemblyBuilderAccess.Run);
- this.ModuleBuilder = assemblyBuilder.DefineDynamicModule("StardewModdingAPI.Proxies");
+ ModuleBuilder moduleBuilder = assemblyBuilder.DefineDynamicModule("StardewModdingAPI.Proxies");
+ this.ProxyManager = new ProxyManager<string>(moduleBuilder, new ProxyManagerConfiguration<string>(
+ proxyPrepareBehavior: ProxyManagerProxyPrepareBehavior.Eager,
+ proxyObjectInterfaceMarking: ProxyObjectInterfaceMarking.Disabled
+ ));
}
- /// <summary>Create an API proxy.</summary>
- /// <typeparam name="TInterface">The interface through which to access the API.</typeparam>
- /// <param name="instance">The API instance to access.</param>
- /// <param name="sourceModID">The unique ID of the mod consuming the API.</param>
- /// <param name="targetModID">The unique ID of the mod providing the API.</param>
+ /// <inheritdoc />
public TInterface CreateProxy<TInterface>(object instance, string sourceModID, string targetModID)
where TInterface : class
{
- lock (this.Builders)
- {
- // validate
- if (instance == null)
- throw new InvalidOperationException("Can't proxy access to a null API.");
- if (!typeof(TInterface).IsInterface)
- throw new InvalidOperationException("The proxy type must be an interface, not a class.");
-
- // get proxy type
- Type targetType = instance.GetType();
- string proxyTypeName = $"StardewModdingAPI.Proxies.From<{sourceModID}_{typeof(TInterface).FullName}>_To<{targetModID}_{targetType.FullName}>";
- if (!this.Builders.TryGetValue(proxyTypeName, out InterfaceProxyBuilder builder))
- {
- builder = new InterfaceProxyBuilder(proxyTypeName, this.ModuleBuilder, typeof(TInterface), targetType);
- this.Builders[proxyTypeName] = builder;
- }
-
- // create instance
- return (TInterface)builder.CreateInstance(instance);
- }
+ return this.ProxyManager.ObtainProxy<string, TInterface>(instance, targetContext: targetModID, proxyContext: sourceModID);
}
}
}
diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs b/src/SMAPI/Framework/Reflection/OriginalInterfaceProxyBuilder.cs
index 70ef81f8..9576f768 100644
--- a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs
+++ b/src/SMAPI/Framework/Reflection/OriginalInterfaceProxyBuilder.cs
@@ -6,7 +6,7 @@ using System.Reflection.Emit;
namespace StardewModdingAPI.Framework.Reflection
{
/// <summary>Generates a proxy class to access a mod API through an arbitrary interface.</summary>
- internal class InterfaceProxyBuilder
+ internal class OriginalInterfaceProxyBuilder
{
/*********
** Fields
@@ -26,7 +26,7 @@ namespace StardewModdingAPI.Framework.Reflection
/// <param name="moduleBuilder">The CLR module in which to create proxy classes.</param>
/// <param name="interfaceType">The interface type to implement.</param>
/// <param name="targetType">The target type.</param>
- public InterfaceProxyBuilder(string name, ModuleBuilder moduleBuilder, Type interfaceType, Type targetType)
+ public OriginalInterfaceProxyBuilder(string name, ModuleBuilder moduleBuilder, Type interfaceType, Type targetType)
{
// validate
if (name == null)
@@ -48,7 +48,7 @@ namespace StardewModdingAPI.Framework.Reflection
il.Emit(OpCodes.Ldarg_0); // this
// ReSharper disable once AssignNullToNotNullAttribute -- never null
- il.Emit(OpCodes.Call, typeof(object).GetConstructor(new Type[0])); // call base constructor
+ il.Emit(OpCodes.Call, typeof(object).GetConstructor(Type.EmptyTypes)!); // call base constructor
il.Emit(OpCodes.Ldarg_0); // this
il.Emit(OpCodes.Ldarg_1); // load argument
il.Emit(OpCodes.Stfld, targetField); // set field to loaded argument
@@ -67,14 +67,14 @@ namespace StardewModdingAPI.Framework.Reflection
// save info
this.TargetType = targetType;
- this.ProxyType = proxyBuilder.CreateType();
+ this.ProxyType = proxyBuilder.CreateType()!;
}
/// <summary>Create an instance of the proxy for a target instance.</summary>
/// <param name="targetInstance">The target instance.</param>
public object CreateInstance(object targetInstance)
{
- ConstructorInfo constructor = this.ProxyType.GetConstructor(new[] { this.TargetType });
+ ConstructorInfo? constructor = this.ProxyType.GetConstructor(new[] { this.TargetType });
if (constructor == null)
throw new InvalidOperationException($"Couldn't find the constructor for generated proxy type '{this.ProxyType.Name}'."); // should never happen
return constructor.Invoke(new[] { targetInstance });
diff --git a/src/SMAPI/Framework/Reflection/OriginalInterfaceProxyFactory.cs b/src/SMAPI/Framework/Reflection/OriginalInterfaceProxyFactory.cs
new file mode 100644
index 00000000..d6966978
--- /dev/null
+++ b/src/SMAPI/Framework/Reflection/OriginalInterfaceProxyFactory.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using System.Reflection.Emit;
+
+namespace StardewModdingAPI.Framework.Reflection
+{
+ /// <inheritdoc />
+ internal class OriginalInterfaceProxyFactory : IInterfaceProxyFactory
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The CLR module in which to create proxy classes.</summary>
+ private readonly ModuleBuilder ModuleBuilder;
+
+ /// <summary>The generated proxy types.</summary>
+ private readonly IDictionary<string, OriginalInterfaceProxyBuilder> Builders = new Dictionary<string, OriginalInterfaceProxyBuilder>();
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ public OriginalInterfaceProxyFactory()
+ {
+ AssemblyBuilder assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName($"StardewModdingAPI.Proxies, Version={this.GetType().Assembly.GetName().Version}, Culture=neutral"), AssemblyBuilderAccess.Run);
+ this.ModuleBuilder = assemblyBuilder.DefineDynamicModule("StardewModdingAPI.Proxies");
+ }
+
+ /// <inheritdoc />
+ public TInterface CreateProxy<TInterface>(object instance, string sourceModID, string targetModID)
+ where TInterface : class
+ {
+ lock (this.Builders)
+ {
+ // validate
+ if (instance == null)
+ throw new InvalidOperationException("Can't proxy access to a null API.");
+ if (!typeof(TInterface).IsInterface)
+ throw new InvalidOperationException("The proxy type must be an interface, not a class.");
+
+ // get proxy type
+ Type targetType = instance.GetType();
+ string proxyTypeName = $"StardewModdingAPI.Proxies.From<{sourceModID}_{typeof(TInterface).FullName}>_To<{targetModID}_{targetType.FullName}>";
+ if (!this.Builders.TryGetValue(proxyTypeName, out OriginalInterfaceProxyBuilder? builder))
+ {
+ builder = new OriginalInterfaceProxyBuilder(proxyTypeName, this.ModuleBuilder, typeof(TInterface), targetType);
+ this.Builders[proxyTypeName] = builder;
+ }
+
+ // create instance
+ return (TInterface)builder.CreateInstance(instance);
+ }
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Reflection/ReflectedField.cs b/src/SMAPI/Framework/Reflection/ReflectedField.cs
index 3c4da4fc..a97ca3f0 100644
--- a/src/SMAPI/Framework/Reflection/ReflectedField.cs
+++ b/src/SMAPI/Framework/Reflection/ReflectedField.cs
@@ -13,8 +13,8 @@ namespace StardewModdingAPI.Framework.Reflection
/// <summary>The type that has the field.</summary>
private readonly Type ParentType;
- /// <summary>The object that has the instance field (if applicable).</summary>
- private readonly object Parent;
+ /// <summary>The object that has the instance field, or <c>null</c> for a static field.</summary>
+ private readonly object? Parent;
/// <summary>The display name shown in error messages.</summary>
private string DisplayName => $"{this.ParentType.FullName}::{this.FieldInfo.Name}";
@@ -32,12 +32,12 @@ namespace StardewModdingAPI.Framework.Reflection
*********/
/// <summary>Construct an instance.</summary>
/// <param name="parentType">The type that has the field.</param>
- /// <param name="obj">The object that has the instance field (if applicable).</param>
+ /// <param name="obj">The object that has the instance field, or <c>null</c> for a static field.</param>
/// <param name="field">The reflection metadata.</param>
/// <param name="isStatic">Whether the field is static.</param>
/// <exception cref="ArgumentNullException">The <paramref name="parentType"/> or <paramref name="field"/> is null.</exception>
/// <exception cref="ArgumentException">The <paramref name="obj"/> is null for a non-static field, or not null for a static field.</exception>
- public ReflectedField(Type parentType, object obj, FieldInfo field, bool isStatic)
+ public ReflectedField(Type parentType, object? obj, FieldInfo field, bool isStatic)
{
// validate
if (parentType == null)
@@ -60,7 +60,7 @@ namespace StardewModdingAPI.Framework.Reflection
{
try
{
- return (TValue)this.FieldInfo.GetValue(this.Parent);
+ return (TValue)this.FieldInfo.GetValue(this.Parent)!;
}
catch (InvalidCastException)
{
diff --git a/src/SMAPI/Framework/Reflection/ReflectedMethod.cs b/src/SMAPI/Framework/Reflection/ReflectedMethod.cs
index 26112806..a607141e 100644
--- a/src/SMAPI/Framework/Reflection/ReflectedMethod.cs
+++ b/src/SMAPI/Framework/Reflection/ReflectedMethod.cs
@@ -12,8 +12,8 @@ namespace StardewModdingAPI.Framework.Reflection
/// <summary>The type that has the method.</summary>
private readonly Type ParentType;
- /// <summary>The object that has the instance method (if applicable).</summary>
- private readonly object Parent;
+ /// <summary>The object that has the instance method, or <c>null</c> for a static method.</summary>
+ private readonly object? Parent;
/// <summary>The display name shown in error messages.</summary>
private string DisplayName => $"{this.ParentType.FullName}::{this.MethodInfo.Name}";
@@ -31,12 +31,12 @@ namespace StardewModdingAPI.Framework.Reflection
*********/
/// <summary>Construct an instance.</summary>
/// <param name="parentType">The type that has the method.</param>
- /// <param name="obj">The object that has the instance method(if applicable).</param>
+ /// <param name="obj">The object that has the instance method, or <c>null</c> for a static method.</param>
/// <param name="method">The reflection metadata.</param>
/// <param name="isStatic">Whether the method is static.</param>
/// <exception cref="ArgumentNullException">The <paramref name="parentType"/> or <paramref name="method"/> is null.</exception>
/// <exception cref="ArgumentException">The <paramref name="obj"/> is null for a non-static method, or not null for a static method.</exception>
- public ReflectedMethod(Type parentType, object obj, MethodInfo method, bool isStatic)
+ public ReflectedMethod(Type parentType, object? obj, MethodInfo method, bool isStatic)
{
// validate
if (parentType == null)
@@ -55,10 +55,10 @@ namespace StardewModdingAPI.Framework.Reflection
}
/// <inheritdoc />
- public TValue Invoke<TValue>(params object[] arguments)
+ public TValue Invoke<TValue>(params object?[] arguments)
{
// invoke method
- object result;
+ object? result;
try
{
result = this.MethodInfo.Invoke(this.Parent, arguments);
@@ -75,7 +75,7 @@ namespace StardewModdingAPI.Framework.Reflection
// cast return value
try
{
- return (TValue)result;
+ return (TValue)result!;
}
catch (InvalidCastException)
{
@@ -84,7 +84,7 @@ namespace StardewModdingAPI.Framework.Reflection
}
/// <inheritdoc />
- public void Invoke(params object[] arguments)
+ public void Invoke(params object?[] arguments)
{
// invoke method
try
diff --git a/src/SMAPI/Framework/Reflection/ReflectedProperty.cs b/src/SMAPI/Framework/Reflection/ReflectedProperty.cs
index 42d7bb59..72e701d1 100644
--- a/src/SMAPI/Framework/Reflection/ReflectedProperty.cs
+++ b/src/SMAPI/Framework/Reflection/ReflectedProperty.cs
@@ -14,10 +14,10 @@ namespace StardewModdingAPI.Framework.Reflection
private readonly string DisplayName;
/// <summary>The underlying property getter.</summary>
- private readonly Func<TValue> GetMethod;
+ private readonly Func<TValue>? GetMethod;
/// <summary>The underlying property setter.</summary>
- private readonly Action<TValue> SetMethod;
+ private readonly Action<TValue>? SetMethod;
/*********
@@ -32,12 +32,12 @@ namespace StardewModdingAPI.Framework.Reflection
*********/
/// <summary>Construct an instance.</summary>
/// <param name="parentType">The type that has the property.</param>
- /// <param name="obj">The object that has the instance property (if applicable).</param>
+ /// <param name="obj">The object that has the instance property, or <c>null</c> for a static property.</param>
/// <param name="property">The reflection metadata.</param>
/// <param name="isStatic">Whether the property is static.</param>
/// <exception cref="ArgumentNullException">The <paramref name="parentType"/> or <paramref name="property"/> is null.</exception>
/// <exception cref="ArgumentException">The <paramref name="obj"/> is null for a non-static property, or not null for a static property.</exception>
- public ReflectedProperty(Type parentType, object obj, PropertyInfo property, bool isStatic)
+ public ReflectedProperty(Type parentType, object? obj, PropertyInfo property, bool isStatic)
{
// validate input
if (parentType == null)
diff --git a/src/SMAPI/Framework/Reflection/Reflector.cs b/src/SMAPI/Framework/Reflection/Reflector.cs
index 889c7ed6..79575c26 100644
--- a/src/SMAPI/Framework/Reflection/Reflector.cs
+++ b/src/SMAPI/Framework/Reflection/Reflector.cs
@@ -1,5 +1,4 @@
using System;
-using System.Linq;
using System.Reflection;
using System.Runtime.Caching;
@@ -13,7 +12,7 @@ namespace StardewModdingAPI.Framework.Reflection
** Fields
*********/
/// <summary>The cached fields and methods found via reflection.</summary>
- private readonly MemoryCache Cache = new MemoryCache(typeof(Reflector).FullName);
+ private readonly MemoryCache Cache = new(typeof(Reflector).FullName!);
/// <summary>The sliding cache expiration time.</summary>
private readonly TimeSpan SlidingCacheExpiry = TimeSpan.FromMinutes(5);
@@ -29,8 +28,9 @@ namespace StardewModdingAPI.Framework.Reflection
/// <typeparam name="TValue">The field type.</typeparam>
/// <param name="obj">The object which has the field.</param>
/// <param name="name">The field name.</param>
- /// <param name="required">Whether to throw an exception if the field is not found.</param>
- /// <returns>Returns the field wrapper, or <c>null</c> if the field doesn't exist and <paramref name="required"/> is <c>false</c>.</returns>
+ /// <param name="required">Whether to throw an exception if the field isn't found. <strong>Due to limitations with nullable reference types, setting this to <c>false</c> will still mark the value non-nullable.</strong></param>
+ /// <returns>Returns the field wrapper, or <c>null</c> if <paramref name="required"/> is <c>false</c> and the field doesn't exist.</returns>
+ /// <exception cref="InvalidOperationException">The target field doesn't exist, and <paramref name="required"/> is true.</exception>
public IReflectedField<TValue> GetField<TValue>(object obj, string name, bool required = true)
{
// validate
@@ -38,24 +38,26 @@ namespace StardewModdingAPI.Framework.Reflection
throw new ArgumentNullException(nameof(obj), "Can't get a instance field from a null object.");
// get field from hierarchy
- IReflectedField<TValue> field = this.GetFieldFromHierarchy<TValue>(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
+ IReflectedField<TValue>? field = this.GetFieldFromHierarchy<TValue>(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
if (required && field == null)
throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a '{name}' instance field.");
- return field;
+ return field!;
}
/// <summary>Get a static field.</summary>
/// <typeparam name="TValue">The field type.</typeparam>
/// <param name="type">The type which has the field.</param>
/// <param name="name">The field name.</param>
- /// <param name="required">Whether to throw an exception if the field is not found.</param>
+ /// <param name="required">Whether to throw an exception if the field isn't found. <strong>Due to limitations with nullable reference types, setting this to <c>false</c> will still mark the value non-nullable.</strong></param>
+ /// <returns>Returns the field wrapper, or <c>null</c> if <paramref name="required"/> is <c>false</c> and the field doesn't exist.</returns>
+ /// <exception cref="InvalidOperationException">The target field doesn't exist, and <paramref name="required"/> is true.</exception>
public IReflectedField<TValue> GetField<TValue>(Type type, string name, bool required = true)
{
// get field from hierarchy
- IReflectedField<TValue> field = this.GetFieldFromHierarchy<TValue>(type, null, name, BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public);
+ IReflectedField<TValue>? field = this.GetFieldFromHierarchy<TValue>(type, null, name, BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public);
if (required && field == null)
throw new InvalidOperationException($"The {type.FullName} object doesn't have a '{name}' static field.");
- return field;
+ return field!;
}
/****
@@ -65,7 +67,9 @@ namespace StardewModdingAPI.Framework.Reflection
/// <typeparam name="TValue">The property type.</typeparam>
/// <param name="obj">The object which has the property.</param>
/// <param name="name">The property name.</param>
- /// <param name="required">Whether to throw an exception if the property is not found.</param>
+ /// <param name="required">Whether to throw an exception if the property isn't found. <strong>Due to limitations with nullable reference types, setting this to <c>false</c> will still mark the value non-nullable.</strong></param>
+ /// <returns>Returns the property wrapper, or <c>null</c> if <paramref name="required"/> is <c>false</c> and the property doesn't exist.</returns>
+ /// <exception cref="InvalidOperationException">The target property doesn't exist, and <paramref name="required"/> is true.</exception>
public IReflectedProperty<TValue> GetProperty<TValue>(object obj, string name, bool required = true)
{
// validate
@@ -73,24 +77,26 @@ namespace StardewModdingAPI.Framework.Reflection
throw new ArgumentNullException(nameof(obj), "Can't get a instance property from a null object.");
// get property from hierarchy
- IReflectedProperty<TValue> property = this.GetPropertyFromHierarchy<TValue>(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
+ IReflectedProperty<TValue>? property = this.GetPropertyFromHierarchy<TValue>(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
if (required && property == null)
throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a '{name}' instance property.");
- return property;
+ return property!;
}
/// <summary>Get a static property.</summary>
/// <typeparam name="TValue">The property type.</typeparam>
/// <param name="type">The type which has the property.</param>
/// <param name="name">The property name.</param>
- /// <param name="required">Whether to throw an exception if the property is not found.</param>
+ /// <param name="required">Whether to throw an exception if the property isn't found. <strong>Due to limitations with nullable reference types, setting this to <c>false</c> will still mark the value non-nullable.</strong></param>
+ /// <returns>Returns the property wrapper, or <c>null</c> if <paramref name="required"/> is <c>false</c> and the property doesn't exist.</returns>
+ /// <exception cref="InvalidOperationException">The target property doesn't exist, and <paramref name="required"/> is true.</exception>
public IReflectedProperty<TValue> GetProperty<TValue>(Type type, string name, bool required = true)
{
// get field from hierarchy
- IReflectedProperty<TValue> property = this.GetPropertyFromHierarchy<TValue>(type, null, name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static);
+ IReflectedProperty<TValue>? property = this.GetPropertyFromHierarchy<TValue>(type, null, name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static);
if (required && property == null)
throw new InvalidOperationException($"The {type.FullName} object doesn't have a '{name}' static property.");
- return property;
+ return property!;
}
/****
@@ -98,8 +104,10 @@ namespace StardewModdingAPI.Framework.Reflection
****/
/// <summary>Get a instance method.</summary>
/// <param name="obj">The object which has the method.</param>
- /// <param name="name">The field name.</param>
- /// <param name="required">Whether to throw an exception if the field is not found.</param>
+ /// <param name="name">The method name.</param>
+ /// <param name="required">Whether to throw an exception if the method isn't found. <strong>Due to limitations with nullable reference types, setting this to <c>false</c> will still mark the value non-nullable.</strong></param>
+ /// <returns>Returns the method wrapper, or <c>null</c> if <paramref name="required"/> is <c>false</c> and the method doesn't exist.</returns>
+ /// <exception cref="InvalidOperationException">The target method doesn't exist, and <paramref name="required"/> is true.</exception>
public IReflectedMethod GetMethod(object obj, string name, bool required = true)
{
// validate
@@ -107,58 +115,25 @@ namespace StardewModdingAPI.Framework.Reflection
throw new ArgumentNullException(nameof(obj), "Can't get a instance method from a null object.");
// get method from hierarchy
- IReflectedMethod method = this.GetMethodFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
+ IReflectedMethod? method = this.GetMethodFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
if (required && method == null)
throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a '{name}' instance method.");
- return method;
+ return method!;
}
/// <summary>Get a static method.</summary>
/// <param name="type">The type which has the method.</param>
- /// <param name="name">The field name.</param>
- /// <param name="required">Whether to throw an exception if the field is not found.</param>
+ /// <param name="name">The method name.</param>
+ /// <param name="required">Whether to throw an exception if the method isn't found. <strong>Due to limitations with nullable reference types, setting this to <c>false</c> will still mark the value non-nullable.</strong></param>
+ /// <returns>Returns the method wrapper, or <c>null</c> if <paramref name="required"/> is <c>false</c> and the method doesn't exist.</returns>
+ /// <exception cref="InvalidOperationException">The target method doesn't exist, and <paramref name="required"/> is true.</exception>
public IReflectedMethod GetMethod(Type type, string name, bool required = true)
{
// get method from hierarchy
- IReflectedMethod method = this.GetMethodFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static);
+ IReflectedMethod? method = this.GetMethodFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static);
if (required && method == null)
throw new InvalidOperationException($"The {type.FullName} object doesn't have a '{name}' static method.");
- return method;
- }
-
- /****
- ** Methods by signature
- ****/
- /// <summary>Get a instance method.</summary>
- /// <param name="obj">The object which has the method.</param>
- /// <param name="name">The field name.</param>
- /// <param name="argumentTypes">The argument types of the method signature to find.</param>
- /// <param name="required">Whether to throw an exception if the field is not found.</param>
- public IReflectedMethod GetMethod(object obj, string name, Type[] argumentTypes, bool required = true)
- {
- // validate parent
- if (obj == null)
- throw new ArgumentNullException(nameof(obj), "Can't get a instance method from a null object.");
-
- // get method from hierarchy
- ReflectedMethod method = this.GetMethodFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, argumentTypes);
- if (required && method == null)
- throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a '{name}' instance method with that signature.");
- return method;
- }
-
- /// <summary>Get a static method.</summary>
- /// <param name="type">The type which has the method.</param>
- /// <param name="name">The field name.</param>
- /// <param name="argumentTypes">The argument types of the method signature to find.</param>
- /// <param name="required">Whether to throw an exception if the field is not found.</param>
- public IReflectedMethod GetMethod(Type type, string name, Type[] argumentTypes, bool required = true)
- {
- // get field from hierarchy
- ReflectedMethod method = this.GetMethodFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, argumentTypes);
- if (required && method == null)
- throw new InvalidOperationException($"The {type.FullName} object doesn't have a '{name}' static method with that signature.");
- return method;
+ return method!;
}
@@ -168,18 +143,25 @@ namespace StardewModdingAPI.Framework.Reflection
/// <summary>Get a field from the type hierarchy.</summary>
/// <typeparam name="TValue">The expected field type.</typeparam>
/// <param name="type">The type which has the field.</param>
- /// <param name="obj">The object which has the field.</param>
+ /// <param name="obj">The object which has the field, or <c>null</c> for a static field.</param>
/// <param name="name">The field name.</param>
/// <param name="bindingFlags">The reflection binding which flags which indicates what type of field to find.</param>
- private IReflectedField<TValue> GetFieldFromHierarchy<TValue>(Type type, object obj, string name, BindingFlags bindingFlags)
+ private IReflectedField<TValue>? GetFieldFromHierarchy<TValue>(Type type, object? obj, string name, BindingFlags bindingFlags)
{
bool isStatic = bindingFlags.HasFlag(BindingFlags.Static);
- FieldInfo field = this.GetCached<FieldInfo>($"field::{isStatic}::{type.FullName}::{name}", () =>
+ FieldInfo? field = this.GetCached($"field::{isStatic}::{type.FullName}::{name}", () =>
{
- FieldInfo fieldInfo = null;
- for (; type != null && fieldInfo == null; type = type.BaseType)
- fieldInfo = type.GetField(name, bindingFlags);
- return fieldInfo;
+ for (Type? curType = type; curType != null; curType = curType.BaseType)
+ {
+ FieldInfo? fieldInfo = curType.GetField(name, bindingFlags);
+ if (fieldInfo != null)
+ {
+ type = curType;
+ return fieldInfo;
+ }
+ }
+
+ return null;
});
return field != null
@@ -190,18 +172,25 @@ namespace StardewModdingAPI.Framework.Reflection
/// <summary>Get a property from the type hierarchy.</summary>
/// <typeparam name="TValue">The expected property type.</typeparam>
/// <param name="type">The type which has the property.</param>
- /// <param name="obj">The object which has the property.</param>
+ /// <param name="obj">The object which has the property, or <c>null</c> for a static property.</param>
/// <param name="name">The property name.</param>
/// <param name="bindingFlags">The reflection binding which flags which indicates what type of property to find.</param>
- private IReflectedProperty<TValue> GetPropertyFromHierarchy<TValue>(Type type, object obj, string name, BindingFlags bindingFlags)
+ private IReflectedProperty<TValue>? GetPropertyFromHierarchy<TValue>(Type type, object? obj, string name, BindingFlags bindingFlags)
{
bool isStatic = bindingFlags.HasFlag(BindingFlags.Static);
- PropertyInfo property = this.GetCached<PropertyInfo>($"property::{isStatic}::{type.FullName}::{name}", () =>
+ PropertyInfo? property = this.GetCached<PropertyInfo>($"property::{isStatic}::{type.FullName}::{name}", () =>
{
- PropertyInfo propertyInfo = null;
- for (; type != null && propertyInfo == null; type = type.BaseType)
- propertyInfo = type.GetProperty(name, bindingFlags);
- return propertyInfo;
+ for (Type? curType = type; curType != null; curType = curType.BaseType)
+ {
+ PropertyInfo? propertyInfo = curType.GetProperty(name, bindingFlags);
+ if (propertyInfo != null)
+ {
+ type = curType;
+ return propertyInfo;
+ }
+ }
+
+ return null;
});
return property != null
@@ -211,18 +200,25 @@ namespace StardewModdingAPI.Framework.Reflection
/// <summary>Get a method from the type hierarchy.</summary>
/// <param name="type">The type which has the method.</param>
- /// <param name="obj">The object which has the method.</param>
+ /// <param name="obj">The object which has the method, or <c>null</c> for a static method.</param>
/// <param name="name">The method name.</param>
/// <param name="bindingFlags">The reflection binding which flags which indicates what type of method to find.</param>
- private IReflectedMethod GetMethodFromHierarchy(Type type, object obj, string name, BindingFlags bindingFlags)
+ private IReflectedMethod? GetMethodFromHierarchy(Type type, object? obj, string name, BindingFlags bindingFlags)
{
bool isStatic = bindingFlags.HasFlag(BindingFlags.Static);
- MethodInfo method = this.GetCached($"method::{isStatic}::{type.FullName}::{name}", () =>
+ MethodInfo? method = this.GetCached($"method::{isStatic}::{type.FullName}::{name}", () =>
{
- MethodInfo methodInfo = null;
- for (; type != null && methodInfo == null; type = type.BaseType)
- methodInfo = type.GetMethod(name, bindingFlags);
- return methodInfo;
+ for (Type? curType = type; curType != null; curType = curType.BaseType)
+ {
+ MethodInfo? methodInfo = curType.GetMethod(name, bindingFlags);
+ if (methodInfo != null)
+ {
+ type = curType;
+ return methodInfo;
+ }
+ }
+
+ return null;
});
return method != null
@@ -230,32 +226,12 @@ namespace StardewModdingAPI.Framework.Reflection
: null;
}
- /// <summary>Get a method from the type hierarchy.</summary>
- /// <param name="type">The type which has the method.</param>
- /// <param name="obj">The object which has the method.</param>
- /// <param name="name">The method name.</param>
- /// <param name="bindingFlags">The reflection binding which flags which indicates what type of method to find.</param>
- /// <param name="argumentTypes">The argument types of the method signature to find.</param>
- private ReflectedMethod GetMethodFromHierarchy(Type type, object obj, string name, BindingFlags bindingFlags, Type[] argumentTypes)
- {
- bool isStatic = bindingFlags.HasFlag(BindingFlags.Static);
- MethodInfo method = this.GetCached($"method::{isStatic}::{type.FullName}::{name}({string.Join(",", argumentTypes.Select(p => p.FullName))})", () =>
- {
- MethodInfo methodInfo = null;
- for (; type != null && methodInfo == null; type = type.BaseType)
- methodInfo = type.GetMethod(name, bindingFlags, null, argumentTypes, null);
- return methodInfo;
- });
- return method != null
- ? new ReflectedMethod(type, obj, method, isStatic)
- : null;
- }
-
/// <summary>Get a method or field through the cache.</summary>
/// <typeparam name="TMemberInfo">The expected <see cref="MemberInfo"/> type.</typeparam>
/// <param name="key">The cache key.</param>
/// <param name="fetch">Fetches a new value to cache.</param>
- private TMemberInfo GetCached<TMemberInfo>(string key, Func<TMemberInfo> fetch) where TMemberInfo : MemberInfo
+ private TMemberInfo? GetCached<TMemberInfo>(string key, Func<TMemberInfo?> fetch)
+ where TMemberInfo : MemberInfo
{
// get from cache
if (this.Cache.Contains(key))
@@ -267,8 +243,8 @@ namespace StardewModdingAPI.Framework.Reflection
}
// fetch & cache new value
- TMemberInfo result = fetch();
- CacheEntry cacheEntry = new CacheEntry(result != null, result);
+ TMemberInfo? result = fetch();
+ CacheEntry cacheEntry = new(result);
this.Cache.Add(key, cacheEntry, new CacheItemPolicy { SlidingExpiration = this.SlidingCacheExpiry });
return result;
}
diff --git a/src/SMAPI/Framework/Rendering/SDisplayDevice.cs b/src/SMAPI/Framework/Rendering/SDisplayDevice.cs
index 85e69ae6..37996b0f 100644
--- a/src/SMAPI/Framework/Rendering/SDisplayDevice.cs
+++ b/src/SMAPI/Framework/Rendering/SDisplayDevice.cs
@@ -25,7 +25,7 @@ namespace StardewModdingAPI.Framework.Rendering
/// <param name="tile">The tile to draw.</param>
/// <param name="location">The tile position to draw.</param>
/// <param name="layerDepth">The layer depth at which to draw.</param>
- public override void DrawTile(Tile tile, Location location, float layerDepth)
+ public override void DrawTile(Tile? tile, Location location, float layerDepth)
{
// identical to XnaDisplayDevice
if (tile == null)
@@ -56,7 +56,7 @@ namespace StardewModdingAPI.Framework.Rendering
/// <param name="tile">The tile being drawn.</param>
private SpriteEffects GetSpriteEffects(Tile tile)
{
- return tile.Properties.TryGetValue("@Flip", out PropertyValue propertyValue) && int.TryParse(propertyValue, out int value)
+ return tile.Properties.TryGetValue("@Flip", out PropertyValue? propertyValue) && int.TryParse(propertyValue, out int value)
? (SpriteEffects)value
: SpriteEffects.None;
}
@@ -65,7 +65,7 @@ namespace StardewModdingAPI.Framework.Rendering
/// <param name="tile">The tile being drawn.</param>
private float GetRotation(Tile tile)
{
- if (!tile.Properties.TryGetValue("@Rotation", out PropertyValue propertyValue) || !int.TryParse(propertyValue, out int value))
+ if (!tile.Properties.TryGetValue("@Rotation", out PropertyValue? propertyValue) || !int.TryParse(propertyValue, out int value))
return 0;
value %= 360;
diff --git a/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs b/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs
index cb499c6b..94b13378 100644
--- a/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs
+++ b/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs
@@ -89,7 +89,7 @@ namespace StardewModdingAPI.Framework.Rendering
/// <param name="tile">The tile to draw.</param>
/// <param name="location">The tile position to draw.</param>
/// <param name="layerDepth">The layer depth at which to draw.</param>
- public virtual void DrawTile(Tile tile, Location location, float layerDepth)
+ public virtual void DrawTile(Tile? tile, Location location, float layerDepth)
{
if (tile == null)
return;
diff --git a/src/SMAPI/Framework/RequestExitDelegate.cs b/src/SMAPI/Framework/RequestExitDelegate.cs
deleted file mode 100644
index 810c399b..00000000
--- a/src/SMAPI/Framework/RequestExitDelegate.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-namespace StardewModdingAPI.Framework
-{
- /// <summary>A delegate which requests that SMAPI immediately exit the game. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</summary>
- /// <param name="module">The module which requested an immediate exit.</param>
- /// <param name="reason">The reason provided for the shutdown.</param>
- internal delegate void RequestExitDelegate(string module, string reason);
-}
diff --git a/src/SMAPI/Framework/SChatBox.cs b/src/SMAPI/Framework/SChatBox.cs
index e000d1cd..7d6f2e5f 100644
--- a/src/SMAPI/Framework/SChatBox.cs
+++ b/src/SMAPI/Framework/SChatBox.cs
@@ -3,7 +3,7 @@ using StardewValley.Menus;
namespace StardewModdingAPI.Framework
{
- /// <summary>SMAPI's implementation of the chatbox which intercepts errors for logging.</summary>
+ /// <summary>SMAPI's implementation of the chat box which intercepts errors for logging.</summary>
internal class SChatBox : ChatBox
{
/*********
diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs
index 55a7f083..e64318a5 100644
--- a/src/SMAPI/Framework/SCore.cs
+++ b/src/SMAPI/Framework/SCore.cs
@@ -19,6 +19,8 @@ using Newtonsoft.Json;
using StardewModdingAPI.Enums;
using StardewModdingAPI.Events;
using StardewModdingAPI.Framework.Content;
+using StardewModdingAPI.Framework.ContentManagers;
+using StardewModdingAPI.Framework.Deprecations;
using StardewModdingAPI.Framework.Events;
using StardewModdingAPI.Framework.Exceptions;
using StardewModdingAPI.Framework.Input;
@@ -43,7 +45,9 @@ using StardewModdingAPI.Toolkit.Serialization;
using StardewModdingAPI.Toolkit.Utilities;
using StardewModdingAPI.Utilities;
using StardewValley;
+using StardewValley.Menus;
using xTile.Display;
+using LanguageCode = StardewValley.LocalizedContentManager.LanguageCode;
using MiniMonoModHotfix = MonoMod.Utils.MiniMonoModHotfix;
using PathUtilities = StardewModdingAPI.Toolkit.Utilities.PathUtilities;
using SObject = StardewValley.Object;
@@ -60,7 +64,7 @@ namespace StardewModdingAPI.Framework
** Low-level components
****/
/// <summary>Tracks whether the game should exit immediately and any pending initialization should be cancelled.</summary>
- private readonly CancellationTokenSource CancellationToken = new CancellationTokenSource();
+ private readonly CancellationTokenSource CancellationToken = new();
/// <summary>Manages the SMAPI console window and log file.</summary>
private readonly LogManager LogManager;
@@ -69,16 +73,16 @@ namespace StardewModdingAPI.Framework
private Monitor Monitor => this.LogManager.Monitor;
/// <summary>Simplifies access to private game code.</summary>
- private readonly Reflector Reflection = new Reflector();
+ private readonly Reflector Reflection = new();
/// <summary>Encapsulates access to SMAPI core translations.</summary>
- private readonly Translator Translator = new Translator();
+ private readonly Translator Translator = new();
/// <summary>The SMAPI configuration settings.</summary>
private readonly SConfig Settings;
/// <summary>The mod toolkit used for generic mod interactions.</summary>
- private readonly ModToolkit Toolkit = new ModToolkit();
+ private readonly ModToolkit Toolkit = new();
/****
** Higher-level components
@@ -87,17 +91,17 @@ namespace StardewModdingAPI.Framework
private readonly CommandManager CommandManager;
/// <summary>The underlying game instance.</summary>
- private SGameRunner Game;
+ private SGameRunner Game = null!; // initialized very early
/// <summary>SMAPI's content manager.</summary>
- private ContentCoordinator ContentCore;
+ private ContentCoordinator ContentCore = null!; // initialized very early
/// <summary>The game's core multiplayer utility for the main player.</summary>
- private SMultiplayer Multiplayer;
+ private SMultiplayer Multiplayer = null!; // initialized very early
/// <summary>Tracks the installed mods.</summary>
/// <remarks>This is initialized after the game starts.</remarks>
- private readonly ModRegistry ModRegistry = new ModRegistry();
+ private readonly ModRegistry ModRegistry = new();
/// <summary>Manages SMAPI events for mods.</summary>
private readonly EventManager EventManager;
@@ -121,37 +125,45 @@ 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; }
+ /// <summary>The last language set by the game.</summary>
+ private (string Locale, LanguageCode Code) LastLanguage { get; set; } = ("", LanguageCode.en);
+
/// <summary>The maximum number of consecutive attempts SMAPI should make to recover from an update error.</summary>
- private readonly Countdown UpdateCrashTimer = new Countdown(60); // 60 ticks = roughly one second
+ private readonly Countdown UpdateCrashTimer = new(60); // 60 ticks = roughly one second
/// <summary>Asset interceptors added or removed since the last tick.</summary>
- private readonly List<AssetInterceptorChange> ReloadAssetInterceptorsQueue = new List<AssetInterceptorChange>();
+ private readonly List<AssetInterceptorChange> ReloadAssetInterceptorsQueue = new();
/// <summary>A list of queued commands to parse and execute.</summary>
/// <remarks>This property must be thread-safe, since it's accessed from a separate console input thread.</remarks>
- private readonly ConcurrentQueue<string> RawCommandQueue = new ConcurrentQueue<string>();
+ private readonly ConcurrentQueue<string> RawCommandQueue = new();
/// <summary>A list of commands to execute on each screen.</summary>
- private readonly PerScreen<List<Tuple<Command, string, string[]>>> ScreenCommandQueue = new PerScreen<List<Tuple<Command, string, string[]>>>(() => new List<Tuple<Command, string, string[]>>());
-
+ private readonly PerScreen<List<QueuedCommand>> ScreenCommandQueue = new(() => new List<QueuedCommand>());
/*********
** Accessors
*********/
/// <summary>Manages deprecation warnings.</summary>
/// <remarks>This is initialized after the game starts. This is accessed directly because it's not part of the normal class model.</remarks>
- internal static DeprecationManager DeprecationManager { get; private set; }
+ internal static DeprecationManager DeprecationManager { get; private set; } = null!; // initialized in constructor, which happens before other code can access it
/// <summary>The singleton instance.</summary>
/// <remarks>This is only intended for use by external code like the Error Handler mod.</remarks>
- internal static SCore Instance { get; private set; }
+ internal static SCore Instance { get; private set; } = null!; // initialized in constructor, which happens before other code can access it
- /// <summary>The number of update ticks which have already executed. This is similar to <see cref="Game1.ticks"/>, but incremented more consistently for every tick.</summary>
+ /// <summary>The number of game update ticks which have already executed. This is similar to <see cref="Game1.ticks"/>, but incremented more consistently for every tick.</summary>
internal static uint TicksElapsed { get; private set; }
+ /// <summary>A specialized form of <see cref="TicksElapsed"/> which is incremented each time SMAPI performs a processing tick (whether that's a game update, one wait cycle while synchronizing code, etc).</summary>
+ internal static uint ProcessTicksElapsed { get; private set; }
+
/*********
** Public methods
@@ -159,7 +171,8 @@ namespace StardewModdingAPI.Framework
/// <summary>Construct an instance.</summary>
/// <param name="modsPath">The path to search for mods.</param>
/// <param name="writeToConsole">Whether to output log messages to the console.</param>
- public SCore(string modsPath, bool writeToConsole)
+ /// <param name="developerMode">Whether to enable development features, or <c>null</c> to use the value from the settings file.</param>
+ public SCore(string modsPath, bool writeToConsole, bool? developerMode)
{
SCore.Instance = this;
@@ -176,6 +189,8 @@ namespace StardewModdingAPI.Framework
this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath));
if (File.Exists(Constants.ApiUserConfigPath))
JsonConvert.PopulateObject(File.ReadAllText(Constants.ApiUserConfigPath), this.Settings);
+ if (developerMode.HasValue)
+ this.Settings.OverrideDeveloperMode(developerMode.Value);
this.LogManager = new LogManager(logPath: logPath, colorConfig: this.Settings.ConsoleColors, writeToConsole: writeToConsole, isVerbose: this.Settings.VerboseLogging, isDeveloperMode: this.Settings.DeveloperMode, getScreenIdForLog: this.GetScreenIdForLog);
this.CommandManager = new CommandManager(this.Monitor);
@@ -220,13 +235,13 @@ namespace StardewModdingAPI.Framework
this.Toolkit.JsonHelper.JsonSettings.Converters.Add(converter);
// add error handlers
- AppDomain.CurrentDomain.UnhandledException += (sender, e) => this.Monitor.Log($"Critical app domain exception: {e.ExceptionObject}", LogLevel.Error);
+ AppDomain.CurrentDomain.UnhandledException += (_, e) => this.Monitor.Log($"Critical app domain exception: {e.ExceptionObject}", LogLevel.Error);
// add more lenient assembly resolver
- AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => AssemblyLoader.ResolveAssembly(e.Name);
+ AppDomain.CurrentDomain.AssemblyResolve += (_, e) => AssemblyLoader.ResolveAssembly(e.Name);
// hook locale event
- LocalizedContentManager.OnLanguageChange += locale => this.OnLocaleChanged();
+ LocalizedContentManager.OnLanguageChange += _ => this.OnLocaleChanged();
// override game
this.Multiplayer = new SMultiplayer(this.Monitor, this.EventManager, this.Toolkit.JsonHelper, this.ModRegistry, this.Reflection, this.OnModMessageReceived, this.Settings.LogNetworkTraffic);
@@ -315,6 +330,7 @@ namespace StardewModdingAPI.Framework
}
/// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
+ [SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "May be disposed before SMAPI is fully initialized.")]
public void Dispose()
{
// skip if already disposed
@@ -339,9 +355,9 @@ namespace StardewModdingAPI.Framework
// dispose core components
this.IsGameRunning = false;
this.ContentCore?.Dispose();
- this.CancellationToken?.Dispose();
+ this.CancellationToken.Dispose();
this.Game?.Dispose();
- this.LogManager?.Dispose(); // dispose last to allow for any last-second log messages
+ this.LogManager.Dispose(); // dispose last to allow for any last-second log messages
// end game (moved from Game1.OnExiting to let us clean up first)
Process.GetCurrentProcess().Kill();
@@ -364,13 +380,13 @@ namespace StardewModdingAPI.Framework
xTile.Format.FormatManager.Instance.RegisterMapFormat(new TMXTile.TMXFormat(Game1.tileSize / Game1.pixelZoom, Game1.tileSize / Game1.pixelZoom, Game1.pixelZoom, Game1.pixelZoom));
// load mod data
- ModToolkit toolkit = new ModToolkit();
+ ModToolkit toolkit = new();
ModDatabase modDatabase = toolkit.GetModDatabase(Constants.ApiMetadataPath);
// load mods
{
this.Monitor.Log("Loading mod metadata...", LogLevel.Debug);
- ModResolver resolver = new ModResolver();
+ ModResolver resolver = new();
// log loose files
{
@@ -501,12 +517,12 @@ namespace StardewModdingAPI.Framework
/*********
** Parse commands
*********/
- while (this.RawCommandQueue.TryDequeue(out string rawInput))
+ while (this.RawCommandQueue.TryDequeue(out string? rawInput))
{
// parse command
- string name;
- string[] args;
- Command command;
+ string? name;
+ string[]? args;
+ Command? command;
int screenId;
try
{
@@ -523,7 +539,7 @@ namespace StardewModdingAPI.Framework
}
// queue command for screen
- this.ScreenCommandQueue.GetValueForScreen(screenId).Add(Tuple.Create(command, name, args));
+ this.ScreenCommandQueue.GetValueForScreen(screenId).Add(new(command, name, args));
}
@@ -540,7 +556,7 @@ namespace StardewModdingAPI.Framework
catch (Exception ex)
{
// log error
- this.Monitor.Log($"An error occured in the overridden update loop: {ex.GetLogSummary()}", LogLevel.Error);
+ this.Monitor.Log($"An error occurred in the overridden update loop: {ex.GetLogSummary()}", LogLevel.Error);
// exit if irrecoverable
if (!this.UpdateCrashTimer.Decrement())
@@ -549,6 +565,7 @@ namespace StardewModdingAPI.Framework
finally
{
SCore.TicksElapsed++;
+ SCore.ProcessTicksElapsed++;
}
}
@@ -558,7 +575,7 @@ namespace StardewModdingAPI.Framework
/// <param name="runUpdate">Invoke the game's update logic.</param>
private void OnPlayerInstanceUpdating(SGame instance, GameTime gameTime, Action runUpdate)
{
- var events = this.EventManager;
+ EventManager events = this.EventManager;
try
{
@@ -567,7 +584,7 @@ namespace StardewModdingAPI.Framework
*********/
if (this.JustReturnedToTitle)
{
- if (!(Game1.mapDisplayDevice is SDisplayDevice))
+ if (Game1.mapDisplayDevice is not SDisplayDevice)
Game1.mapDisplayDevice = this.GetMapDisplayDevice();
this.JustReturnedToTitle = false;
@@ -578,12 +595,8 @@ namespace StardewModdingAPI.Framework
*********/
{
var commandQueue = this.ScreenCommandQueue.Value;
- foreach (var entry in commandQueue)
+ foreach ((Command? command, string? name, string[]? args) in commandQueue)
{
- Command command = entry.Item1;
- string name = entry.Item2;
- string[] args = entry.Item3;
-
try
{
command.Callback.Invoke(name, args);
@@ -620,8 +633,11 @@ namespace StardewModdingAPI.Framework
{
this.Monitor.Log("Game loader synchronizing...");
this.Reflection.GetMethod(Game1.game1, "UpdateTitleScreen").Invoke(Game1.currentGameTime); // run game logic to change music on load, etc
+ // ReSharper disable once ConstantConditionalAccessQualifier -- may become null within the loop
while (Game1.currentLoader?.MoveNext() == true)
{
+ SCore.ProcessTicksElapsed++;
+
// raise load stage changed
switch (Game1.currentLoader.Current)
{
@@ -806,7 +822,7 @@ namespace StardewModdingAPI.Framework
// raise cursor moved event
if (state.Cursor.IsChanged)
- events.CursorMoved.Raise(new CursorMovedEventArgs(state.Cursor.Old, state.Cursor.New));
+ events.CursorMoved.Raise(new CursorMovedEventArgs(state.Cursor.Old!, state.Cursor.New!));
// raise mouse wheel scrolled
if (state.MouseWheelScroll.IsChanged)
@@ -937,7 +953,7 @@ namespace StardewModdingAPI.Framework
// raise player events
if (raiseWorldEvents)
{
- PlayerSnapshot playerState = state.CurrentPlayer;
+ PlayerSnapshot playerState = state.CurrentPlayer!; // not null at this point
Farmer player = playerState.Player;
// raise current location changed
@@ -946,25 +962,25 @@ namespace StardewModdingAPI.Framework
if (this.Monitor.IsVerbose)
this.Monitor.Log($"Context: set location to {playerState.Location.New}.");
- events.Warped.Raise(new WarpedEventArgs(player, playerState.Location.Old, playerState.Location.New));
+ events.Warped.Raise(new WarpedEventArgs(player, playerState.Location.Old!, playerState.Location.New!));
}
// raise player leveled up a skill
- foreach (var pair in playerState.Skills)
+ foreach ((SkillType skill, var value) in playerState.Skills)
{
- if (!pair.Value.IsChanged)
+ if (!value.IsChanged)
continue;
if (this.Monitor.IsVerbose)
- this.Monitor.Log($"Events: player skill '{pair.Key}' changed from {pair.Value.Old} to {pair.Value.New}.");
+ this.Monitor.Log($"Events: player skill '{skill}' changed from {value.Old} to {value.New}.");
- events.LevelChanged.Raise(new LevelChangedEventArgs(player, pair.Key, pair.Value.Old, pair.Value.New));
+ events.LevelChanged.Raise(new LevelChangedEventArgs(player, skill, value.Old, value.New));
}
// raise player inventory changed
if (playerState.Inventory.IsChanged)
{
- var inventory = playerState.Inventory;
+ SnapshotItemListDiff inventory = playerState.Inventory;
if (this.Monitor.IsVerbose)
this.Monitor.Log("Events: player inventory changed.");
@@ -986,6 +1002,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();
+ }
}
/*********
@@ -1036,7 +1059,7 @@ namespace StardewModdingAPI.Framework
// get locale
string locale = this.ContentCore.GetLocale();
- LocalizedContentManager.LanguageCode languageCode = this.ContentCore.Language;
+ LanguageCode languageCode = this.ContentCore.Language;
// update core translations
this.Translator.SetLocale(locale, languageCode);
@@ -1044,11 +1067,26 @@ namespace StardewModdingAPI.Framework
// update mod translation helpers
foreach (IModMetadata mod in this.ModRegistry.GetAll())
{
- mod.Translations.SetLocale(locale, languageCode);
+ TranslationHelper translations = mod.Translations!; // not null at this point
+ translations.SetLocale(locale, languageCode);
foreach (ContentPack contentPack in mod.GetFakeContentPacks())
contentPack.TranslationImpl.SetLocale(locale, languageCode);
}
+
+ // raise event
+ if (this.EventManager.LocaleChanged.HasListeners())
+ {
+ this.EventManager.LocaleChanged.Raise(
+ new LocaleChangedEventArgs(
+ oldLanguage: this.LastLanguage.Code,
+ oldLocale: this.LastLanguage.Locale,
+ newLanguage: languageCode,
+ newLocale: locale
+ )
+ );
+ }
+ this.LastLanguage = (locale, languageCode);
}
/// <summary>Raised when the low-level stage while loading a save changes.</summary>
@@ -1077,7 +1115,7 @@ namespace StardewModdingAPI.Framework
break;
case LoadStage.Loaded:
- // override chatbox
+ // override chat box
Game1.onScreenMenus.Remove(Game1.chatBox);
Game1.onScreenMenus.Add(Game1.chatBox = new SChatBox(this.LogManager.MonitorForGame));
break;
@@ -1095,6 +1133,78 @@ namespace StardewModdingAPI.Framework
this.EventManager.DayEnding.RaiseEmpty();
}
+ /// <summary>A callback invoked after an asset is fully loaded through a content manager.</summary>
+ /// <param name="contentManager">The content manager through which the asset was loaded.</param>
+ /// <param name="assetName">The asset name that was loaded.</param>
+ private void OnAssetLoaded(IContentManager contentManager, IAssetName assetName)
+ {
+ if (this.EventManager.AssetReady.HasListeners())
+ this.EventManager.AssetReady.Raise(new AssetReadyEventArgs(assetName, assetName.GetBaseAssetName()));
+ }
+
+ /// <summary>A callback invoked after assets have been invalidated from the content cache.</summary>
+ /// <param name="assetNames">The invalidated asset names.</param>
+ private void OnAssetsInvalidated(IList<IAssetName> assetNames)
+ {
+ if (this.EventManager.AssetsInvalidated.HasListeners())
+ this.EventManager.AssetsInvalidated.Raise(new AssetsInvalidatedEventArgs(assetNames, assetNames.Select(p => p.GetBaseAssetName())));
+ }
+
+ /// <summary>Get the load/edit operations to apply to an asset by querying registered <see cref="IContentEvents.AssetRequested"/> event handlers.</summary>
+ /// <param name="asset">The asset info being requested.</param>
+ private IList<AssetOperationGroup> RequestAssetOperations(IAssetInfo asset)
+ {
+ List<AssetOperationGroup> operations = new();
+
+ this.EventManager.AssetRequested.Raise(
+ invoke: (mod, invoke) =>
+ {
+ AssetRequestedEventArgs args = new(mod, asset.Name, asset.NameWithoutLocale, asset.DataType, this.GetOnBehalfOfContentPack);
+
+ invoke(args);
+
+ if (args.LoadOperations.Any() || args.EditOperations.Any())
+ {
+ operations.Add(
+ new AssetOperationGroup(mod, args.LoadOperations.ToArray(), args.EditOperations.ToArray())
+ );
+ }
+ }
+ );
+
+ return operations;
+ }
+
+ /// <summary>Get the mod metadata for a content pack whose ID matches <paramref name="id"/>, if it's a valid content pack for the given <paramref name="mod"/>.</summary>
+ /// <param name="mod">The mod requesting to act on the content pack's behalf.</param>
+ /// <param name="id">The content pack ID.</param>
+ /// <param name="verb">The verb phrase indicating what action will be performed, like 'load assets' or 'edit assets'.</param>
+ /// <returns>Returns the content pack metadata if valid, else <c>null</c>.</returns>
+ private IModMetadata? GetOnBehalfOfContentPack(IModMetadata mod, string? id, string verb)
+ {
+ if (id == null)
+ return null;
+
+ string errorPrefix = $"Can't {verb} on behalf of content pack ID '{id}'";
+
+ // get target mod
+ IModMetadata? onBehalfOf = this.ModRegistry.Get(id);
+ if (onBehalfOf == null)
+ {
+ mod.LogAsModOnce($"{errorPrefix}: there's no content pack installed with that ID.", LogLevel.Warn);
+ return null;
+ }
+
+ // make sure it's a content pack for the requesting mod
+ if (!onBehalfOf.IsContentPack || !string.Equals(onBehalfOf.Manifest.ContentPackFor?.UniqueID, mod.Manifest.UniqueID))
+ {
+ mod.LogAsModOnce($"{errorPrefix}: that isn't a content pack for this mod.", LogLevel.Warn);
+ return null;
+ }
+
+ return onBehalfOf;
+ }
+
/// <summary>Raised immediately before the player returns to the title screen.</summary>
private void OnReturningToTitle()
{
@@ -1120,7 +1230,7 @@ namespace StardewModdingAPI.Framework
modIDs.Remove(message.FromModID); // don't send a broadcast back to the sender
// raise events
- this.EventManager.ModMessageReceived.Raise(new ModMessageReceivedEventArgs(message, this.Toolkit.JsonHelper), mod => mod != null && modIDs.Contains(mod.Manifest.UniqueID));
+ this.EventManager.ModMessageReceived.Raise(new ModMessageReceivedEventArgs(message, this.Toolkit.JsonHelper), mod => modIDs.Contains(mod.Manifest.UniqueID));
}
/// <summary>Constructor a content manager to read game content files.</summary>
@@ -1129,9 +1239,22 @@ namespace StardewModdingAPI.Framework
private LocalizedContentManager CreateContentManager(IServiceProvider serviceProvider, string rootDirectory)
{
// Game1._temporaryContent initializing from SGame constructor
+ // ReSharper disable once ConditionIsAlwaysTrueOrFalse -- this is the method that initializes it
if (this.ContentCore == null)
{
- this.ContentCore = new ContentCoordinator(serviceProvider, rootDirectory, Thread.CurrentThread.CurrentUICulture, this.Monitor, this.Reflection, this.Toolkit.JsonHelper, this.InitializeBeforeFirstAssetLoaded, this.Settings.AggressiveMemoryOptimizations);
+ this.ContentCore = new ContentCoordinator(
+ serviceProvider: serviceProvider,
+ rootDirectory: rootDirectory,
+ currentCulture: Thread.CurrentThread.CurrentUICulture,
+ monitor: this.Monitor,
+ reflection: this.Reflection,
+ jsonHelper: this.Toolkit.JsonHelper,
+ onLoadingFirstAsset: this.InitializeBeforeFirstAssetLoaded,
+ onAssetLoaded: this.OnAssetLoaded,
+ onAssetsInvalidated: this.OnAssetsInvalidated,
+ aggressiveMemoryOptimizations: this.Settings.AggressiveMemoryOptimizations,
+ requestAssetOperations: this.RequestAssetOperations
+ );
if (this.ContentCore.Language != this.Translator.LocaleEnum)
this.Translator.SetLocale(this.ContentCore.GetLocale(), this.ContentCore.Language);
@@ -1169,21 +1292,21 @@ namespace StardewModdingAPI.Framework
// detect issues
bool hasObjectIssues = false;
void LogIssue(int id, string issue) => this.Monitor.Log($@"Detected issue: item #{id} in Content\Data\ObjectInformation.xnb is invalid ({issue}).");
- foreach (KeyValuePair<int, string> entry in Game1.objectInformation)
+ foreach ((int id, string? fieldsStr) in Game1.objectInformation)
{
// must not be empty
- if (string.IsNullOrWhiteSpace(entry.Value))
+ if (string.IsNullOrWhiteSpace(fieldsStr))
{
- LogIssue(entry.Key, "entry is empty");
+ LogIssue(id, "entry is empty");
hasObjectIssues = true;
continue;
}
// require core fields
- string[] fields = entry.Value.Split('/');
+ string[] fields = fieldsStr.Split('/');
if (fields.Length < SObject.objectInfoDescriptionIndex + 1)
{
- LogIssue(entry.Key, "too few fields for an object");
+ LogIssue(id, "too few fields for an object");
hasObjectIssues = true;
continue;
}
@@ -1194,7 +1317,7 @@ namespace StardewModdingAPI.Framework
case "Cooking":
if (fields.Length < SObject.objectInfoBuffDurationIndex + 1)
{
- LogIssue(entry.Key, "too few fields for a cooking item");
+ LogIssue(id, "too few fields for a cooking item");
hasObjectIssues = true;
}
break;
@@ -1242,17 +1365,17 @@ namespace StardewModdingAPI.Framework
string[] installedNames = registryKeys
.SelectMany(registryKey =>
{
- using RegistryKey key = Registry.LocalMachine.OpenSubKey(registryKey);
+ using RegistryKey? key = Registry.LocalMachine.OpenSubKey(registryKey);
if (key == null)
- return new string[0];
+ return Array.Empty<string>();
return key
.GetSubKeyNames()
.Select(subkeyName =>
{
- using RegistryKey subkey = key.OpenSubKey(subkeyName);
- string displayName = (string)subkey?.GetValue("DisplayName");
- string displayVersion = (string)subkey?.GetValue("DisplayVersion");
+ using RegistryKey? subkey = key.OpenSubKey(subkeyName);
+ string? displayName = (string?)subkey?.GetValue("DisplayName");
+ string? displayVersion = (string?)subkey?.GetValue("DisplayVersion");
if (displayName != null && displayVersion != null && displayName.EndsWith($" {displayVersion}"))
displayName = displayName.Substring(0, displayName.Length - displayVersion.Length - 1);
@@ -1262,6 +1385,7 @@ namespace StardewModdingAPI.Framework
.ToArray();
})
.Where(name => name != null && (name.Contains("MSI Afterburner") || name.Contains("RivaTuner")))
+ .Select(name => name!)
.Distinct()
.OrderBy(name => name)
.ToArray();
@@ -1289,19 +1413,19 @@ namespace StardewModdingAPI.Framework
{
// create client
string url = this.Settings.WebApiBaseUrl;
- WebApiClient client = new WebApiClient(url, Constants.ApiVersion);
+ WebApiClient client = new(url, Constants.ApiVersion);
this.Monitor.Log("Checking for updates...");
// check SMAPI version
{
- ISemanticVersion updateFound = null;
- string updateUrl = null;
+ ISemanticVersion? updateFound = null;
+ string? updateUrl = null;
try
{
// fetch update check
ModEntryModel response = client.GetModInfo(new[] { new ModSearchEntryModel("Pathoschild.SMAPI", Constants.ApiVersion, new[] { $"GitHub:{this.Settings.GitHubProjectName}" }) }, apiVersion: Constants.ApiVersion, gameVersion: Constants.GameVersion, platform: Constants.Platform).Single().Value;
updateFound = response.SuggestedUpdate?.Version;
- updateUrl = response.SuggestedUpdate?.Url ?? Constants.HomePageUrl;
+ updateUrl = response.SuggestedUpdate?.Url;
// log message
if (updateFound != null)
@@ -1327,42 +1451,7 @@ namespace StardewModdingAPI.Framework
// show update message on next launch
if (updateFound != null)
- this.LogManager.WriteUpdateMarker(updateFound.ToString(), updateUrl);
- }
-
- // check Stardew64Installer version
- if (Constants.IsPatchedByStardew64Installer(out ISemanticVersion patchedByVersion))
- {
- ISemanticVersion updateFound = null;
- string updateUrl = null;
- try
- {
- // fetch update check
- ModEntryModel response = client.GetModInfo(new[] { new ModSearchEntryModel("Steviegt6.Stardew64Installer", patchedByVersion, new[] { $"GitHub:{this.Settings.Stardew64InstallerGitHubProjectName}" }) }, apiVersion: Constants.ApiVersion, gameVersion: Constants.GameVersion, platform: Constants.Platform).Single().Value;
- updateFound = response.SuggestedUpdate?.Version;
- updateUrl = response.SuggestedUpdate?.Url ?? Constants.HomePageUrl;
-
- // log message
- if (updateFound != null)
- this.Monitor.Log($"You can update Stardew64Installer to {updateFound}: {updateUrl}", LogLevel.Alert);
- else
- this.Monitor.Log(" Stardew64Installer okay.");
-
- // show errors
- if (response.Errors.Any())
- {
- this.Monitor.Log("Couldn't check for a new version of Stardew64Installer. This won't affect your game, but you may not be notified of new versions if this keeps happening.", LogLevel.Warn);
- this.Monitor.Log($"Error: {string.Join("\n", response.Errors)}");
- }
- }
- catch (Exception ex)
- {
- this.Monitor.Log("Couldn't check for a new version of Stardew64Installer. This won't affect your game, but you won't be notified of new versions if this keeps happening.", LogLevel.Warn);
- this.Monitor.Log(ex is WebException && ex.InnerException == null
- ? $"Error: {ex.Message}"
- : $"Error: {ex.GetLogSummary()}"
- );
- }
+ this.LogManager.WriteUpdateMarker(updateFound.ToString(), updateUrl ?? Constants.HomePageUrl);
}
// check mod versions
@@ -1396,12 +1485,12 @@ namespace StardewModdingAPI.Framework
foreach (IModMetadata mod in mods.OrderBy(p => p.DisplayName))
{
// link to update-check data
- if (!mod.HasID() || !results.TryGetValue(mod.Manifest.UniqueID, out ModEntryModel result))
+ if (!mod.HasID() || !results.TryGetValue(mod.Manifest.UniqueID, out ModEntryModel? result))
continue;
mod.SetUpdateData(result);
// handle errors
- if (result.Errors != null && result.Errors.Any())
+ if (result.Errors.Any())
{
errors.AppendLine(result.Errors.Length == 1
? $" {mod.DisplayName}: {result.Errors[0]}"
@@ -1423,13 +1512,8 @@ namespace StardewModdingAPI.Framework
{
this.Monitor.Newline();
this.Monitor.Log($"You can update {updates.Count} mod{(updates.Count != 1 ? "s" : "")}:", LogLevel.Alert);
- foreach (var entry in updates)
- {
- IModMetadata mod = entry.Item1;
- ISemanticVersion newVersion = entry.Item2;
- string newUrl = entry.Item3;
+ foreach ((IModMetadata mod, ISemanticVersion newVersion, string newUrl) in updates)
this.Monitor.Log($" {mod.DisplayName} {newVersion}: {newUrl}", LogLevel.Alert);
- }
}
else
this.Monitor.Log(" All mods up to date.");
@@ -1473,18 +1557,19 @@ namespace StardewModdingAPI.Framework
// load mods
IList<IModMetadata> skippedMods = new List<IModMetadata>();
- using (AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.Platform, this.Monitor, this.Settings.ParanoidWarnings, this.Settings.RewriteMods))
+ using (AssemblyLoader modAssemblyLoader = new(Constants.Platform, this.Monitor, this.Settings.ParanoidWarnings, this.Settings.RewriteMods))
{
// init
HashSet<string> suppressUpdateChecks = new HashSet<string>(this.Settings.SuppressUpdateChecks, StringComparer.OrdinalIgnoreCase);
- InterfaceProxyFactory proxyFactory = new InterfaceProxyFactory();
+ IInterfaceProxyFactory proxyFactory = this.Settings.UsePintail
+ ? new InterfaceProxyFactory()
+ : new OriginalInterfaceProxyFactory();
// load mods
foreach (IModMetadata mod in mods)
{
- if (!this.TryLoadMod(mod, mods, modAssemblyLoader, proxyFactory, jsonHelper, contentCore, modDatabase, suppressUpdateChecks, out ModFailReason? failReason, out string errorPhrase, out string errorDetails))
+ if (!this.TryLoadMod(mod, mods, modAssemblyLoader, proxyFactory, jsonHelper, contentCore, modDatabase, suppressUpdateChecks, out ModFailReason? failReason, out string? errorPhrase, out string? errorDetails))
{
- failReason ??= ModFailReason.LoadFailed;
mod.SetStatus(ModMetadataStatus.Failed, failReason.Value, errorPhrase, errorDetails);
skippedMods.Add(mod);
}
@@ -1506,27 +1591,51 @@ namespace StardewModdingAPI.Framework
// initialize loaded non-content-pack mods
this.Monitor.Log("Launching mods...", LogLevel.Debug);
+#pragma warning disable CS0612, CS0618 // deprecated code
foreach (IModMetadata metadata in loadedMods)
{
// add interceptors
- if (metadata.Mod.Helper.Content is ContentHelper helper)
+ if (metadata.Mod?.Helper is ModHelper helper)
{
// ReSharper disable SuspiciousTypeConversion.Global
if (metadata.Mod is IAssetEditor editor)
+ {
+ SCore.DeprecationManager.Warn(
+ source: metadata,
+ nounPhrase: $"{nameof(IAssetEditor)}",
+ version: "3.14.0",
+ severity: DeprecationLevel.Notice,
+ logStackTrace: false
+ );
+
this.ContentCore.Editors.Add(new ModLinked<IAssetEditor>(metadata, editor));
+ }
+
if (metadata.Mod is IAssetLoader loader)
+ {
+ SCore.DeprecationManager.Warn(
+ source: metadata,
+ nounPhrase: $"{nameof(IAssetLoader)}",
+ version: "3.14.0",
+ severity: DeprecationLevel.Notice,
+ logStackTrace: false
+ );
+
this.ContentCore.Loaders.Add(new ModLinked<IAssetLoader>(metadata, loader));
+ }
// ReSharper restore SuspiciousTypeConversion.Global
- helper.ObservableAssetEditors.CollectionChanged += (sender, e) => this.OnAssetInterceptorsChanged(metadata, e.NewItems?.Cast<IAssetEditor>(), e.OldItems?.Cast<IAssetEditor>(), this.ContentCore.Editors);
- helper.ObservableAssetLoaders.CollectionChanged += (sender, e) => this.OnAssetInterceptorsChanged(metadata, e.NewItems?.Cast<IAssetLoader>(), e.OldItems?.Cast<IAssetLoader>(), this.ContentCore.Loaders);
+ ContentHelper content = helper.GetLegacyContentHelper();
+ content.ObservableAssetEditors.CollectionChanged += (_, e) => this.OnAssetInterceptorsChanged(metadata, e.NewItems?.Cast<IAssetEditor>(), e.OldItems?.Cast<IAssetEditor>(), this.ContentCore.Editors);
+ content.ObservableAssetLoaders.CollectionChanged += (_, e) => this.OnAssetInterceptorsChanged(metadata, e.NewItems?.Cast<IAssetLoader>(), e.OldItems?.Cast<IAssetLoader>(), this.ContentCore.Loaders);
}
+#pragma warning restore CS0612, CS0618
// call entry method
try
{
- IMod mod = metadata.Mod;
- mod.Entry(mod.Helper);
+ IMod mod = metadata.Mod!;
+ mod.Entry(mod.Helper!);
}
catch (Exception ex)
{
@@ -1536,7 +1645,7 @@ namespace StardewModdingAPI.Framework
// get mod API
try
{
- object api = metadata.Mod.GetApi();
+ object? api = metadata.Mod!.GetApi();
if (api != null && !api.GetType().IsPublic)
{
api = null;
@@ -1565,15 +1674,16 @@ namespace StardewModdingAPI.Framework
/// <param name="added">The interceptors that were added.</param>
/// <param name="removed">The interceptors that were removed.</param>
/// <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)
+ private void OnAssetInterceptorsChanged<T>(IModMetadata mod, IEnumerable<T>? added, IEnumerable<T>? removed, IList<ModLinked<T>> list)
+ where T : notnull
{
- 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())
@@ -1594,7 +1704,7 @@ namespace StardewModdingAPI.Framework
/// <param name="errorReasonPhrase">The user-facing reason phrase explaining why the mod couldn't be loaded (if applicable).</param>
/// <param name="errorDetails">More detailed details about the error intended for developers (if any).</param>
/// <returns>Returns whether the mod was successfully loaded.</returns>
- private bool TryLoadMod(IModMetadata mod, IModMetadata[] mods, AssemblyLoader assemblyLoader, InterfaceProxyFactory proxyFactory, JsonHelper jsonHelper, ContentCoordinator contentCore, ModDatabase modDatabase, HashSet<string> suppressUpdateChecks, out ModFailReason? failReason, out string errorReasonPhrase, out string errorDetails)
+ private bool TryLoadMod(IModMetadata mod, IModMetadata[] mods, AssemblyLoader assemblyLoader, IInterfaceProxyFactory proxyFactory, JsonHelper jsonHelper, ContentCoordinator contentCore, ModDatabase modDatabase, HashSet<string> suppressUpdateChecks, [NotNullWhen(false)] out ModFailReason? failReason, out string? errorReasonPhrase, out string? errorDetails)
{
errorDetails = null;
@@ -1603,6 +1713,7 @@ namespace StardewModdingAPI.Framework
string relativePath = mod.GetRelativePathWithRoot();
if (mod.IsContentPack)
this.Monitor.Log($" {mod.DisplayName} (from {relativePath}) [content pack]...");
+ // ReSharper disable once ConstantConditionalAccessQualifier -- mod may be invalid at this point
else if (mod.Manifest?.EntryDll != null)
this.Monitor.Log($" {mod.DisplayName} (from {relativePath}{Path.DirectorySeparatorChar}{mod.Manifest.EntryDll})..."); // don't use Path.Combine here, since EntryDLL might not be valid
else
@@ -1610,21 +1721,22 @@ namespace StardewModdingAPI.Framework
}
// add warning for missing update key
- if (mod.HasID() && !suppressUpdateChecks.Contains(mod.Manifest.UniqueID) && !mod.HasValidUpdateKeys())
+ if (mod.HasID() && !suppressUpdateChecks.Contains(mod.Manifest!.UniqueID) && !mod.HasValidUpdateKeys())
mod.SetWarning(ModWarning.NoUpdateKeys);
// validate status
if (mod.Status == ModMetadataStatus.Failed)
{
this.Monitor.Log($" Failed: {mod.ErrorDetails ?? mod.Error}");
- failReason = mod.FailReason;
+ failReason = mod.FailReason ?? ModFailReason.LoadFailed;
errorReasonPhrase = mod.Error;
return false;
}
+ IManifest manifest = mod.Manifest!;
// validate dependencies
// Although dependencies are validated before mods are loaded, a dependency may have failed to load.
- foreach (IManifestDependency dependency in mod.Manifest.Dependencies.Where(p => p.IsRequired))
+ foreach (IManifestDependency dependency in manifest.Dependencies.Where(p => p.IsRequired))
{
if (this.ModRegistry.Get(dependency.UniqueID) == null)
{
@@ -1640,11 +1752,12 @@ namespace StardewModdingAPI.Framework
// load as content pack
if (mod.IsContentPack)
{
- IManifest manifest = mod.Manifest;
IMonitor monitor = this.LogManager.GetMonitor(mod.DisplayName);
- IContentHelper contentHelper = new ContentHelper(this.ContentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, monitor);
- TranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, contentCore.GetLocale(), contentCore.Language);
- IContentPack contentPack = new ContentPack(mod.DirectoryPath, manifest, contentHelper, translationHelper, jsonHelper);
+ CaseInsensitivePathLookup relativePathCache = CaseInsensitivePathLookup.GetCachedFor(mod.DirectoryPath);
+ GameContentHelper gameContentHelper = new(this.ContentCore, mod, mod.DisplayName, monitor, this.Reflection);
+ IModContentHelper modContentHelper = new ModContentHelper(this.ContentCore, mod.DirectoryPath, mod, mod.DisplayName, gameContentHelper.GetUnderlyingContentManager(), relativePathCache, this.Reflection);
+ TranslationHelper translationHelper = new(mod, contentCore.GetLocale(), contentCore.Language);
+ IContentPack contentPack = new ContentPack(mod.DirectoryPath, manifest, modContentHelper, translationHelper, jsonHelper, relativePathCache);
mod.SetMod(contentPack, monitor, translationHelper);
this.ModRegistry.Add(mod);
@@ -1657,8 +1770,10 @@ namespace StardewModdingAPI.Framework
else
{
// get mod info
- IManifest manifest = mod.Manifest;
- string assemblyPath = Path.Combine(mod.DirectoryPath, manifest.EntryDll);
+ string assemblyPath = Path.Combine(
+ mod.DirectoryPath,
+ CaseInsensitivePathLookup.GetCachedFor(mod.DirectoryPath).GetFilePath(manifest.EntryDll!)
+ );
// load mod
Assembly modAssembly;
@@ -1669,7 +1784,7 @@ namespace StardewModdingAPI.Framework
}
catch (IncompatibleInstructionException) // details already in trace logs
{
- string[] updateUrls = new[] { modDatabase.GetModPageUrlFor(manifest.UniqueID), "https://smapi.io/mods" }.Where(p => p != null).ToArray();
+ string[] updateUrls = new[] { modDatabase.GetModPageUrlFor(manifest.UniqueID), "https://smapi.io/mods" }.Where(p => p != null).ToArray()!;
errorReasonPhrase = $"it's no longer compatible. Please check for a new version at {string.Join(" or ", updateUrls)}";
failReason = ModFailReason.Incompatible;
return false;
@@ -1695,7 +1810,7 @@ namespace StardewModdingAPI.Framework
try
{
// get mod instance
- if (!this.TryLoadModEntry(modAssembly, out Mod modEntry, out errorReasonPhrase))
+ if (!this.TryLoadModEntry(modAssembly, out Mod? modEntry, out errorReasonPhrase))
{
failReason = ModFailReason.LoadFailed;
return false;
@@ -1709,23 +1824,27 @@ namespace StardewModdingAPI.Framework
return this.ModRegistry
.GetAll(assemblyMods: false)
- .Where(p => p.IsContentPack && mod.HasID(p.Manifest.ContentPackFor.UniqueID))
- .Select(p => p.ContentPack)
+ .Where(p => p.IsContentPack && mod.HasID(p.Manifest.ContentPackFor!.UniqueID))
+ .Select(p => p.ContentPack!)
.ToArray();
}
// init mod helpers
IMonitor monitor = this.LogManager.GetMonitor(mod.DisplayName);
- TranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, contentCore.GetLocale(), contentCore.Language);
+ TranslationHelper translationHelper = new(mod, contentCore.GetLocale(), contentCore.Language);
IModHelper modHelper;
{
IContentPack CreateFakeContentPack(string packDirPath, IManifest packManifest)
{
IMonitor packMonitor = this.LogManager.GetMonitor(packManifest.Name);
- IContentHelper packContentHelper = new ContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor);
- TranslationHelper packTranslationHelper = new TranslationHelper(packManifest.UniqueID, contentCore.GetLocale(), contentCore.Language);
- ContentPack contentPack = new ContentPack(packDirPath, packManifest, packContentHelper, packTranslationHelper, this.Toolkit.JsonHelper);
+ CaseInsensitivePathLookup relativePathCache = CaseInsensitivePathLookup.GetCachedFor(packDirPath);
+
+ GameContentHelper gameContentHelper = new(contentCore, mod, packManifest.Name, packMonitor, this.Reflection);
+ IModContentHelper packContentHelper = new ModContentHelper(contentCore, packDirPath, mod, packManifest.Name, gameContentHelper.GetUnderlyingContentManager(), relativePathCache, this.Reflection);
+ TranslationHelper packTranslationHelper = new(mod, contentCore.GetLocale(), contentCore.Language);
+
+ ContentPack contentPack = new(packDirPath, packManifest, packContentHelper, packTranslationHelper, this.Toolkit.JsonHelper, relativePathCache);
this.ReloadTranslationsForTemporaryContentPack(mod, contentPack);
mod.FakeContentPacks.Add(new WeakReference<ContentPack>(contentPack));
return contentPack;
@@ -1733,14 +1852,19 @@ namespace StardewModdingAPI.Framework
IModEvents events = new ModEvents(mod, this.EventManager);
ICommandHelper commandHelper = new CommandHelper(mod, this.CommandManager);
- IContentHelper contentHelper = new ContentHelper(contentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, monitor);
- IContentPackHelper contentPackHelper = new ContentPackHelper(manifest.UniqueID, new Lazy<IContentPack[]>(GetContentPacks), CreateFakeContentPack);
- IDataHelper dataHelper = new DataHelper(manifest.UniqueID, mod.DirectoryPath, jsonHelper);
- IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, mod.DisplayName, this.Reflection);
- IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry, proxyFactory, monitor);
- IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(manifest.UniqueID, this.Multiplayer);
-
- modHelper = new ModHelper(manifest.UniqueID, mod.DirectoryPath, () => this.GetCurrentGameInstance().Input, events, contentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper);
+ CaseInsensitivePathLookup relativePathCache = CaseInsensitivePathLookup.GetCachedFor(mod.DirectoryPath);
+#pragma warning disable CS0612 // deprecated code
+ ContentHelper contentHelper = new(contentCore, mod.DirectoryPath, mod, monitor, this.Reflection);
+#pragma warning restore CS0612
+ GameContentHelper gameContentHelper = new(contentCore, mod, mod.DisplayName, monitor, this.Reflection);
+ IModContentHelper modContentHelper = new ModContentHelper(contentCore, mod.DirectoryPath, mod, mod.DisplayName, gameContentHelper.GetUnderlyingContentManager(), relativePathCache, this.Reflection);
+ IContentPackHelper contentPackHelper = new ContentPackHelper(mod, new Lazy<IContentPack[]>(GetContentPacks), CreateFakeContentPack);
+ IDataHelper dataHelper = new DataHelper(mod, mod.DirectoryPath, jsonHelper);
+ IReflectionHelper reflectionHelper = new ReflectionHelper(mod, mod.DisplayName, this.Reflection);
+ IModRegistry modRegistryHelper = new ModRegistryHelper(mod, this.ModRegistry, proxyFactory, monitor);
+ IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(mod, this.Multiplayer);
+
+ modHelper = new ModHelper(mod, mod.DirectoryPath, () => this.GetCurrentGameInstance().Input, events, contentHelper, gameContentHelper, modContentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper);
}
// init mod
@@ -1768,7 +1892,7 @@ namespace StardewModdingAPI.Framework
/// <param name="mod">The loaded instance.</param>
/// <param name="error">The error indicating why loading failed (if applicable).</param>
/// <returns>Returns whether the mod entry class was successfully loaded.</returns>
- private bool TryLoadModEntry(Assembly modAssembly, out Mod mod, out string error)
+ private bool TryLoadModEntry(Assembly modAssembly, [NotNullWhen(true)] out Mod? mod, [NotNullWhen(false)] out string? error)
{
mod = null;
@@ -1786,7 +1910,7 @@ namespace StardewModdingAPI.Framework
}
// get implementation
- mod = (Mod)modAssembly.CreateInstance(modEntries[0].ToString());
+ mod = (Mod?)modAssembly.CreateInstance(modEntries[0].ToString());
if (mod == null)
{
error = "its entry class couldn't be instantiated.";
@@ -1832,7 +1956,7 @@ namespace StardewModdingAPI.Framework
metadata.LogAsMod($" - {error}", LogLevel.Warn);
}
- metadata.Translations.SetTranslations(translations);
+ metadata.Translations!.SetTranslations(translations);
}
// fake content packs
@@ -1867,7 +1991,7 @@ namespace StardewModdingAPI.Framework
// read translation files
var translations = new Dictionary<string, IDictionary<string, string>>();
errors = new List<string>();
- DirectoryInfo translationsDir = new DirectoryInfo(folderPath);
+ DirectoryInfo translationsDir = new(folderPath);
if (translationsDir.Exists)
{
foreach (FileInfo file in translationsDir.EnumerateFiles("*.json"))
@@ -1875,7 +1999,7 @@ namespace StardewModdingAPI.Framework
string locale = Path.GetFileNameWithoutExtension(file.Name.ToLower().Trim());
try
{
- if (!jsonHelper.ReadJsonFileIfExists(file.FullName, out IDictionary<string, string> data) || data == null)
+ if (!jsonHelper.ReadJsonFileIfExists(file.FullName, out IDictionary<string, string>? data))
{
errors.Add($"{file.Name} file couldn't be read"); // mainly happens when the file is corrupted or empty
continue;
@@ -1894,8 +2018,8 @@ namespace StardewModdingAPI.Framework
foreach (string locale in translations.Keys.ToArray())
{
// handle duplicates
- HashSet<string> keys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
- HashSet<string> duplicateKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+ HashSet<string> keys = new(StringComparer.OrdinalIgnoreCase);
+ HashSet<string> duplicateKeys = new(StringComparer.OrdinalIgnoreCase);
foreach (string key in translations[locale].Keys.ToArray())
{
if (!keys.Add(key))
@@ -1923,7 +2047,7 @@ namespace StardewModdingAPI.Framework
{
// default path
{
- FileInfo defaultFile = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogFilename}.{Constants.LogExtension}"));
+ FileInfo defaultFile = new(Path.Combine(Constants.LogDir, $"{Constants.LogFilename}.{Constants.LogExtension}"));
if (!defaultFile.Exists)
return defaultFile.FullName;
}
@@ -1931,7 +2055,7 @@ namespace StardewModdingAPI.Framework
// get first disambiguated path
for (int i = 2; i < int.MaxValue; i++)
{
- FileInfo file = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogFilename}.player-{i}.{Constants.LogExtension}"));
+ FileInfo file = new(Path.Combine(Constants.LogDir, $"{Constants.LogFilename}.player-{i}.{Constants.LogExtension}"));
if (!file.Exists)
return file.FullName;
}
@@ -1943,7 +2067,7 @@ namespace StardewModdingAPI.Framework
/// <summary>Delete normal (non-crash) log files created by SMAPI.</summary>
private void PurgeNormalLogs()
{
- DirectoryInfo logsDir = new DirectoryInfo(Constants.LogDir);
+ DirectoryInfo logsDir = new(Constants.LogDir);
if (!logsDir.Exists)
return;
@@ -1985,5 +2109,15 @@ namespace StardewModdingAPI.Framework
return null;
}
+
+
+ /*********
+ ** Private types
+ *********/
+ /// <summary>A queued console command to run during the update loop.</summary>
+ /// <param name="Command">The command which can handle the input.</param>
+ /// <param name="Name">The parsed command name.</param>
+ /// <param name="Args">The parsed command arguments.</param>
+ private readonly record struct QueuedCommand(Command Command, string Name, string[] Args);
}
}
diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs
index 104cf330..0a8a068f 100644
--- a/src/SMAPI/Framework/SGame.cs
+++ b/src/SMAPI/Framework/SGame.cs
@@ -2,7 +2,6 @@ using System;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Text;
-using System.Threading.Tasks;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using StardewModdingAPI.Events;
@@ -37,7 +36,7 @@ namespace StardewModdingAPI.Framework
private readonly EventManager Events;
/// <summary>The maximum number of consecutive attempts SMAPI should make to recover from a draw error.</summary>
- private readonly Countdown DrawCrashTimer = new Countdown(60); // 60 ticks = roughly one second
+ private readonly Countdown DrawCrashTimer = new(60); // 60 ticks = roughly one second
/// <summary>Simplifies access to private game code.</summary>
private readonly Reflector Reflection;
@@ -46,10 +45,10 @@ namespace StardewModdingAPI.Framework
private readonly Action<string> ExitGameImmediately;
/// <summary>The initial override for <see cref="Input"/>. This value is null after initialization.</summary>
- private SInputState InitialInput;
+ private SInputState? InitialInput;
/// <summary>The initial override for <see cref="Multiplayer"/>. This value is null after initialization.</summary>
- private SMultiplayer InitialMultiplayer;
+ private SMultiplayer? InitialMultiplayer;
/// <summary>Raised when the instance is updating its state (roughly 60 times per second).</summary>
private readonly Action<SGame, GameTime, Action> OnUpdating;
@@ -64,21 +63,18 @@ namespace StardewModdingAPI.Framework
/// <summary>Manages input visible to the game.</summary>
public SInputState Input => (SInputState)Game1.input;
- /// <summary>The game background task which initializes a new day.</summary>
- public Task NewDayTask => Game1._newDayTask;
-
/// <summary>Monitors the entire game state for changes.</summary>
- public WatcherCore Watchers { get; private set; }
+ public WatcherCore Watchers { get; private set; } = null!; // initialized on first update tick
/// <summary>A snapshot of the current <see cref="Watchers"/> state.</summary>
- public WatcherSnapshot WatcherSnapshot { get; } = new WatcherSnapshot();
+ public WatcherSnapshot WatcherSnapshot { get; } = new();
/// <summary>Whether the current update tick is the first one for this instance.</summary>
public bool IsFirstTick = true;
/// <summary>The number of ticks until SMAPI should notify mods that the game has loaded.</summary>
/// <remarks>Skipping a few frames ensures the game finishes initializing the world before mods try to change it.</remarks>
- public Countdown AfterLoadTimer { get; } = new Countdown(5);
+ public Countdown AfterLoadTimer { get; } = new(5);
/// <summary>Whether the game is saving and SMAPI has already raised <see cref="IGameLoopEvents.Saving"/>.</summary>
public bool IsBetweenSaveEvents { get; set; }
@@ -92,7 +88,7 @@ namespace StardewModdingAPI.Framework
/// <summary>Construct a content manager to read game content files.</summary>
/// <remarks>This must be static because the game accesses it before the <see cref="SGame"/> constructor is called.</remarks>
[NonInstancedStatic]
- public static Func<IServiceProvider, string, LocalizedContentManager> CreateContentManagerImpl;
+ public static Func<IServiceProvider, string, LocalizedContentManager>? CreateContentManagerImpl;
/*********
@@ -136,11 +132,10 @@ namespace StardewModdingAPI.Framework
/// <remarks>This is intended for use by <see cref="Keybind"/> and shouldn't be used directly in most cases.</remarks>
internal static SButtonState GetInputState(SButton button)
{
- SInputState input = Game1.input as SInputState;
- if (input == null)
+ if (Game1.input is not SInputState inputHandler)
throw new InvalidOperationException("SMAPI's input state is not in a ready state yet.");
- return input.GetState(button);
+ return inputHandler.GetState(button);
}
/// <inheritdoc />
@@ -170,13 +165,11 @@ namespace StardewModdingAPI.Framework
{
base.Initialize();
- // The game resets public static fields after the class is constructed (see
- // GameRunner.SetInstanceDefaults), so SMAPI needs to re-override them here.
+ // The game resets public static fields after the class is constructed (see GameRunner.SetInstanceDefaults), so SMAPI needs to re-override them here.
Game1.input = this.InitialInput;
Game1.multiplayer = this.InitialMultiplayer;
- // The Initial* fields should no longer be used after this point, since mods may
- // further override them after initialization.
+ // The Initial* fields should no longer be used after this point, since mods may further override them after initialization.
this.InitialInput = null;
this.InitialMultiplayer = null;
}
@@ -249,6 +242,7 @@ namespace StardewModdingAPI.Framework
Context.IsInDrawLoop = false;
}
+#nullable disable
/// <summary>Replicate the game's draw logic with some changes for SMAPI.</summary>
/// <param name="gameTime">A snapshot of the game timing state.</param>
/// <param name="target_screen">The render target, if any.</param>
@@ -256,6 +250,7 @@ namespace StardewModdingAPI.Framework
[SuppressMessage("ReSharper", "CompareOfFloatsByEqualityOperator", Justification = "copied from game code as-is")]
[SuppressMessage("ReSharper", "InconsistentNaming", Justification = "copied from game code as-is")]
[SuppressMessage("ReSharper", "LocalVariableHidesMember", Justification = "copied from game code as-is")]
+ [SuppressMessage("ReSharper", "MergeIntoPattern", Justification = "copied from game code as-is")]
[SuppressMessage("ReSharper", "PossibleLossOfFraction", Justification = "copied from game code as-is")]
[SuppressMessage("ReSharper", "RedundantArgumentDefaultValue", Justification = "copied from game code as-is")]
[SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = "copied from game code as-is")]
@@ -263,8 +258,9 @@ namespace StardewModdingAPI.Framework
[SuppressMessage("ReSharper", "RedundantExplicitNullableCreation", Justification = "copied from game code as-is")]
[SuppressMessage("ReSharper", "RedundantTypeArgumentsOfMethod", Justification = "copied from game code as-is")]
[SuppressMessage("ReSharper", "IdentifierTypo", Justification = "copied from game code as-is")]
- [SuppressMessage("SMAPI.CommonErrors", "AvoidNetField", Justification = "copied from game code as-is")]
+ [SuppressMessage("ReSharper", "MergeIntoPattern", Justification = "copied from game code as-is")]
[SuppressMessage("SMAPI.CommonErrors", "AvoidImplicitNetFieldCast", Justification = "copied from game code as-is")]
+ [SuppressMessage("SMAPI.CommonErrors", "AvoidNetField", Justification = "copied from game code as-is")]
private void DrawImpl(GameTime gameTime, RenderTarget2D target_screen)
{
var events = this.Events;
@@ -624,7 +620,7 @@ namespace StardewModdingAPI.Framework
if (tile != null)
{
Vector2 vector_draw_position = Game1.GlobalToLocal(Game1.viewport, tile_position * 64f);
- Location draw_location = new Location((int)vector_draw_position.X, (int)vector_draw_position.Y);
+ Location draw_location = new((int)vector_draw_position.X, (int)vector_draw_position.Y);
Game1.mapDisplayDevice.DrawTile(tile, draw_location, (tile_position.Y * 64f - 1f) / 10000f);
}
}
@@ -950,5 +946,6 @@ namespace StardewModdingAPI.Framework
this.drawOverlays(Game1.spriteBatch);
Game1.PopUIMode();
}
+#nullable enable
}
}
diff --git a/src/SMAPI/Framework/SGameRunner.cs b/src/SMAPI/Framework/SGameRunner.cs
index b816bb7c..213fe561 100644
--- a/src/SMAPI/Framework/SGameRunner.cs
+++ b/src/SMAPI/Framework/SGameRunner.cs
@@ -93,7 +93,7 @@ namespace StardewModdingAPI.Framework
/// <param name="instanceIndex">The instance index.</param>
public override Game1 CreateGameInstance(PlayerIndex playerIndex = PlayerIndex.One, int instanceIndex = 0)
{
- SInputState inputState = new SInputState();
+ SInputState inputState = new();
return new SGame(playerIndex, instanceIndex, this.Monitor, this.Reflection, this.Events, inputState, this.ModHooks, this.Multiplayer, this.ExitGameImmediately, this.OnPlayerInstanceUpdating, this.OnGameContentLoaded);
}
@@ -148,7 +148,7 @@ namespace StardewModdingAPI.Framework
/// <summary>Update metadata when a split screen is added or removed.</summary>
private void UpdateForSplitScreenChanges()
{
- HashSet<int> oldScreenIds = new HashSet<int>(Context.ActiveScreenIds);
+ HashSet<int> oldScreenIds = new(Context.ActiveScreenIds);
// track active screens
Context.ActiveScreenIds.Clear();
diff --git a/src/SMAPI/Framework/SModHooks.cs b/src/SMAPI/Framework/SModHooks.cs
index 101e022a..a7736c8b 100644
--- a/src/SMAPI/Framework/SModHooks.cs
+++ b/src/SMAPI/Framework/SModHooks.cs
@@ -33,7 +33,7 @@ namespace StardewModdingAPI.Framework
/// <param name="action">The vanilla <see cref="Game1.newDayAfterFade"/> logic.</param>
public override void OnGame1_NewDayAfterFade(Action action)
{
- this.BeforeNewDayAfterFade?.Invoke();
+ this.BeforeNewDayAfterFade();
action();
}
diff --git a/src/SMAPI/Framework/SMultiplayer.cs b/src/SMAPI/Framework/SMultiplayer.cs
index 5956b63f..e41e7edc 100644
--- a/src/SMAPI/Framework/SMultiplayer.cs
+++ b/src/SMAPI/Framework/SMultiplayer.cs
@@ -56,10 +56,10 @@ namespace StardewModdingAPI.Framework
private readonly bool LogNetworkTraffic;
/// <summary>The backing field for <see cref="Peers"/>.</summary>
- private readonly PerScreen<IDictionary<long, MultiplayerPeer>> PeersImpl = new PerScreen<IDictionary<long, MultiplayerPeer>>(() => new Dictionary<long, MultiplayerPeer>());
+ private readonly PerScreen<IDictionary<long, MultiplayerPeer>> PeersImpl = new(() => new Dictionary<long, MultiplayerPeer>());
/// <summary>The backing field for <see cref="HostPeer"/>.</summary>
- private readonly PerScreen<MultiplayerPeer> HostPeerImpl = new PerScreen<MultiplayerPeer>();
+ private readonly PerScreen<MultiplayerPeer?> HostPeerImpl = new();
/*********
@@ -69,7 +69,7 @@ namespace StardewModdingAPI.Framework
public IDictionary<long, MultiplayerPeer> Peers => this.PeersImpl.Value;
/// <summary>The metadata for the host player, if the current player is a farmhand.</summary>
- public MultiplayerPeer HostPeer
+ public MultiplayerPeer? HostPeer
{
get => this.HostPeerImpl.Value;
private set => this.HostPeerImpl.Value = value;
@@ -111,20 +111,20 @@ namespace StardewModdingAPI.Framework
{
switch (client)
{
- case LidgrenClient _:
+ case LidgrenClient:
{
- string address = this.Reflection.GetField<string>(client, "address").GetValue();
+ string address = this.Reflection.GetField<string?>(client, "address").GetValue() ?? throw new InvalidOperationException("Can't initialize base networking client: no valid address found.");
return new SLidgrenClient(address, this.OnClientProcessingMessage, this.OnClientSendingMessage);
}
- case GalaxyNetClient _:
+ case GalaxyNetClient:
{
- GalaxyID address = this.Reflection.GetField<GalaxyID>(client, "lobbyId").GetValue();
+ GalaxyID address = this.Reflection.GetField<GalaxyID?>(client, "lobbyId").GetValue() ?? throw new InvalidOperationException("Can't initialize GOG networking client: no valid address found.");
return new SGalaxyNetClient(address, this.OnClientProcessingMessage, this.OnClientSendingMessage);
}
default:
- this.Monitor.Log($"Unknown multiplayer client type: {client.GetType().AssemblyQualifiedName}", LogLevel.Trace);
+ this.Monitor.Log($"Unknown multiplayer client type: {client.GetType().AssemblyQualifiedName}");
return client;
}
}
@@ -135,20 +135,20 @@ namespace StardewModdingAPI.Framework
{
switch (server)
{
- case LidgrenServer _:
+ case LidgrenServer:
{
- IGameServer gameServer = this.Reflection.GetField<IGameServer>(server, "gameServer").GetValue();
+ IGameServer gameServer = this.Reflection.GetField<IGameServer?>(server, "gameServer").GetValue() ?? throw new InvalidOperationException("Can't initialize base networking client: the required 'gameServer' field wasn't found.");
return new SLidgrenServer(gameServer, this, this.OnServerProcessingMessage);
}
- case GalaxyNetServer _:
+ case GalaxyNetServer:
{
- IGameServer gameServer = this.Reflection.GetField<IGameServer>(server, "gameServer").GetValue();
+ IGameServer gameServer = this.Reflection.GetField<IGameServer?>(server, "gameServer").GetValue() ?? throw new InvalidOperationException("Can't initialize GOG networking client: the required 'gameServer' field wasn't found.");
return new SGalaxyNetServer(gameServer, this, this.OnServerProcessingMessage);
}
default:
- this.Monitor.Log($"Unknown multiplayer server type: {server.GetType().AssemblyQualifiedName}", LogLevel.Trace);
+ this.Monitor.Log($"Unknown multiplayer server type: {server.GetType().AssemblyQualifiedName}");
return server;
}
}
@@ -160,7 +160,7 @@ namespace StardewModdingAPI.Framework
protected void OnClientSendingMessage(OutgoingMessage message, Action<OutgoingMessage> sendMessage, Action resume)
{
if (this.LogNetworkTraffic)
- this.Monitor.Log($"CLIENT SEND {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace);
+ this.Monitor.Log($"CLIENT SEND {(MessageType)message.MessageType} {message.FarmerID}");
switch (message.MessageType)
{
@@ -184,7 +184,7 @@ namespace StardewModdingAPI.Framework
public void OnServerProcessingMessage(IncomingMessage message, Action<OutgoingMessage> sendMessage, Action resume)
{
if (this.LogNetworkTraffic)
- this.Monitor.Log($"SERVER RECV {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace);
+ this.Monitor.Log($"SERVER RECV {(MessageType)message.MessageType} {message.FarmerID}");
switch (message.MessageType)
{
@@ -192,11 +192,11 @@ namespace StardewModdingAPI.Framework
case (byte)MessageType.ModContext:
{
// parse message
- RemoteContextModel model = this.ReadContext(message.Reader);
- this.Monitor.Log($"Received context for farmhand {message.FarmerID} running {(model != null ? $"SMAPI {model.ApiVersion} with {model.Mods.Length} mods" : "vanilla")}.", LogLevel.Trace);
+ RemoteContextModel? model = this.ReadContext(message.Reader);
+ this.Monitor.Log($"Received context for farmhand {message.FarmerID} running {(model != null ? $"SMAPI {model.ApiVersion} with {model.Mods.Length} mods" : "vanilla")}.");
// store peer
- MultiplayerPeer newPeer = new MultiplayerPeer(
+ MultiplayerPeer newPeer = new(
playerID: message.FarmerID,
screenID: this.GetScreenId(message.FarmerID),
model: model,
@@ -243,8 +243,8 @@ namespace StardewModdingAPI.Framework
// store peer if new
if (!this.Peers.ContainsKey(message.FarmerID))
{
- this.Monitor.Log($"Received connection for vanilla player {message.FarmerID}.", LogLevel.Trace);
- MultiplayerPeer peer = new MultiplayerPeer(
+ this.Monitor.Log($"Received connection for vanilla player {message.FarmerID}.");
+ MultiplayerPeer peer = new(
playerID: message.FarmerID,
screenID: this.GetScreenId(message.FarmerID),
model: null,
@@ -280,7 +280,7 @@ namespace StardewModdingAPI.Framework
public void OnClientProcessingMessage(IncomingMessage message, Action<OutgoingMessage> sendMessage, Action resume)
{
if (this.LogNetworkTraffic)
- this.Monitor.Log($"CLIENT RECV {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace);
+ this.Monitor.Log($"CLIENT RECV {(MessageType)message.MessageType} {message.FarmerID}");
switch (message.MessageType)
{
@@ -288,11 +288,11 @@ namespace StardewModdingAPI.Framework
case (byte)MessageType.ModContext:
{
// parse message
- RemoteContextModel model = this.ReadContext(message.Reader);
- this.Monitor.Log($"Received context for {(model?.IsHost == true ? "host" : "farmhand")} {message.FarmerID} running {(model != null ? $"SMAPI {model.ApiVersion} with {model.Mods.Length} mods" : "vanilla")}.", LogLevel.Trace);
+ RemoteContextModel? model = this.ReadContext(message.Reader);
+ this.Monitor.Log($"Received context for {(model?.IsHost == true ? "host" : "farmhand")} {message.FarmerID} running {(model != null ? $"SMAPI {model.ApiVersion} with {model.Mods.Length} mods" : "vanilla")}.");
// store peer
- MultiplayerPeer peer = new MultiplayerPeer(
+ MultiplayerPeer peer = new(
playerID: message.FarmerID,
screenID: this.GetScreenId(message.FarmerID),
model: model,
@@ -314,7 +314,7 @@ namespace StardewModdingAPI.Framework
// store peer
if (!this.Peers.ContainsKey(message.FarmerID) && this.HostPeer == null)
{
- this.Monitor.Log($"Received connection for vanilla host {message.FarmerID}.", LogLevel.Trace);
+ this.Monitor.Log($"Received connection for vanilla host {message.FarmerID}.");
var peer = new MultiplayerPeer(
playerID: message.FarmerID,
screenID: this.GetScreenId(message.FarmerID),
@@ -332,7 +332,7 @@ namespace StardewModdingAPI.Framework
case (byte)MessageType.PlayerIntroduction:
{
// store peer
- if (!this.Peers.TryGetValue(message.FarmerID, out MultiplayerPeer peer))
+ if (!this.Peers.TryGetValue(message.FarmerID, out MultiplayerPeer? peer))
{
peer = new MultiplayerPeer(
playerID: message.FarmerID,
@@ -341,7 +341,7 @@ namespace StardewModdingAPI.Framework
sendMessage: sendMessage,
isHost: this.HostPeer == null
);
- this.Monitor.Log($"Received connection for vanilla {(peer.IsHost ? "host" : "farmhand")} {message.FarmerID}.", LogLevel.Trace);
+ this.Monitor.Log($"Received connection for vanilla {(peer.IsHost ? "host" : "farmhand")} {message.FarmerID}.");
this.AddPeer(peer, canBeHost: true);
}
@@ -365,9 +365,9 @@ namespace StardewModdingAPI.Framework
{
foreach (long playerID in this.disconnectingFarmers)
{
- if (this.Peers.TryGetValue(playerID, out MultiplayerPeer peer))
+ if (this.Peers.TryGetValue(playerID, out MultiplayerPeer? peer))
{
- this.Monitor.Log($"Player quit: {playerID}", LogLevel.Trace);
+ this.Monitor.Log($"Player quit: {playerID}");
this.Peers.Remove(playerID);
this.EventManager.PeerDisconnected.Raise(new PeerDisconnectedEventArgs(peer));
}
@@ -382,7 +382,7 @@ namespace StardewModdingAPI.Framework
/// <param name="fromModID">The unique ID of the mod sending the message.</param>
/// <param name="toModIDs">The mod IDs which should receive the message on the destination computers, or <c>null</c> for all mods. Specifying mod IDs is recommended to improve performance, unless it's a general-purpose broadcast.</param>
/// <param name="toPlayerIDs">The <see cref="Farmer.UniqueMultiplayerID" /> values for the players who should receive the message, or <c>null</c> for all players. If you don't need to broadcast to all players, specifying player IDs is recommended to reduce latency.</param>
- public void BroadcastModMessage<TMessage>(TMessage message, string messageType, string fromModID, string[] toModIDs, long[] toPlayerIDs)
+ public void BroadcastModMessage<TMessage>(TMessage message, string messageType, string fromModID, string[]? toModIDs, long[]? toPlayerIDs)
{
// validate input
if (message == null)
@@ -420,7 +420,7 @@ namespace StardewModdingAPI.Framework
}
// get data to send
- ModMessageModel model = new ModMessageModel(
+ ModMessageModel model = new(
fromPlayerID: Game1.player.UniqueMultiplayerID,
fromModID: fromModID,
toModIDs: toModIDs,
@@ -434,7 +434,7 @@ namespace StardewModdingAPI.Framework
if (sendToSelf)
{
if (this.LogNetworkTraffic)
- this.Monitor.Log($"Broadcasting '{messageType}' message to self: {data}.", LogLevel.Trace);
+ this.Monitor.Log($"Broadcasting '{messageType}' message to self: {data}.");
this.OnModMessageReceived(model);
}
@@ -447,7 +447,7 @@ namespace StardewModdingAPI.Framework
foreach (MultiplayerPeer peer in sendToPeers)
{
if (this.LogNetworkTraffic)
- this.Monitor.Log($"Broadcasting '{messageType}' message to farmhand {peer.PlayerID}: {data}.", LogLevel.Trace);
+ this.Monitor.Log($"Broadcasting '{messageType}' message to farmhand {peer.PlayerID}: {data}.");
peer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, peer.PlayerID, data));
}
@@ -455,7 +455,7 @@ namespace StardewModdingAPI.Framework
else if (this.HostPeer?.HasSmapi == true)
{
if (this.LogNetworkTraffic)
- this.Monitor.Log($"Broadcasting '{messageType}' message to host {this.HostPeer.PlayerID}: {data}.", LogLevel.Trace);
+ this.Monitor.Log($"Broadcasting '{messageType}' message to host {this.HostPeer.PlayerID}: {data}.");
this.HostPeer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, this.HostPeer.PlayerID, data));
}
@@ -486,13 +486,13 @@ namespace StardewModdingAPI.Framework
/// <summary>Read the metadata context for a player.</summary>
/// <param name="reader">The stream reader.</param>
- private RemoteContextModel ReadContext(BinaryReader reader)
+ private RemoteContextModel? ReadContext(BinaryReader reader)
{
string data = reader.ReadString();
RemoteContextModel model = this.JsonHelper.Deserialize<RemoteContextModel>(data);
return model.ApiVersion != null
? model
- : null; // no data available for unmodded players
+ : null; // no data available for vanilla players
}
/// <summary>Receive a mod message sent from another player's mods.</summary>
@@ -504,7 +504,7 @@ namespace StardewModdingAPI.Framework
ModMessageModel model = this.JsonHelper.Deserialize<ModMessageModel>(json);
HashSet<long> playerIDs = new HashSet<long>(model.ToPlayerIDs ?? this.GetKnownPlayerIDs());
if (this.LogNetworkTraffic)
- this.Monitor.Log($"Received message: {json}.", LogLevel.Trace);
+ this.Monitor.Log($"Received message: {json}.");
// notify local mods
if (playerIDs.Contains(Game1.player.UniqueMultiplayerID))
@@ -513,12 +513,15 @@ namespace StardewModdingAPI.Framework
// forward to other players
if (Context.IsMainPlayer && playerIDs.Any(p => p != Game1.player.UniqueMultiplayerID))
{
- ModMessageModel newModel = new ModMessageModel(model);
foreach (long playerID in playerIDs)
{
- if (playerID != Game1.player.UniqueMultiplayerID && playerID != model.FromPlayerID && this.Peers.TryGetValue(playerID, out MultiplayerPeer peer))
+ if (playerID != Game1.player.UniqueMultiplayerID && playerID != model.FromPlayerID && this.Peers.TryGetValue(playerID, out MultiplayerPeer? peer))
{
- newModel.ToPlayerIDs = new[] { peer.PlayerID };
+ ModMessageModel newModel = new(model)
+ {
+ ToPlayerIDs = new[] { peer.PlayerID }
+ };
+
this.Monitor.VerboseLog($" Forwarding message to player {peer.PlayerID}.");
peer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, peer.PlayerID, this.JsonHelper.Serialize(newModel, Formatting.None)));
}
@@ -544,22 +547,20 @@ namespace StardewModdingAPI.Framework
/// <summary>Get the fields to include in a context sync message sent to other players.</summary>
private object[] GetContextSyncMessageFields()
{
- RemoteContextModel model = new RemoteContextModel
- {
- IsHost = Context.IsWorldReady && Context.IsMainPlayer,
- Platform = Constants.TargetPlatform,
- ApiVersion = Constants.ApiVersion,
- GameVersion = Constants.GameVersion,
- Mods = this.ModRegistry
+ RemoteContextModel model = new(
+ isHost: Context.IsWorldReady && Context.IsMainPlayer,
+ platform: Constants.TargetPlatform,
+ apiVersion: Constants.ApiVersion,
+ gameVersion: Constants.GameVersion,
+ mods: this.ModRegistry
.GetAll()
- .Select(mod => new RemoteContextModModel
- {
- ID = mod.Manifest.UniqueID,
- Name = mod.Manifest.Name,
- Version = mod.Manifest.Version
- })
+ .Select(mod => new RemoteContextModModel(
+ id: mod.Manifest.UniqueID,
+ name: mod.Manifest.Name,
+ version: mod.Manifest.Version
+ ))
.ToArray()
- };
+ );
return new object[] { this.JsonHelper.Serialize(model, Formatting.None) };
}
@@ -571,21 +572,19 @@ namespace StardewModdingAPI.Framework
if (!peer.HasSmapi)
return new object[] { "{}" };
- RemoteContextModel model = new RemoteContextModel
- {
- IsHost = peer.IsHost,
- Platform = peer.Platform.Value,
- ApiVersion = peer.ApiVersion,
- GameVersion = peer.GameVersion,
- Mods = peer.Mods
- .Select(mod => new RemoteContextModModel
- {
- ID = mod.ID,
- Name = mod.Name,
- Version = mod.Version
- })
+ RemoteContextModel model = new(
+ isHost: peer.IsHost,
+ platform: peer.Platform.Value,
+ apiVersion: peer.ApiVersion,
+ gameVersion: peer.GameVersion,
+ mods: peer.Mods
+ .Select(mod => new RemoteContextModModel(
+ id: mod.ID,
+ name: mod.Name,
+ version: mod.Version
+ ))
.ToArray()
- };
+ );
return new object[] { this.JsonHelper.Serialize(model, Formatting.None) };
}
diff --git a/src/SMAPI/Framework/Serialization/KeybindConverter.cs b/src/SMAPI/Framework/Serialization/KeybindConverter.cs
index 93a274a8..539f1291 100644
--- a/src/SMAPI/Framework/Serialization/KeybindConverter.cs
+++ b/src/SMAPI/Framework/Serialization/KeybindConverter.cs
@@ -36,7 +36,7 @@ namespace StardewModdingAPI.Framework.Serialization
/// <param name="objectType">The object type.</param>
/// <param name="existingValue">The object being read.</param>
/// <param name="serializer">The calling serializer.</param>
- public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
+ public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
string path = reader.Path;
@@ -44,7 +44,7 @@ namespace StardewModdingAPI.Framework.Serialization
{
case JsonToken.Null:
return objectType == typeof(Keybind)
- ? (object)new Keybind()
+ ? new Keybind()
: new KeybindList();
case JsonToken.String:
@@ -53,13 +53,13 @@ namespace StardewModdingAPI.Framework.Serialization
if (objectType == typeof(Keybind))
{
- return Keybind.TryParse(str, out Keybind parsed, out string[] errors)
+ return Keybind.TryParse(str, out Keybind? parsed, out string[] errors)
? parsed
: throw new SParseException($"Can't parse {nameof(Keybind)} from invalid value '{str}' (path: {path}).\n{string.Join("\n", errors)}");
}
else
{
- return KeybindList.TryParse(str, out KeybindList parsed, out string[] errors)
+ return KeybindList.TryParse(str, out KeybindList? parsed, out string[] errors)
? parsed
: throw new SParseException($"Can't parse {nameof(KeybindList)} from invalid value '{str}' (path: {path}).\n{string.Join("\n", errors)}");
}
@@ -74,7 +74,7 @@ namespace StardewModdingAPI.Framework.Serialization
/// <param name="writer">The JSON writer.</param>
/// <param name="value">The value.</param>
/// <param name="serializer">The calling serializer.</param>
- public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
+ public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
writer.WriteValue(value?.ToString());
}
diff --git a/src/SMAPI/Framework/Singleton.cs b/src/SMAPI/Framework/Singleton.cs
index 399a8bf0..1bf318c4 100644
--- a/src/SMAPI/Framework/Singleton.cs
+++ b/src/SMAPI/Framework/Singleton.cs
@@ -5,6 +5,6 @@ namespace StardewModdingAPI.Framework
internal static class Singleton<T> where T : new()
{
/// <summary>The singleton instance.</summary>
- public static T Instance { get; } = new T();
+ public static T Instance { get; } = new();
}
}
diff --git a/src/SMAPI/Framework/SnapshotDiff.cs b/src/SMAPI/Framework/SnapshotDiff.cs
index 5b6288ff..d659d2b4 100644
--- a/src/SMAPI/Framework/SnapshotDiff.cs
+++ b/src/SMAPI/Framework/SnapshotDiff.cs
@@ -13,10 +13,10 @@ namespace StardewModdingAPI.Framework
public bool IsChanged { get; private set; }
/// <summary>The previous value.</summary>
- public T Old { get; private set; }
+ public T? Old { get; private set; }
/// <summary>The current value.</summary>
- public T New { get; private set; }
+ public T? New { get; private set; }
/*********
diff --git a/src/SMAPI/Framework/SnapshotItemListDiff.cs b/src/SMAPI/Framework/SnapshotItemListDiff.cs
index e8ab1b1e..76060db2 100644
--- a/src/SMAPI/Framework/SnapshotItemListDiff.cs
+++ b/src/SMAPI/Framework/SnapshotItemListDiff.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Linq;
using StardewModdingAPI.Events;
using StardewValley;
@@ -46,7 +47,7 @@ namespace StardewModdingAPI.Framework
/// <param name="stackSizes">The items with their previous stack sizes.</param>
/// <param name="changes">The inventory changes, or <c>null</c> if nothing changed.</param>
/// <returns>Returns whether anything changed.</returns>
- public static bool TryGetChanges(ISet<Item> added, ISet<Item> removed, IDictionary<Item, int> stackSizes, out SnapshotItemListDiff changes)
+ public static bool TryGetChanges(ISet<Item> added, ISet<Item> removed, IDictionary<Item, int> stackSizes, [NotNullWhen(true)] out SnapshotItemListDiff? changes)
{
KeyValuePair<Item, int>[] sizesChanged = stackSizes.Where(p => p.Key.Stack != p.Value).ToArray();
if (sizesChanged.Any() || added.Any() || removed.Any())
diff --git a/src/SMAPI/Framework/SnapshotListDiff.cs b/src/SMAPI/Framework/SnapshotListDiff.cs
index 2d0efa0d..90066af1 100644
--- a/src/SMAPI/Framework/SnapshotListDiff.cs
+++ b/src/SMAPI/Framework/SnapshotListDiff.cs
@@ -11,10 +11,10 @@ namespace StardewModdingAPI.Framework
** Fields
*********/
/// <summary>The removed values.</summary>
- private readonly List<T> RemovedImpl = new List<T>();
+ private readonly List<T> RemovedImpl = new();
/// <summary>The added values.</summary>
- private readonly List<T> AddedImpl = new List<T>();
+ private readonly List<T> AddedImpl = new();
/*********
@@ -37,7 +37,7 @@ namespace StardewModdingAPI.Framework
/// <param name="isChanged">Whether the value changed since the last update.</param>
/// <param name="removed">The removed values.</param>
/// <param name="added">The added values.</param>
- public void Update(bool isChanged, IEnumerable<T> removed, IEnumerable<T> added)
+ public void Update(bool isChanged, IEnumerable<T>? removed, IEnumerable<T>? added)
{
this.IsChanged = isChanged;
diff --git a/src/SMAPI/Framework/StateTracking/ChestTracker.cs b/src/SMAPI/Framework/StateTracking/ChestTracker.cs
index 65f58ee7..c33a7498 100644
--- a/src/SMAPI/Framework/StateTracking/ChestTracker.cs
+++ b/src/SMAPI/Framework/StateTracking/ChestTracker.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Linq;
using StardewModdingAPI.Framework.StateTracking.Comparers;
using StardewModdingAPI.Framework.StateTracking.FieldWatchers;
@@ -18,10 +19,10 @@ namespace StardewModdingAPI.Framework.StateTracking
private readonly IDictionary<Item, int> StackSizes;
/// <summary>Items added since the last update.</summary>
- private readonly HashSet<Item> Added = new HashSet<Item>(new ObjectReferenceComparer<Item>());
+ private readonly HashSet<Item> Added = new(new ObjectReferenceComparer<Item>());
/// <summary>Items removed since the last update.</summary>
- private readonly HashSet<Item> Removed = new HashSet<Item>(new ObjectReferenceComparer<Item>());
+ private readonly HashSet<Item> Removed = new(new ObjectReferenceComparer<Item>());
/// <summary>The underlying inventory watcher.</summary>
private readonly ICollectionWatcher<Item> InventoryWatcher;
@@ -84,7 +85,7 @@ namespace StardewModdingAPI.Framework.StateTracking
/// <summary>Get the inventory changes since the last update, if anything changed.</summary>
/// <param name="changes">The inventory changes, or <c>null</c> if nothing changed.</param>
/// <returns>Returns whether anything changed.</returns>
- public bool TryGetInventoryChanges(out SnapshotItemListDiff changes)
+ public bool TryGetInventoryChanges([NotNullWhen(true)] out SnapshotItemListDiff? changes)
{
return SnapshotItemListDiff.TryGetChanges(added: this.Added, removed: this.Removed, stackSizes: this.StackSizes, out changes);
}
diff --git a/src/SMAPI/Framework/StateTracking/Comparers/EquatableComparer.cs b/src/SMAPI/Framework/StateTracking/Comparers/EquatableComparer.cs
index a96ffdb6..9d8559b4 100644
--- a/src/SMAPI/Framework/StateTracking/Comparers/EquatableComparer.cs
+++ b/src/SMAPI/Framework/StateTracking/Comparers/EquatableComparer.cs
@@ -15,7 +15,7 @@ namespace StardewModdingAPI.Framework.StateTracking.Comparers
/// <returns>true if the specified objects are equal; otherwise, false.</returns>
/// <param name="x">The first object to compare.</param>
/// <param name="y">The second object to compare.</param>
- public bool Equals(T x, T y)
+ public bool Equals(T? x, T? y)
{
if (x == null)
return y == null;
diff --git a/src/SMAPI/Framework/StateTracking/Comparers/GenericEqualsComparer.cs b/src/SMAPI/Framework/StateTracking/Comparers/GenericEqualsComparer.cs
index cc1d6553..41b17e10 100644
--- a/src/SMAPI/Framework/StateTracking/Comparers/GenericEqualsComparer.cs
+++ b/src/SMAPI/Framework/StateTracking/Comparers/GenericEqualsComparer.cs
@@ -14,7 +14,7 @@ namespace StardewModdingAPI.Framework.StateTracking.Comparers
/// <returns>true if the specified objects are equal; otherwise, false.</returns>
/// <param name="x">The first object to compare.</param>
/// <param name="y">The second object to compare.</param>
- public bool Equals(T x, T y)
+ public bool Equals(T? x, T? y)
{
if (x == null)
return y == null;
diff --git a/src/SMAPI/Framework/StateTracking/Comparers/ObjectReferenceComparer.cs b/src/SMAPI/Framework/StateTracking/Comparers/ObjectReferenceComparer.cs
index ef9adafb..e6ece854 100644
--- a/src/SMAPI/Framework/StateTracking/Comparers/ObjectReferenceComparer.cs
+++ b/src/SMAPI/Framework/StateTracking/Comparers/ObjectReferenceComparer.cs
@@ -14,7 +14,7 @@ namespace StardewModdingAPI.Framework.StateTracking.Comparers
/// <returns>true if the specified objects are equal; otherwise, false.</returns>
/// <param name="x">The first object to compare.</param>
/// <param name="y">The second object to compare.</param>
- public bool Equals(T x, T y)
+ public bool Equals(T? x, T? y)
{
return object.ReferenceEquals(x, y);
}
diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs
index 32ec8c7e..256370ce 100644
--- a/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs
+++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs
@@ -17,10 +17,10 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
private HashSet<TValue> LastValues;
/// <summary>The pairs added since the last reset.</summary>
- private readonly List<TValue> AddedImpl = new List<TValue>();
+ private readonly List<TValue> AddedImpl = new();
/// <summary>The pairs removed since the last reset.</summary>
- private readonly List<TValue> RemovedImpl = new List<TValue>();
+ private readonly List<TValue> RemovedImpl = new();
/*********
diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableWatcher.cs
index 5ca4b9f4..5f76fe0a 100644
--- a/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableWatcher.cs
+++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableWatcher.cs
@@ -40,7 +40,8 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
{
this.GetValue = getValue;
this.Comparer = comparer;
- this.PreviousValue = getValue();
+ this.CurrentValue = getValue();
+ this.PreviousValue = this.CurrentValue;
}
/// <summary>Update the current value if needed.</summary>
diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/ImmutableCollectionWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/ImmutableCollectionWatcher.cs
index 30e6274f..84340fbf 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
@@ -10,16 +11,16 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
** Accessors
*********/
/// <summary>A singleton collection watcher instance.</summary>
- public static ImmutableCollectionWatcher<TValue> Instance { get; } = new ImmutableCollectionWatcher<TValue>();
+ public static ImmutableCollectionWatcher<TValue> Instance { get; } = new();
/// <summary>Whether the collection changed since the last reset.</summary>
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/FieldWatchers/NetCollectionWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/NetCollectionWatcher.cs
index 21e84c47..676c9fb4 100644
--- a/src/SMAPI/Framework/StateTracking/FieldWatchers/NetCollectionWatcher.cs
+++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/NetCollectionWatcher.cs
@@ -15,10 +15,10 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
private readonly NetCollection<TValue> Field;
/// <summary>The pairs added since the last reset.</summary>
- private readonly List<TValue> AddedImpl = new List<TValue>();
+ private readonly List<TValue> AddedImpl = new();
/// <summary>The pairs removed since the last reset.</summary>
- private readonly List<TValue> RemovedImpl = new List<TValue>();
+ private readonly List<TValue> RemovedImpl = new();
/*********
diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/NetDictionaryWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/NetDictionaryWatcher.cs
index e6882f7e..f55e4cea 100644
--- a/src/SMAPI/Framework/StateTracking/FieldWatchers/NetDictionaryWatcher.cs
+++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/NetDictionaryWatcher.cs
@@ -10,6 +10,7 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
/// <typeparam name="TSerialDict">The serializable dictionary type that can store the keys and values.</typeparam>
/// <typeparam name="TSelf">The net field instance type.</typeparam>
internal class NetDictionaryWatcher<TKey, TValue, TField, TSerialDict, TSelf> : BaseDisposableWatcher, IDictionaryWatcher<TKey, TValue>
+ where TKey : notnull
where TField : class, INetObject<INetSerializable>, new()
where TSerialDict : IDictionary<TKey, TValue>, new()
where TSelf : NetDictionary<TKey, TValue, TField, TSerialDict, TSelf>
diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/ObservableCollectionWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/ObservableCollectionWatcher.cs
index c29d2783..97aedca8 100644
--- a/src/SMAPI/Framework/StateTracking/FieldWatchers/ObservableCollectionWatcher.cs
+++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/ObservableCollectionWatcher.cs
@@ -16,13 +16,13 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
private readonly ObservableCollection<TValue> Field;
/// <summary>The pairs added since the last reset.</summary>
- private readonly List<TValue> AddedImpl = new List<TValue>();
+ private readonly List<TValue> AddedImpl = new();
/// <summary>The pairs removed since the last reset.</summary>
- private readonly List<TValue> RemovedImpl = new List<TValue>();
+ private readonly List<TValue> RemovedImpl = new();
/// <summary>The previous values as of the last update.</summary>
- private readonly List<TValue> PreviousValues = new List<TValue>();
+ private readonly List<TValue> PreviousValues = new();
/*********
@@ -79,7 +79,7 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
/// <summary>A callback invoked when an entry is added or removed from the collection.</summary>
/// <param name="sender">The event sender.</param>
/// <param name="e">The event arguments.</param>
- private void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
+ private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.Action == NotifyCollectionChangedAction.Reset)
{
@@ -88,8 +88,8 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
}
else
{
- TValue[] added = e.NewItems?.Cast<TValue>().ToArray();
- TValue[] removed = e.OldItems?.Cast<TValue>().ToArray();
+ TValue[]? added = e.NewItems?.Cast<TValue>().ToArray();
+ TValue[]? removed = e.OldItems?.Cast<TValue>().ToArray();
if (removed != null)
{
diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs
index bde43486..c4a4d0b9 100644
--- a/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs
+++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs
@@ -18,7 +18,8 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
/// <summary>Get a watcher which compares values using their <see cref="object.Equals(object)"/> method. This method should only be used when <see cref="ForEquatable{T}"/> won't work, since this doesn't validate whether they're comparable.</summary>
/// <typeparam name="T">The value type.</typeparam>
/// <param name="getValue">Get the current value.</param>
- public static IValueWatcher<T> ForGenericEquality<T>(Func<T> getValue) where T : struct
+ public static IValueWatcher<T> ForGenericEquality<T>(Func<T> getValue)
+ where T : struct
{
return new ComparableWatcher<T>(getValue, new GenericEqualsComparer<T>());
}
@@ -26,7 +27,8 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
/// <summary>Get a watcher for an <see cref="IEquatable{T}"/> value.</summary>
/// <typeparam name="T">The value type.</typeparam>
/// <param name="getValue">Get the current value.</param>
- public static IValueWatcher<T> ForEquatable<T>(Func<T> getValue) where T : IEquatable<T>
+ public static IValueWatcher<T> ForEquatable<T>(Func<T> getValue)
+ where T : IEquatable<T>
{
return new ComparableWatcher<T>(getValue, new EquatableComparer<T>());
}
@@ -77,7 +79,8 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
/// <summary>Get a watcher for a net collection.</summary>
/// <typeparam name="T">The value type.</typeparam>
/// <param name="collection">The net collection.</param>
- public static ICollectionWatcher<T> ForNetCollection<T>(NetCollection<T> collection) where T : class, INetObject<INetSerializable>
+ public static ICollectionWatcher<T> ForNetCollection<T>(NetCollection<T> collection)
+ where T : class, INetObject<INetSerializable>
{
return new NetCollectionWatcher<T>(collection);
}
@@ -85,7 +88,8 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
/// <summary>Get a watcher for a net list.</summary>
/// <typeparam name="T">The value type.</typeparam>
/// <param name="collection">The net list.</param>
- public static ICollectionWatcher<T> ForNetList<T>(NetList<T, NetRef<T>> collection) where T : class, INetObject<INetSerializable>
+ public static ICollectionWatcher<T> ForNetList<T>(NetList<T, NetRef<T>> collection)
+ where T : class, INetObject<INetSerializable>
{
return new NetListWatcher<T>(collection);
}
@@ -98,6 +102,7 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
/// <typeparam name="TSelf">The net field instance type.</typeparam>
/// <param name="field">The net field.</param>
public static NetDictionaryWatcher<TKey, TValue, TField, TSerialDict, TSelf> ForNetDictionary<TKey, TValue, TField, TSerialDict, TSelf>(NetDictionary<TKey, TValue, TField, TSerialDict, TSelf> field)
+ where TKey : notnull
where TField : class, INetObject<INetSerializable>, new()
where TSerialDict : IDictionary<TKey, TValue>, new()
where TSelf : NetDictionary<TKey, TValue, TField, TSerialDict, TSelf>
diff --git a/src/SMAPI/Framework/StateTracking/LocationTracker.cs b/src/SMAPI/Framework/StateTracking/LocationTracker.cs
index 6d3a62bb..ff72a19b 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;
@@ -18,7 +19,7 @@ namespace StardewModdingAPI.Framework.StateTracking
** Fields
*********/
/// <summary>The underlying watchers.</summary>
- private readonly List<IWatcher> Watchers = new List<IWatcher>();
+ private readonly List<IWatcher> Watchers = new();
/*********
@@ -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>
@@ -129,20 +130,20 @@ namespace StardewModdingAPI.Framework.StateTracking
private void UpdateChestWatcherList(IEnumerable<KeyValuePair<Vector2, SObject>> added, IEnumerable<KeyValuePair<Vector2, SObject>> removed)
{
// remove unused watchers
- foreach (KeyValuePair<Vector2, SObject> pair in removed)
+ foreach ((Vector2 tile, SObject? obj) in removed)
{
- if (pair.Value is Chest && this.ChestWatchers.TryGetValue(pair.Key, out ChestTracker watcher))
+ if (obj is Chest && this.ChestWatchers.TryGetValue(tile, out ChestTracker? watcher))
{
watcher.Dispose();
- this.ChestWatchers.Remove(pair.Key);
+ this.ChestWatchers.Remove(tile);
}
}
// add new watchers
- foreach (KeyValuePair<Vector2, SObject> pair in added)
+ foreach ((Vector2 tile, SObject? obj) in added)
{
- if (pair.Value is Chest chest && !this.ChestWatchers.ContainsKey(pair.Key))
- this.ChestWatchers.Add(pair.Key, new ChestTracker(chest));
+ if (obj is Chest chest && !this.ChestWatchers.ContainsKey(tile))
+ this.ChestWatchers.Add(tile, new ChestTracker(chest));
}
}
}
diff --git a/src/SMAPI/Framework/StateTracking/PlayerTracker.cs b/src/SMAPI/Framework/StateTracking/PlayerTracker.cs
index cf49a7c1..5433ac8e 100644
--- a/src/SMAPI/Framework/StateTracking/PlayerTracker.cs
+++ b/src/SMAPI/Framework/StateTracking/PlayerTracker.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Linq;
using StardewModdingAPI.Enums;
using StardewModdingAPI.Framework.StateTracking.Comparers;
@@ -21,10 +22,10 @@ namespace StardewModdingAPI.Framework.StateTracking
private IDictionary<Item, int> CurrentInventory;
/// <summary>The player's last valid location.</summary>
- private GameLocation LastValidLocation;
+ private GameLocation? LastValidLocation;
/// <summary>The underlying watchers.</summary>
- private readonly List<IWatcher> Watchers = new List<IWatcher>();
+ private readonly List<IWatcher> Watchers = new();
/*********
@@ -34,7 +35,7 @@ namespace StardewModdingAPI.Framework.StateTracking
public Farmer Player { get; }
/// <summary>The player's current location.</summary>
- public IValueWatcher<GameLocation> LocationWatcher { get; }
+ public IValueWatcher<GameLocation?> LocationWatcher { get; }
/// <summary>Tracks changes to the player's skill levels.</summary>
public IDictionary<SkillType, IValueWatcher<int>> SkillWatchers { get; }
@@ -49,7 +50,8 @@ namespace StardewModdingAPI.Framework.StateTracking
{
// init player data
this.Player = player;
- this.PreviousInventory = this.GetInventory();
+ this.CurrentInventory = this.GetInventory();
+ this.PreviousInventory = new Dictionary<Item, int>(this.CurrentInventory);
// init trackers
this.LocationWatcher = WatcherFactory.ForReference(this.GetCurrentLocation);
@@ -93,7 +95,7 @@ namespace StardewModdingAPI.Framework.StateTracking
/// <summary>Get the player's current location, ignoring temporary null values.</summary>
/// <remarks>The game will set <see cref="Character.currentLocation"/> to null in some cases, e.g. when they're a secondary player in multiplayer and transition to a location that hasn't been synced yet. While that's happening, this returns the player's last valid location instead.</remarks>
- public GameLocation GetCurrentLocation()
+ public GameLocation? GetCurrentLocation()
{
return this.Player.currentLocation ?? this.LastValidLocation;
}
@@ -101,7 +103,7 @@ namespace StardewModdingAPI.Framework.StateTracking
/// <summary>Get the inventory changes since the last update, if anything changed.</summary>
/// <param name="changes">The inventory changes, or <c>null</c> if nothing changed.</param>
/// <returns>Returns whether anything changed.</returns>
- public bool TryGetInventoryChanges(out SnapshotItemListDiff changes)
+ public bool TryGetInventoryChanges([NotNullWhen(true)] out SnapshotItemListDiff? changes)
{
IDictionary<Item, int> current = this.GetInventory();
@@ -122,7 +124,7 @@ namespace StardewModdingAPI.Framework.StateTracking
public void Dispose()
{
this.PreviousInventory.Clear();
- this.CurrentInventory?.Clear();
+ this.CurrentInventory.Clear();
foreach (IWatcher watcher in this.Watchers)
watcher.Dispose();
diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs
index 6c9cc4f5..0d0469d7 100644
--- a/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs
+++ b/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs
@@ -17,25 +17,25 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
public GameLocation Location { get; }
/// <summary>Tracks added or removed buildings.</summary>
- public SnapshotListDiff<Building> Buildings { get; } = new SnapshotListDiff<Building>();
+ public SnapshotListDiff<Building> Buildings { get; } = new();
/// <summary>Tracks added or removed debris.</summary>
- public SnapshotListDiff<Debris> Debris { get; } = new SnapshotListDiff<Debris>();
+ public SnapshotListDiff<Debris> Debris { get; } = new();
/// <summary>Tracks added or removed large terrain features.</summary>
- public SnapshotListDiff<LargeTerrainFeature> LargeTerrainFeatures { get; } = new SnapshotListDiff<LargeTerrainFeature>();
+ public SnapshotListDiff<LargeTerrainFeature> LargeTerrainFeatures { get; } = new();
/// <summary>Tracks added or removed NPCs.</summary>
- public SnapshotListDiff<NPC> Npcs { get; } = new SnapshotListDiff<NPC>();
+ public SnapshotListDiff<NPC> Npcs { get; } = new();
/// <summary>Tracks added or removed objects.</summary>
- public SnapshotListDiff<KeyValuePair<Vector2, Object>> Objects { get; } = new SnapshotListDiff<KeyValuePair<Vector2, Object>>();
+ public SnapshotListDiff<KeyValuePair<Vector2, Object>> Objects { get; } = new();
/// <summary>Tracks added or removed terrain features.</summary>
- public SnapshotListDiff<KeyValuePair<Vector2, TerrainFeature>> TerrainFeatures { get; } = new SnapshotListDiff<KeyValuePair<Vector2, TerrainFeature>>();
+ public SnapshotListDiff<KeyValuePair<Vector2, TerrainFeature>> TerrainFeatures { get; } = new();
/// <summary>Tracks added or removed furniture.</summary>
- public SnapshotListDiff<Furniture> Furniture { get; } = new SnapshotListDiff<Furniture>();
+ public SnapshotListDiff<Furniture> Furniture { get; } = new();
/// <summary>Tracks changed chest inventories.</summary>
public IDictionary<Chest, SnapshotItemListDiff> ChestItems { get; } = new Dictionary<Chest, SnapshotItemListDiff>();
@@ -68,7 +68,7 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
this.ChestItems.Clear();
foreach (ChestTracker tracker in watcher.ChestWatchers.Values)
{
- if (tracker.TryGetInventoryChanges(out SnapshotItemListDiff changes))
+ if (tracker.TryGetInventoryChanges(out SnapshotItemListDiff? changes))
this.ChestItems[tracker.Chest] = changes;
}
}
diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs
index 0908b02a..6a24ec30 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(Array.Empty<Item>(), Array.Empty<Item>(), Array.Empty<ItemStackSizeChange>());
/*********
@@ -24,14 +24,14 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
public Farmer Player { get; }
/// <summary>The player's current location.</summary>
- public SnapshotDiff<GameLocation> Location { get; } = new SnapshotDiff<GameLocation>();
+ public SnapshotDiff<GameLocation> Location { get; } = new();
/// <summary>Tracks changes to the player's skill levels.</summary>
public IDictionary<SkillType, SnapshotDiff<int>> Skills { get; } =
Enum
.GetValues(typeof(SkillType))
.Cast<SkillType>()
- .ToDictionary(skill => skill, skill => new SnapshotDiff<int>());
+ .ToDictionary(skill => skill, _ => new SnapshotDiff<int>());
/// <summary>Get a list of inventory changes.</summary>
public SnapshotItemListDiff Inventory { get; private set; }
@@ -45,17 +45,18 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
public PlayerSnapshot(Farmer player)
{
this.Player = player;
+ this.Inventory = this.EmptyItemListDiff;
}
/// <summary>Update the tracked values.</summary>
/// <param name="watcher">The player watcher to snapshot.</param>
public void Update(PlayerTracker watcher)
{
- this.Location.Update(watcher.LocationWatcher);
- foreach (var pair in this.Skills)
- pair.Value.Update(watcher.SkillWatchers[pair.Key]);
+ this.Location.Update(watcher.LocationWatcher!);
+ foreach ((SkillType skill, var value) in this.Skills)
+ value.Update(watcher.SkillWatchers[skill]);
- this.Inventory = watcher.TryGetInventoryChanges(out SnapshotItemListDiff itemChanges)
+ this.Inventory = watcher.TryGetInventoryChanges(out SnapshotItemListDiff? itemChanges)
? itemChanges
: this.EmptyItemListDiff;
}
diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/WatcherSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/WatcherSnapshot.cs
index cf51e040..27a891de 100644
--- a/src/SMAPI/Framework/StateTracking/Snapshots/WatcherSnapshot.cs
+++ b/src/SMAPI/Framework/StateTracking/Snapshots/WatcherSnapshot.cs
@@ -11,31 +11,31 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
** Accessors
*********/
/// <summary>Tracks changes to the window size.</summary>
- public SnapshotDiff<Point> WindowSize { get; } = new SnapshotDiff<Point>();
+ public SnapshotDiff<Point> WindowSize { get; } = new();
/// <summary>Tracks changes to the current player.</summary>
- public PlayerSnapshot CurrentPlayer { get; private set; }
+ public PlayerSnapshot? CurrentPlayer { get; private set; }
/// <summary>Tracks changes to the time of day (in 24-hour military format).</summary>
- public SnapshotDiff<int> Time { get; } = new SnapshotDiff<int>();
+ public SnapshotDiff<int> Time { get; } = new();
/// <summary>Tracks changes to the save ID.</summary>
- public SnapshotDiff<ulong> SaveID { get; } = new SnapshotDiff<ulong>();
+ public SnapshotDiff<ulong> SaveID { get; } = new();
/// <summary>Tracks changes to the game's locations.</summary>
- public WorldLocationsSnapshot Locations { get; } = new WorldLocationsSnapshot();
+ public WorldLocationsSnapshot Locations { get; } = new();
/// <summary>Tracks changes to <see cref="Game1.activeClickableMenu"/>.</summary>
- public SnapshotDiff<IClickableMenu> ActiveMenu { get; } = new SnapshotDiff<IClickableMenu>();
+ public SnapshotDiff<IClickableMenu> ActiveMenu { get; } = new();
/// <summary>Tracks changes to the cursor position.</summary>
- public SnapshotDiff<ICursorPosition> Cursor { get; } = new SnapshotDiff<ICursorPosition>();
+ public SnapshotDiff<ICursorPosition> Cursor { get; } = new();
/// <summary>Tracks changes to the mouse wheel scroll.</summary>
- public SnapshotDiff<int> MouseWheelScroll { get; } = new SnapshotDiff<int>();
+ public SnapshotDiff<int> MouseWheelScroll { get; } = new();
/// <summary>Tracks changes to the content locale.</summary>
- public SnapshotDiff<LocalizedContentManager.LanguageCode> Locale { get; } = new SnapshotDiff<LocalizedContentManager.LanguageCode>();
+ public SnapshotDiff<LocalizedContentManager.LanguageCode> Locale { get; } = new();
/*********
@@ -54,7 +54,7 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
// update snapshots
this.WindowSize.Update(watchers.WindowSizeWatcher);
this.Locale.Update(watchers.LocaleWatcher);
- this.CurrentPlayer?.Update(watchers.CurrentPlayerTracker);
+ this.CurrentPlayer?.Update(watchers.CurrentPlayerTracker!);
this.Time.Update(watchers.TimeWatcher);
this.SaveID.Update(watchers.SaveIdWatcher);
this.Locations.Update(watchers.LocationsWatcher);
diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/WorldLocationsSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/WorldLocationsSnapshot.cs
index 73ed2d8f..59f94942 100644
--- a/src/SMAPI/Framework/StateTracking/Snapshots/WorldLocationsSnapshot.cs
+++ b/src/SMAPI/Framework/StateTracking/Snapshots/WorldLocationsSnapshot.cs
@@ -12,14 +12,14 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
** Fields
*********/
/// <summary>A map of tracked locations.</summary>
- private readonly Dictionary<GameLocation, LocationSnapshot> LocationsDict = new Dictionary<GameLocation, LocationSnapshot>(new ObjectReferenceComparer<GameLocation>());
+ private readonly Dictionary<GameLocation, LocationSnapshot> LocationsDict = new(new ObjectReferenceComparer<GameLocation>());
/*********
** Accessors
*********/
/// <summary>Tracks changes to the location list.</summary>
- public SnapshotListDiff<GameLocation> LocationList { get; } = new SnapshotListDiff<GameLocation>();
+ public SnapshotListDiff<GameLocation> LocationList { get; } = new();
/// <summary>The tracked locations.</summary>
public IEnumerable<LocationSnapshot> Locations => this.LocationsDict.Values;
@@ -42,7 +42,7 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
// update locations
foreach (LocationTracker locationWatcher in watcher.Locations)
{
- if (!this.LocationsDict.TryGetValue(locationWatcher.Location, out LocationSnapshot snapshot))
+ if (!this.LocationsDict.TryGetValue(locationWatcher.Location, out LocationSnapshot? snapshot))
this.LocationsDict[locationWatcher.Location] = snapshot = new LocationSnapshot(locationWatcher.Location);
snapshot.Update(locationWatcher);
diff --git a/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs b/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs
index e968d79c..817a6011 100644
--- a/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs
+++ b/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs
@@ -25,10 +25,10 @@ namespace StardewModdingAPI.Framework.StateTracking
private readonly ICollectionWatcher<GameLocation> VolcanoLocationListWatcher;
/// <summary>A lookup of the tracked locations.</summary>
- private IDictionary<GameLocation, LocationTracker> LocationDict { get; } = new Dictionary<GameLocation, LocationTracker>(new ObjectReferenceComparer<GameLocation>());
+ private Dictionary<GameLocation, LocationTracker> LocationDict { get; } = new(new ObjectReferenceComparer<GameLocation>());
/// <summary>A lookup of registered buildings and their indoor location.</summary>
- private readonly IDictionary<Building, GameLocation> BuildingIndoors = new Dictionary<Building, GameLocation>(new ObjectReferenceComparer<Building>());
+ private readonly Dictionary<Building, GameLocation?> BuildingIndoors = new(new ObjectReferenceComparer<Building>());
/*********
@@ -99,10 +99,9 @@ namespace StardewModdingAPI.Framework.StateTracking
}
// detect building interiors changed (e.g. construction completed)
- foreach (KeyValuePair<Building, GameLocation> pair in this.BuildingIndoors.Where(p => !object.Equals(p.Key.indoors.Value, p.Value)))
+ foreach ((Building building, GameLocation? oldIndoors) in this.BuildingIndoors.Where(p => !object.Equals(p.Key.indoors.Value, p.Value)))
{
- GameLocation oldIndoors = pair.Value;
- GameLocation newIndoors = pair.Key.indoors.Value;
+ GameLocation? newIndoors = building.indoors.Value;
if (oldIndoors != null)
this.Added.Add(oldIndoors);
@@ -187,19 +186,19 @@ namespace StardewModdingAPI.Framework.StateTracking
****/
/// <summary>Add the given building.</summary>
/// <param name="building">The building to add.</param>
- public void Add(Building building)
+ public void Add(Building? building)
{
if (building == null)
return;
- GameLocation indoors = building.indoors.Value;
+ GameLocation? indoors = building.indoors.Value;
this.BuildingIndoors[building] = indoors;
this.Add(indoors);
}
/// <summary>Add the given location.</summary>
/// <param name="location">The location to add.</param>
- public void Add(GameLocation location)
+ public void Add(GameLocation? location)
{
if (location == null)
return;
@@ -218,7 +217,7 @@ namespace StardewModdingAPI.Framework.StateTracking
/// <summary>Remove the given building.</summary>
/// <param name="building">The building to remove.</param>
- public void Remove(Building building)
+ public void Remove(Building? building)
{
if (building == null)
return;
@@ -229,12 +228,12 @@ namespace StardewModdingAPI.Framework.StateTracking
/// <summary>Remove the given location.</summary>
/// <param name="location">The location to remove.</param>
- public void Remove(GameLocation location)
+ public void Remove(GameLocation? location)
{
if (location == null)
return;
- if (this.LocationDict.TryGetValue(location, out LocationTracker watcher))
+ if (this.LocationDict.TryGetValue(location, out LocationTracker? watcher))
{
// track change
this.Removed.Add(location);
diff --git a/src/SMAPI/Framework/TemporaryHacks/MiniMonoModHotfix.cs b/src/SMAPI/Framework/TemporaryHacks/MiniMonoModHotfix.cs
index 9d63ab2c..b5fc1f57 100644
--- a/src/SMAPI/Framework/TemporaryHacks/MiniMonoModHotfix.cs
+++ b/src/SMAPI/Framework/TemporaryHacks/MiniMonoModHotfix.cs
@@ -5,15 +5,15 @@
//
// This will be removed when Harmony/MonoMod are updated to incorporate the fix.
//
-// Special thanks to 0x0ade for submitting this worokaround! Copy/pasted and adapted from MonoMod.
+// Special thanks to 0x0ade for submitting this workaround! Copy/pasted and adapted from MonoMod.
using System;
-using System.Reflection;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+using System.Reflection.Emit;
using System.Runtime.CompilerServices;
using HarmonyLib;
-using System.Reflection.Emit;
// ReSharper disable once CheckNamespace -- Temporary hotfix submitted by the MonoMod author.
namespace MonoMod.Utils
@@ -24,38 +24,38 @@ 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[] _CacheGetterArgs = { /* MemberListType.All */ 0, /* name apparently always null? */ null };
+ private static readonly object[] _NoArgs = Array.Empty<object>();
+ private static readonly object?[] _CacheGetterArgs = { /* MemberListType.All */ 0, /* name apparently always null? */ null };
- private static Type t_RuntimeModule =
+ private static readonly Type? t_RuntimeModule =
typeof(Module).Assembly
.GetType("System.Reflection.RuntimeModule");
- private static PropertyInfo p_RuntimeModule_RuntimeType =
+ private static readonly PropertyInfo? p_RuntimeModule_RuntimeType =
typeof(Module).Assembly
.GetType("System.Reflection.RuntimeModule")
?.GetProperty("RuntimeType", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
- private static Type t_RuntimeType =
+ private static readonly Type? t_RuntimeType =
typeof(Type).Assembly
.GetType("System.RuntimeType");
- private static PropertyInfo p_RuntimeType_Cache =
+ private static readonly PropertyInfo? p_RuntimeType_Cache =
typeof(Type).Assembly
.GetType("System.RuntimeType")
?.GetProperty("Cache", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
- private static MethodInfo m_RuntimeTypeCache_GetFieldList =
+ private static readonly MethodInfo? m_RuntimeTypeCache_GetFieldList =
typeof(Type).Assembly
.GetType("System.RuntimeType+RuntimeTypeCache")
?.GetMethod("GetFieldList", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
- private static MethodInfo m_RuntimeTypeCache_GetPropertyList =
+ private static readonly MethodInfo? m_RuntimeTypeCache_GetPropertyList =
typeof(Type).Assembly
.GetType("System.RuntimeType+RuntimeTypeCache")
?.GetMethod("GetPropertyList", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
- private static readonly ConditionalWeakTable<Type, CacheFixEntry> _CacheFixed = new ConditionalWeakTable<Type, CacheFixEntry>();
+ private static readonly ConditionalWeakTable<Type, CacheFixEntry> _CacheFixed = new();
public static void Apply()
{
@@ -63,37 +63,37 @@ namespace MonoMod.Utils
harmony.Patch(
original: typeof(Harmony).Assembly
- .GetType("HarmonyLib.MethodBodyReader")
+ .GetType("HarmonyLib.MethodBodyReader", throwOnError: true)!
.GetMethod("ReadOperand", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance),
transpiler: new HarmonyMethod(typeof(MiniMonoModHotfix), nameof(ResolveTokenFix))
);
harmony.Patch(
original: typeof(MonoMod.Utils.ReflectionHelper).Assembly
- .GetType("MonoMod.Utils.DynamicMethodDefinition+<>c__DisplayClass3_0")
+ .GetType("MonoMod.Utils.DynamicMethodDefinition+<>c__DisplayClass3_0", throwOnError: true)!
.GetMethod("<_CopyMethodToDefinition>g__ResolveTokenAs|1", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance),
transpiler: new HarmonyMethod(typeof(MiniMonoModHotfix), nameof(ResolveTokenFix))
);
}
- private static IEnumerable<CodeInstruction> ResolveTokenFix(IEnumerable<CodeInstruction> instrs)
+ private static IEnumerable<CodeInstruction> ResolveTokenFix(IEnumerable<CodeInstruction> instructions)
{
- MethodInfo getdecl = typeof(MiniMonoModHotfix).GetMethod(nameof(GetRealDeclaringType));
- MethodInfo fixup = typeof(MiniMonoModHotfix).GetMethod(nameof(FixReflectionCache));
+ MethodInfo getRealDeclaringType = typeof(MiniMonoModHotfix).GetMethod(nameof(MiniMonoModHotfix.GetRealDeclaringType)) ?? throw new InvalidOperationException($"Can't get required method {nameof(MiniMonoModHotfix)}.{nameof(GetRealDeclaringType)}");
+ MethodInfo fixReflectionCache = typeof(MiniMonoModHotfix).GetMethod(nameof(MiniMonoModHotfix.FixReflectionCache)) ?? throw new InvalidOperationException($"Can't get required method {nameof(MiniMonoModHotfix)}.{nameof(FixReflectionCache)}");
- foreach (CodeInstruction instr in instrs)
+ foreach (CodeInstruction instruction in instructions)
{
- yield return instr;
+ yield return instruction;
- if (instr.operand is MethodInfo called)
+ if (instruction.operand is MethodInfo called)
{
switch (called.Name)
{
case "ResolveType":
// type.FixReflectionCache();
yield return new CodeInstruction(OpCodes.Dup);
- yield return new CodeInstruction(OpCodes.Call, fixup);
+ yield return new CodeInstruction(OpCodes.Call, fixReflectionCache);
break;
case "ResolveMember":
@@ -101,15 +101,15 @@ namespace MonoMod.Utils
case "ResolveField":
// member.GetRealDeclaringType().FixReflectionCache();
yield return new CodeInstruction(OpCodes.Dup);
- yield return new CodeInstruction(OpCodes.Call, getdecl);
- yield return new CodeInstruction(OpCodes.Call, fixup);
+ yield return new CodeInstruction(OpCodes.Call, getRealDeclaringType);
+ yield return new CodeInstruction(OpCodes.Call, fixReflectionCache);
break;
}
}
}
}
- public static Type GetModuleType(this Module module)
+ public static Type? GetModuleType(this Module? module)
{
// Sadly we can't blindly resolve type 0x02000001 as the runtime throws ArgumentException.
@@ -118,22 +118,21 @@ namespace MonoMod.Utils
// .NET
if (p_RuntimeModule_RuntimeType != null)
- return (Type)p_RuntimeModule_RuntimeType.GetValue(module, _NoArgs);
+ return (Type?)p_RuntimeModule_RuntimeType.GetValue(module, _NoArgs);
// The hotfix doesn't apply to Mono anyway, thus that's not copied over.
return null;
}
- public static Type GetRealDeclaringType(this MemberInfo member)
- => member.DeclaringType ?? member.Module?.GetModuleType();
+ public static Type? GetRealDeclaringType(this MemberInfo member)
+ {
+ return member.DeclaringType ?? member.Module.GetModuleType();
+ }
- public static void FixReflectionCache(this Type type)
+ public static void FixReflectionCache(this Type? type)
{
- if (t_RuntimeType == null ||
- p_RuntimeType_Cache == null ||
- m_RuntimeTypeCache_GetFieldList == null ||
- m_RuntimeTypeCache_GetPropertyList == null)
+ if (t_RuntimeType == null || p_RuntimeType_Cache == null || m_RuntimeTypeCache_GetFieldList == null || m_RuntimeTypeCache_GetPropertyList == null)
return;
for (; type != null; type = type.DeclaringType)
@@ -143,21 +142,17 @@ namespace MonoMod.Utils
if (!t_RuntimeType.IsInstanceOfType(type))
continue;
- CacheFixEntry entry = _CacheFixed.GetValue(type, rt => {
- CacheFixEntry entryNew = new CacheFixEntry();
- object cache;
- Array properties, fields;
-
+ CacheFixEntry entry = _CacheFixed.GetValue(type, rt =>
+ {
// All RuntimeTypes MUST have a cache, the getter is non-virtual, it creates on demand and asserts non-null.
- entryNew.Cache = cache = p_RuntimeType_Cache.GetValue(rt, _NoArgs);
- entryNew.Properties = properties = _GetArray(cache, m_RuntimeTypeCache_GetPropertyList);
- entryNew.Fields = fields = _GetArray(cache, m_RuntimeTypeCache_GetFieldList);
+ object cache = MiniMonoModHotfix.p_RuntimeType_Cache.GetValue(rt, MiniMonoModHotfix._NoArgs)!;
+ Array properties = MiniMonoModHotfix._GetArray(cache, MiniMonoModHotfix.m_RuntimeTypeCache_GetPropertyList);
+ Array fields = MiniMonoModHotfix._GetArray(cache, MiniMonoModHotfix.m_RuntimeTypeCache_GetFieldList);
_FixReflectionCacheOrder<PropertyInfo>(properties);
_FixReflectionCacheOrder<FieldInfo>(fields);
- entryNew.NeedsVerify = false;
- return entryNew;
+ return new CacheFixEntry(cache, properties, fields, needsVerify: false);
});
if (entry.NeedsVerify && !_Verify(entry, type))
@@ -175,44 +170,43 @@ namespace MonoMod.Utils
private static bool _Verify(CacheFixEntry entry, Type type)
{
- object cache;
- Array properties, fields;
-
// The cache can sometimes be invalidated.
// TODO: Figure out if only the arrays get replaced or if the entire cache object gets replaced!
- if (entry.Cache != (cache = p_RuntimeType_Cache.GetValue(type, _NoArgs)))
+ object cache = p_RuntimeType_Cache!.GetValue(type, _NoArgs)!;
+ if (entry.Cache != cache)
{
entry.Cache = cache;
- entry.Properties = _GetArray(cache, m_RuntimeTypeCache_GetPropertyList);
- entry.Fields = _GetArray(cache, m_RuntimeTypeCache_GetFieldList);
+ entry.Properties = _GetArray(cache, m_RuntimeTypeCache_GetPropertyList!);
+ entry.Fields = _GetArray(cache, m_RuntimeTypeCache_GetFieldList!);
return false;
}
- else if (entry.Properties != (properties = _GetArray(cache, m_RuntimeTypeCache_GetPropertyList)))
+
+ Array properties = _GetArray(cache, m_RuntimeTypeCache_GetPropertyList!);
+ if (entry.Properties != properties)
{
entry.Properties = properties;
- entry.Fields = _GetArray(cache, m_RuntimeTypeCache_GetFieldList);
+ entry.Fields = _GetArray(cache, m_RuntimeTypeCache_GetFieldList!);
return false;
-
}
- else if (entry.Fields != (fields = _GetArray(cache, m_RuntimeTypeCache_GetFieldList)))
+
+ Array fields = _GetArray(cache, m_RuntimeTypeCache_GetFieldList!);
+ if (entry.Fields != fields)
{
entry.Fields = fields;
return false;
}
- else
- {
- // Cache should still be the same, no re-fix necessary.
- return true;
- }
+
+ // Cache should still be the same, no re-fix necessary.
+ return true;
}
private static Array _GetArray(object cache, MethodInfo getter)
{
// Get and discard once, otherwise we might not be getting the actual backing array.
getter.Invoke(cache, _CacheGetterArgs);
- return (Array)getter.Invoke(cache, _CacheGetterArgs);
+ return (Array)getter.Invoke(cache, _CacheGetterArgs)!;
}
private static void _FixReflectionCacheOrder<T>(Array orig) where T : MemberInfo
@@ -220,7 +214,7 @@ namespace MonoMod.Utils
// Sort using a short-lived list.
List<T> list = new List<T>(orig.Length);
for (int i = 0; i < orig.Length; i++)
- list.Add((T)orig.GetValue(i));
+ list.Add((T)orig.GetValue(i)!);
list.Sort((a, b) => a.MetadataToken - b.MetadataToken);
@@ -230,10 +224,18 @@ namespace MonoMod.Utils
private class CacheFixEntry
{
- public object Cache;
+ public object? Cache;
public Array Properties;
public Array Fields;
public bool NeedsVerify;
+
+ public CacheFixEntry(object? cache, Array properties, Array fields, bool needsVerify)
+ {
+ this.Cache = cache;
+ this.Properties = properties;
+ this.Fields = fields;
+ this.NeedsVerify = needsVerify;
+ }
}
}
}
diff --git a/src/SMAPI/Framework/Translator.cs b/src/SMAPI/Framework/Translator.cs
index 4492b17f..3beee250 100644
--- a/src/SMAPI/Framework/Translator.cs
+++ b/src/SMAPI/Framework/Translator.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Linq;
using StardewValley;
@@ -21,7 +22,7 @@ namespace StardewModdingAPI.Framework
/*********
** Accessors
*********/
- /// <summary>The current locale.</summary>
+ /// <summary>The current locale code like <c>fr-FR</c>, or an empty string for English.</summary>
public string Locale { get; private set; }
/// <summary>The game's current language code.</summary>
@@ -37,9 +38,10 @@ namespace StardewModdingAPI.Framework
this.SetLocale(string.Empty, LocalizedContentManager.LanguageCode.en);
}
- /// <summary>Set the current locale and precache translations.</summary>
+ /// <summary>Set the current locale and pre-cache translations.</summary>
/// <param name="locale">The current locale.</param>
/// <param name="localeEnum">The game's current language code.</param>
+ [MemberNotNull(nameof(Translator.ForLocale), nameof(Translator.Locale))]
public void SetLocale(string locale, LocalizedContentManager.LanguageCode localeEnum)
{
this.Locale = locale.ToLower().Trim();
@@ -48,8 +50,9 @@ namespace StardewModdingAPI.Framework
this.ForLocale = new Dictionary<string, Translation>(StringComparer.OrdinalIgnoreCase);
foreach (string key in this.GetAllKeysRaw())
{
- string text = this.GetRaw(key, locale, withFallback: true);
- this.ForLocale.Add(key, new Translation(this.Locale, key, text));
+ string? text = this.GetRaw(key, locale, withFallback: true);
+ if (text != null)
+ this.ForLocale.Add(key, new Translation(this.Locale, key, text));
}
}
@@ -63,14 +66,14 @@ namespace StardewModdingAPI.Framework
/// <param name="key">The translation key.</param>
public Translation Get(string key)
{
- this.ForLocale.TryGetValue(key, out Translation translation);
+ this.ForLocale.TryGetValue(key, out Translation? translation);
return translation ?? new Translation(this.Locale, key, null);
}
/// <summary>Get a translation for the current locale.</summary>
/// <param name="key">The translation key.</param>
/// <param name="tokens">An object containing token key/value pairs. This can be an anonymous object (like <c>new { value = 42, name = "Cranberries" }</c>), a dictionary, or a class instance.</param>
- public Translation Get(string key, object tokens)
+ public Translation Get(string key, object? tokens)
{
return this.Get(key).Tokens(tokens);
}
@@ -85,7 +88,7 @@ namespace StardewModdingAPI.Framework
foreach (var localeSet in this.All)
{
string locale = localeSet.Key;
- string text = this.GetRaw(key, locale, withFallback);
+ string? text = this.GetRaw(key, locale, withFallback);
if (text != null)
translations[locale] = new Translation(locale, key, text);
@@ -126,13 +129,13 @@ namespace StardewModdingAPI.Framework
/// <param name="key">The translation key.</param>
/// <param name="locale">The locale to get.</param>
/// <param name="withFallback">Whether to add duplicate translations for locale fallback. For example, if a translation is defined in <c>default.json</c> but not <c>fr.json</c>, setting this to true will add a <c>fr</c> entry which duplicates the default text.</param>
- private string GetRaw(string key, string locale, bool withFallback)
+ private string? GetRaw(string key, string locale, bool withFallback)
{
foreach (string next in this.GetRelevantLocales(locale))
{
- string translation = null;
+ string? translation = null;
bool hasTranslation =
- this.All.TryGetValue(next, out IDictionary<string, string> translations)
+ this.All.TryGetValue(next, out IDictionary<string, string>? translations)
&& translations.TryGetValue(key, out translation);
if (hasTranslation)
diff --git a/src/SMAPI/Framework/Utilities/Countdown.cs b/src/SMAPI/Framework/Utilities/Countdown.cs
index 342b4258..94c69e73 100644
--- a/src/SMAPI/Framework/Utilities/Countdown.cs
+++ b/src/SMAPI/Framework/Utilities/Countdown.cs
@@ -1,4 +1,4 @@
-namespace StardewModdingAPI.Framework.Utilities
+namespace StardewModdingAPI.Framework.Utilities
{
/// <summary>Counts down from a baseline value.</summary>
internal class Countdown
diff --git a/src/SMAPI/Framework/Utilities/TickCacheDictionary.cs b/src/SMAPI/Framework/Utilities/TickCacheDictionary.cs
new file mode 100644
index 00000000..20d206e2
--- /dev/null
+++ b/src/SMAPI/Framework/Utilities/TickCacheDictionary.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Collections.Generic;
+
+namespace StardewModdingAPI.Framework.Utilities
+{
+ /// <summary>An in-memory dictionary cache that stores data for the duration of a game update tick.</summary>
+ /// <typeparam name="TKey">The dictionary key type.</typeparam>
+ /// <typeparam name="TValue">The dictionary value type.</typeparam>
+ internal class TickCacheDictionary<TKey, TValue>
+ where TKey : notnull
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The last game tick for which data was cached.</summary>
+ private uint? LastGameTick;
+
+ /// <summary>The underlying cached data.</summary>
+ private readonly Dictionary<TKey, TValue> Cache = new();
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Get a value from the cache, fetching it first if it's not cached yet.</summary>
+ /// <param name="cacheKey">The unique key for the cached value.</param>
+ /// <param name="get">Get the latest data if it's not in the cache yet.</param>
+ public TValue GetOrSet(TKey cacheKey, Func<TValue> get)
+ {
+ // clear cache on new tick
+ if (SCore.ProcessTicksElapsed != this.LastGameTick)
+ {
+ this.Cache.Clear();
+ this.LastGameTick = SCore.ProcessTicksElapsed;
+ }
+
+ // fetch value
+ if (!this.Cache.TryGetValue(cacheKey, out TValue? cached))
+ this.Cache[cacheKey] = cached = get();
+ return cached;
+ }
+
+ /// <summary>Remove an entry from the cache.</summary>
+ /// <param name="cacheKey">The unique key for the cached value.</param>
+ /// <returns>Returns whether the key was present in the dictionary.</returns>
+ public bool Remove(TKey cacheKey)
+ {
+ return this.Cache.Remove(cacheKey);
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/WatcherCore.cs b/src/SMAPI/Framework/WatcherCore.cs
index 62a0c3b8..5e20ac7b 100644
--- a/src/SMAPI/Framework/WatcherCore.cs
+++ b/src/SMAPI/Framework/WatcherCore.cs
@@ -17,7 +17,7 @@ namespace StardewModdingAPI.Framework
** Fields
*********/
/// <summary>The underlying watchers for convenience. These are accessible individually as separate properties.</summary>
- private readonly List<IWatcher> Watchers = new List<IWatcher>();
+ private readonly List<IWatcher> Watchers = new();
/*********
@@ -27,7 +27,7 @@ namespace StardewModdingAPI.Framework
public readonly IValueWatcher<Point> WindowSizeWatcher;
/// <summary>Tracks changes to the current player.</summary>
- public PlayerTracker CurrentPlayerTracker;
+ public PlayerTracker? CurrentPlayerTracker;
/// <summary>Tracks changes to the time of day (in 24-hour military format).</summary>
public readonly IValueWatcher<int> TimeWatcher;