summaryrefslogtreecommitdiff
path: root/src/SMAPI
diff options
context:
space:
mode:
Diffstat (limited to 'src/SMAPI')
-rw-r--r--src/SMAPI/Constants.cs4
-rw-r--r--src/SMAPI/Events/ButtonsChangedEventArgs.cs67
-rw-r--r--src/SMAPI/Events/IInputEvents.cs3
-rw-r--r--src/SMAPI/Framework/ContentManagers/GameContentManager.cs2
-rw-r--r--src/SMAPI/Framework/ContentManagers/ModContentManager.cs15
-rw-r--r--src/SMAPI/Framework/Events/EventManager.cs4
-rw-r--r--src/SMAPI/Framework/Events/ModInputEvents.cs7
-rw-r--r--src/SMAPI/Framework/Logging/LogManager.cs11
-rw-r--r--src/SMAPI/Framework/ModHelpers/InputHelper.cs14
-rw-r--r--src/SMAPI/Framework/ModLoading/AssemblyLoader.cs9
-rw-r--r--src/SMAPI/Framework/Models/SConfig.cs9
-rw-r--r--src/SMAPI/Framework/Networking/MultiplayerPeer.cs10
-rw-r--r--src/SMAPI/Framework/SChatBox.cs49
-rw-r--r--src/SMAPI/Framework/SCore.cs76
-rw-r--r--src/SMAPI/Framework/SGame.cs17
-rw-r--r--src/SMAPI/Framework/SGameRunner.cs22
-rw-r--r--src/SMAPI/Framework/SMultiplayer.cs86
-rw-r--r--src/SMAPI/Framework/Serialization/KeybindConverter.cs82
-rw-r--r--src/SMAPI/IInputHelper.cs6
-rw-r--r--src/SMAPI/IMultiplayerPeer.cs7
-rw-r--r--src/SMAPI/Metadata/CoreAssetPropagator.cs139
-rw-r--r--src/SMAPI/Metadata/InstructionMetadata.cs28
-rw-r--r--src/SMAPI/Patches/DialogueErrorPatch.cs192
-rw-r--r--src/SMAPI/Patches/EventErrorPatch.cs114
-rw-r--r--src/SMAPI/Patches/LoadErrorPatch.cs157
-rw-r--r--src/SMAPI/Patches/ObjectErrorPatch.cs143
-rw-r--r--src/SMAPI/Patches/ScheduleErrorPatch.cs115
-rw-r--r--src/SMAPI/Properties/AssemblyInfo.cs1
-rw-r--r--src/SMAPI/SButtonState.cs2
-rw-r--r--src/SMAPI/SMAPI.config.json7
-rw-r--r--src/SMAPI/Utilities/Keybind.cs139
-rw-r--r--src/SMAPI/Utilities/KeybindList.cs161
-rw-r--r--src/SMAPI/Utilities/PerScreen.cs53
-rw-r--r--src/SMAPI/i18n/de.json4
-rw-r--r--src/SMAPI/i18n/default.json3
-rw-r--r--src/SMAPI/i18n/es.json3
-rw-r--r--src/SMAPI/i18n/fr.json3
-rw-r--r--src/SMAPI/i18n/hu.json3
-rw-r--r--src/SMAPI/i18n/it.json3
-rw-r--r--src/SMAPI/i18n/ja.json3
-rw-r--r--src/SMAPI/i18n/ko.json3
-rw-r--r--src/SMAPI/i18n/pt.json3
-rw-r--r--src/SMAPI/i18n/ru.json3
-rw-r--r--src/SMAPI/i18n/tr.json3
-rw-r--r--src/SMAPI/i18n/zh.json3
45 files changed, 848 insertions, 940 deletions
diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs
index b72471ca..2adafbbf 100644
--- a/src/SMAPI/Constants.cs
+++ b/src/SMAPI/Constants.cs
@@ -54,10 +54,10 @@ namespace StardewModdingAPI
** Public
****/
/// <summary>SMAPI's current semantic version.</summary>
- public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.8.4");
+ public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.9.0");
/// <summary>The minimum supported version of Stardew Valley.</summary>
- public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.5.3");
+ public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.5.4");
/// <summary>The maximum supported version of Stardew Valley.</summary>
public static ISemanticVersion MaximumGameVersion { get; } = null;
diff --git a/src/SMAPI/Events/ButtonsChangedEventArgs.cs b/src/SMAPI/Events/ButtonsChangedEventArgs.cs
new file mode 100644
index 00000000..dda41692
--- /dev/null
+++ b/src/SMAPI/Events/ButtonsChangedEventArgs.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using StardewModdingAPI.Framework.Input;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Event arguments when any buttons were pressed or released.</summary>
+ public class ButtonsChangedEventArgs : EventArgs
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The buttons that were pressed, held, or released since the previous tick.</summary>
+ private readonly Lazy<Dictionary<SButtonState, SButton[]>> ButtonsByState;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The current cursor position.</summary>
+ public ICursorPosition Cursor { get; }
+
+ /// <summary>The buttons which were pressed since the previous tick.</summary>
+ public IEnumerable<SButton> Pressed => this.ButtonsByState.Value[SButtonState.Pressed];
+
+ /// <summary>The buttons which were held since the previous tick.</summary>
+ public IEnumerable<SButton> Held => this.ButtonsByState.Value[SButtonState.Held];
+
+ /// <summary>The buttons which were released since the previous tick.</summary>
+ public IEnumerable<SButton> Released => this.ButtonsByState.Value[SButtonState.Released];
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="cursor">The cursor position.</param>
+ /// <param name="inputState">The game's current input state.</param>
+ internal ButtonsChangedEventArgs(ICursorPosition cursor, SInputState inputState)
+ {
+ this.Cursor = cursor;
+ this.ButtonsByState = new Lazy<Dictionary<SButtonState, SButton[]>>(() => this.GetButtonsByState(inputState));
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get the buttons that were pressed, held, or released since the previous tick.</summary>
+ /// <param name="inputState">The game's current input state.</param>
+ private Dictionary<SButtonState, SButton[]> GetButtonsByState(SInputState inputState)
+ {
+ Dictionary<SButtonState, SButton[]> lookup = inputState.ButtonStates
+ .GroupBy(p => p.Value)
+ .ToDictionary(p => p.Key, p => p.Select(p => p.Key).ToArray());
+
+ foreach (var state in new[] { SButtonState.Pressed, SButtonState.Held, SButtonState.Released })
+ {
+ if (!lookup.ContainsKey(state))
+ lookup[state] = new SButton[0];
+ }
+
+ return lookup;
+ }
+ }
+}
diff --git a/src/SMAPI/Events/IInputEvents.cs b/src/SMAPI/Events/IInputEvents.cs
index 5c40a438..081c40c0 100644
--- a/src/SMAPI/Events/IInputEvents.cs
+++ b/src/SMAPI/Events/IInputEvents.cs
@@ -5,6 +5,9 @@ namespace StardewModdingAPI.Events
/// <summary>Events raised when the player provides input using a controller, keyboard, or mouse.</summary>
public interface IInputEvents
{
+ /// <summary>Raised after the player presses or releases any buttons on the keyboard, controller, or mouse.</summary>
+ event EventHandler<ButtonsChangedEventArgs> ButtonsChanged;
+
/// <summary>Raised after the player presses a button on the keyboard, controller, or mouse.</summary>
event EventHandler<ButtonPressedEventArgs> ButtonPressed;
diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
index 3db3856f..665c019b 100644
--- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
@@ -414,7 +414,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
int loadedIndex = this.TryFindTilesheet(loadedMap, vanillaSheet.Id);
string reason = loadedIndex != -1
- ? $"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://stardewcommunitywiki.com/Modding:Maps#Tilesheet_order for help."
+ ? $"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."
: $"mod has no tilesheet with ID '{vanillaSheet.Id}'. Map replacements must keep the original tilesheets to avoid errors or crashes.";
SCore.DeprecationManager.PlaceholderWarn("3.8.2", DeprecationLevel.PendingRemoval);
diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
index 753ec188..1456d3c1 100644
--- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
@@ -127,7 +127,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
if (asset is Map map)
{
map.assetPath = assetName;
- this.FixTilesheetPaths(map, relativeMapPath: assetName);
+ this.FixTilesheetPaths(map, relativeMapPath: assetName, fixEagerPathPrefixes: true);
}
}
break;
@@ -168,7 +168,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
FormatManager formatManager = FormatManager.Instance;
Map map = formatManager.LoadMap(file.FullName);
map.assetPath = assetName;
- this.FixTilesheetPaths(map, relativeMapPath: assetName);
+ this.FixTilesheetPaths(map, relativeMapPath: assetName, fixEagerPathPrefixes: false);
asset = (T)(object)map;
}
break;
@@ -260,8 +260,9 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <summary>Fix custom map tilesheet paths so they can be found by the content manager.</summary>
/// <param name="map">The map whose tilesheets to fix.</param>
/// <param name="relativeMapPath">The relative map path within the mod folder.</param>
+ /// <param name="fixEagerPathPrefixes">Whether to undo the game's eager tilesheet path prefixing for maps loaded from an <c>.xnb</c> file, which incorrectly prefixes tilesheet paths with the map's local asset key folder.</param>
/// <exception cref="ContentLoadException">A map tilesheet couldn't be resolved.</exception>
- private void FixTilesheetPaths(Map map, string relativeMapPath)
+ private void FixTilesheetPaths(Map map, string relativeMapPath, bool fixEagerPathPrefixes)
{
// get map info
relativeMapPath = this.AssertAndNormalizeAssetName(relativeMapPath); // Mono's Path.GetDirectoryName doesn't handle Windows dir separators
@@ -270,12 +271,16 @@ namespace StardewModdingAPI.Framework.ContentManagers
// fix tilesheets
foreach (TileSheet tilesheet in map.TileSheets)
{
+ // get image source
tilesheet.ImageSource = this.NormalizePathSeparators(tilesheet.ImageSource);
-
string imageSource = tilesheet.ImageSource;
- string errorPrefix = $"{this.ModName} loaded map '{relativeMapPath}' with invalid tilesheet path '{imageSource}'.";
+
+ // reverse incorrect eager tilesheet path prefixing
+ if (fixEagerPathPrefixes && relativeMapFolder.Length > 0 && imageSource.StartsWith(relativeMapFolder))
+ imageSource = imageSource.Substring(relativeMapFolder.Length + 1);
// validate tilesheet path
+ string errorPrefix = $"{this.ModName} loaded map '{relativeMapPath}' with invalid tilesheet path '{imageSource}'.";
if (Path.IsPathRooted(imageSource) || PathUtilities.GetSegments(imageSource).Contains(".."))
throw new SContentLoadException($"{errorPrefix} Tilesheet paths must be a relative path without directory climbing (../).");
diff --git a/src/SMAPI/Framework/Events/EventManager.cs b/src/SMAPI/Framework/Events/EventManager.cs
index 665dbfe3..f4abfffe 100644
--- a/src/SMAPI/Framework/Events/EventManager.cs
+++ b/src/SMAPI/Framework/Events/EventManager.cs
@@ -93,6 +93,9 @@ namespace StardewModdingAPI.Framework.Events
/****
** Input
****/
+ /// <summary>Raised after the player presses or releases any buttons on the keyboard, controller, or mouse.</summary>
+ public readonly ManagedEvent<ButtonsChangedEventArgs> ButtonsChanged;
+
/// <summary>Raised after the player presses a button on the keyboard, controller, or mouse.</summary>
public readonly ManagedEvent<ButtonPressedEventArgs> ButtonPressed;
@@ -212,6 +215,7 @@ namespace StardewModdingAPI.Framework.Events
this.TimeChanged = ManageEventOf<TimeChangedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.TimeChanged));
this.ReturnedToTitle = ManageEventOf<ReturnedToTitleEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.ReturnedToTitle));
+ this.ButtonsChanged = ManageEventOf<ButtonsChangedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonsChanged));
this.ButtonPressed = ManageEventOf<ButtonPressedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonPressed));
this.ButtonReleased = ManageEventOf<ButtonReleasedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonReleased));
this.CursorMoved = ManageEventOf<CursorMovedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.CursorMoved), isPerformanceCritical: true);
diff --git a/src/SMAPI/Framework/Events/ModInputEvents.cs b/src/SMAPI/Framework/Events/ModInputEvents.cs
index ab26ab3e..6f423e5d 100644
--- a/src/SMAPI/Framework/Events/ModInputEvents.cs
+++ b/src/SMAPI/Framework/Events/ModInputEvents.cs
@@ -9,6 +9,13 @@ namespace StardewModdingAPI.Framework.Events
/*********
** Accessors
*********/
+ /// <summary>Raised after the player presses or releases any buttons on the keyboard, controller, or mouse.</summary>
+ 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>
public event EventHandler<ButtonPressedEventArgs> ButtonPressed
{
diff --git a/src/SMAPI/Framework/Logging/LogManager.cs b/src/SMAPI/Framework/Logging/LogManager.cs
index e504218b..ff00cff7 100644
--- a/src/SMAPI/Framework/Logging/LogManager.cs
+++ b/src/SMAPI/Framework/Logging/LogManager.cs
@@ -208,7 +208,7 @@ namespace StardewModdingAPI.Framework.Logging
// show update alert
if (File.Exists(Constants.UpdateMarker))
{
- string[] rawUpdateFound = File.ReadAllText(Constants.UpdateMarker).Split(new [] { '|' }, 2);
+ string[] rawUpdateFound = File.ReadAllText(Constants.UpdateMarker).Split(new[] { '|' }, 2);
if (SemanticVersion.TryParse(rawUpdateFound[0], out ISemanticVersion updateFound))
{
if (Constants.ApiVersion.IsPrerelease() && updateFound.IsNewerThan(Constants.ApiVersion))
@@ -286,12 +286,15 @@ namespace StardewModdingAPI.Framework.Logging
/// <summary>Log details for settings that don't match the default.</summary>
/// <param name="isDeveloperMode">Whether to enable full console output for developers.</param>
/// <param name="checkForUpdates">Whether to check for newer versions of SMAPI and mods on startup.</param>
- public void LogSettingsHeader(bool isDeveloperMode, bool checkForUpdates)
+ /// <param name="rewriteMods">Whether to rewrite mods for compatibility.</param>
+ public void LogSettingsHeader(bool isDeveloperMode, bool checkForUpdates, bool rewriteMods)
{
if (isDeveloperMode)
- this.Monitor.Log($"You have SMAPI for developers, so the console will be much more verbose. You can disable developer mode by installing the non-developer version of SMAPI, or by editing {Constants.ApiConfigPath}.", LogLevel.Info);
+ this.Monitor.Log("You have SMAPI for developers, so the console will be much more verbose. You can disable developer mode by installing the non-developer version of SMAPI.", LogLevel.Info);
if (!checkForUpdates)
- this.Monitor.Log($"You configured SMAPI to not check for updates. Running an old version of SMAPI is not recommended. You can enable update checks by reinstalling SMAPI or editing {Constants.ApiConfigPath}.", LogLevel.Warn);
+ this.Monitor.Log("You configured SMAPI to not check for updates. Running an old version of SMAPI is not recommended. You can enable update checks by reinstalling SMAPI.", LogLevel.Warn);
+ if (!rewriteMods)
+ this.Monitor.Log("You configured SMAPI to not rewrite broken mods. Many older mods may fail to load. You can undo this by reinstalling SMAPI.", LogLevel.Warn);
if (!this.Monitor.WriteToConsole)
this.Monitor.Log("Writing to the terminal is disabled because the --no-terminal argument was received. This usually means launching the terminal failed.", LogLevel.Warn);
this.Monitor.VerboseLog("Verbose logging enabled.");
diff --git a/src/SMAPI/Framework/ModHelpers/InputHelper.cs b/src/SMAPI/Framework/ModHelpers/InputHelper.cs
index e1317544..88caf4c3 100644
--- a/src/SMAPI/Framework/ModHelpers/InputHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/InputHelper.cs
@@ -1,5 +1,6 @@
using System;
using StardewModdingAPI.Framework.Input;
+using StardewModdingAPI.Utilities;
namespace StardewModdingAPI.Framework.ModHelpers
{
@@ -50,6 +51,19 @@ namespace StardewModdingAPI.Framework.ModHelpers
}
/// <inheritdoc />
+ public void SuppressActiveKeybinds(KeybindList keybindList)
+ {
+ foreach (Keybind keybind in keybindList.Keybinds)
+ {
+ if (!keybind.GetState().IsDown())
+ continue;
+
+ foreach (SButton button in keybind.Buttons)
+ this.Suppress(button);
+ }
+ }
+
+ /// <inheritdoc />
public SButtonState GetState(SButton button)
{
return this.CurrentInputState().GetState(button);
diff --git a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
index 9fb5384e..4fae0f44 100644
--- a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
+++ b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
@@ -37,6 +37,9 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <summary>The objects to dispose as part of this instance.</summary>
private readonly HashSet<IDisposable> Disposables = new HashSet<IDisposable>();
+ /// <summary>Whether to rewrite mods for compatibility.</summary>
+ private readonly bool RewriteMods;
+
/*********
** Public methods
@@ -45,10 +48,12 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <param name="targetPlatform">The current game platform.</param>
/// <param name="monitor">Encapsulates monitoring and logging.</param>
/// <param name="paranoidMode">Whether to detect paranoid mode issues.</param>
- public AssemblyLoader(Platform targetPlatform, IMonitor monitor, bool paranoidMode)
+ /// <param name="rewriteMods">Whether to rewrite mods for compatibility.</param>
+ public AssemblyLoader(Platform targetPlatform, IMonitor monitor, bool paranoidMode, bool rewriteMods)
{
this.Monitor = monitor;
this.ParanoidMode = paranoidMode;
+ this.RewriteMods = rewriteMods;
this.AssemblyMap = this.TrackForDisposal(Constants.GetAssemblyMap(targetPlatform));
// init resolver
@@ -308,7 +313,7 @@ namespace StardewModdingAPI.Framework.ModLoading
}
// find or rewrite code
- IInstructionHandler[] handlers = new InstructionMetadata().GetHandlers(this.ParanoidMode, platformChanged).ToArray();
+ IInstructionHandler[] handlers = new InstructionMetadata().GetHandlers(this.ParanoidMode, platformChanged, this.RewriteMods).ToArray();
RecursiveRewriter rewriter = new RecursiveRewriter(
module: module,
rewriteType: (type, replaceWith) =>
diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs
index 3a3f6960..dea08717 100644
--- a/src/SMAPI/Framework/Models/SConfig.cs
+++ b/src/SMAPI/Framework/Models/SConfig.cs
@@ -20,13 +20,15 @@ namespace StardewModdingAPI.Framework.Models
[nameof(GitHubProjectName)] = "Pathoschild/SMAPI",
[nameof(WebApiBaseUrl)] = "https://smapi.io/api/",
[nameof(VerboseLogging)] = false,
- [nameof(LogNetworkTraffic)] = false
+ [nameof(LogNetworkTraffic)] = false,
+ [nameof(RewriteMods)] = 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)
{
"SMAPI.ConsoleCommands",
+ "SMAPI.ErrorHandler",
"SMAPI.SaveBackup"
};
@@ -55,6 +57,9 @@ namespace StardewModdingAPI.Framework.Models
/// <summary>Whether SMAPI should log more information about the game context.</summary>
public bool VerboseLogging { get; set; }
+ /// <summary>Whether SMAPI should rewrite mods for compatibility.</summary>
+ public bool RewriteMods { get; set; } = (bool)SConfig.DefaultValues[nameof(SConfig.RewriteMods)];
+
/// <summary>Whether SMAPI should log network traffic. Best combined with <see cref="VerboseLogging"/>, which includes network metadata.</summary>
public bool LogNetworkTraffic { get; set; }
@@ -68,7 +73,7 @@ namespace StardewModdingAPI.Framework.Models
/********
** Public methods
********/
- /// <summary>Get the settings which have been customised by the player.</summary>
+ /// <summary>Get the settings which have been customized by the player.</summary>
public IDictionary<string, object> GetCustomSettings()
{
IDictionary<string, object> custom = new Dictionary<string, object>();
diff --git a/src/SMAPI/Framework/Networking/MultiplayerPeer.cs b/src/SMAPI/Framework/Networking/MultiplayerPeer.cs
index 5eda71f6..3923700f 100644
--- a/src/SMAPI/Framework/Networking/MultiplayerPeer.cs
+++ b/src/SMAPI/Framework/Networking/MultiplayerPeer.cs
@@ -25,9 +25,15 @@ namespace StardewModdingAPI.Framework.Networking
public bool IsHost { get; }
/// <inheritdoc />
+ public bool IsSplitScreen => this.ScreenID != null;
+
+ /// <inheritdoc />
public bool HasSmapi => this.ApiVersion != null;
/// <inheritdoc />
+ public int? ScreenID { get; }
+
+ /// <inheritdoc />
public GamePlatform? Platform { get; }
/// <inheritdoc />
@@ -45,12 +51,14 @@ namespace StardewModdingAPI.Framework.Networking
*********/
/// <summary>Construct an instance.</summary>
/// <param name="playerID">The player's unique ID.</param>
+ /// <param name="screenID">The player's screen ID, if applicable.</param>
/// <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, 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)
{
diff --git a/src/SMAPI/Framework/SChatBox.cs b/src/SMAPI/Framework/SChatBox.cs
new file mode 100644
index 00000000..e000d1cd
--- /dev/null
+++ b/src/SMAPI/Framework/SChatBox.cs
@@ -0,0 +1,49 @@
+using StardewValley;
+using StardewValley.Menus;
+
+namespace StardewModdingAPI.Framework
+{
+ /// <summary>SMAPI's implementation of the chatbox which intercepts errors for logging.</summary>
+ internal class SChatBox : ChatBox
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>Encapsulates monitoring and logging.</summary>
+ private readonly IMonitor Monitor;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="monitor">Encapsulates monitoring and logging.</param>
+ public SChatBox(IMonitor monitor)
+ {
+ this.Monitor = monitor;
+ }
+
+ /// <inheritdoc />
+ protected override void runCommand(string command)
+ {
+ this.Monitor.Log($"> chat command: {command}");
+ base.runCommand(command);
+ }
+
+ /// <inheritdoc />
+ public override void receiveChatMessage(long sourceFarmer, int chatKind, LocalizedContentManager.LanguageCode language, string message)
+ {
+ if (chatKind == ChatBox.errorMessage)
+ {
+ // log error
+ this.Monitor.Log(message, LogLevel.Error);
+
+ // add event details if applicable
+ if (Game1.CurrentEvent != null && message.StartsWith("Event script error:"))
+ this.Monitor.Log($"In event #{Game1.CurrentEvent.id} for location {Game1.currentLocation?.NameOrUniqueName}", LogLevel.Error);
+ }
+
+ base.receiveChatMessage(sourceFarmer, chatKind, language, message);
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs
index f9a36593..cd094ff4 100644
--- a/src/SMAPI/Framework/SCore.cs
+++ b/src/SMAPI/Framework/SCore.cs
@@ -124,9 +124,6 @@ namespace StardewModdingAPI.Framework
/// <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
- /// <summary>Whether custom content was removed from the save data to avoid a crash.</summary>
- private bool IsSaveContentRemoved;
-
/// <summary>Asset interceptors added or removed since the last tick.</summary>
private readonly List<AssetInterceptorChange> ReloadAssetInterceptorsQueue = new List<AssetInterceptorChange>();
@@ -135,7 +132,7 @@ namespace StardewModdingAPI.Framework
private readonly ConcurrentQueue<string> RawCommandQueue = new ConcurrentQueue<string>();
/// <summary>A list of commands to execute on each screen.</summary>
- private readonly PerScreen<List<Tuple<Command, string, string[]>>> ScreenCommandQueue = new(() => new());
+ private readonly PerScreen<List<Tuple<Command, string, string[]>>> ScreenCommandQueue = new PerScreen<List<Tuple<Command, string, string[]>>>(() => new List<Tuple<Command, string, string[]>>());
/*********
@@ -145,6 +142,10 @@ namespace StardewModdingAPI.Framework
/// <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; }
+ /// <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; }
+
/// <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>
internal static uint TicksElapsed { get; private set; }
@@ -157,6 +158,8 @@ namespace StardewModdingAPI.Framework
/// <param name="writeToConsole">Whether to output log messages to the console.</param>
public SCore(string modsPath, bool writeToConsole)
{
+ SCore.Instance = this;
+
// init paths
this.VerifyPath(modsPath);
this.VerifyPath(Constants.LogDir);
@@ -205,6 +208,7 @@ namespace StardewModdingAPI.Framework
{
JsonConverter[] converters = {
new ColorConverter(),
+ new KeybindConverter(),
new PointConverter(),
new Vector2Converter(),
new RectangleConverter()
@@ -245,12 +249,7 @@ namespace StardewModdingAPI.Framework
// apply game patches
new GamePatcher(this.Monitor).Apply(
- new EventErrorPatch(this.LogManager.MonitorForGame),
- new DialogueErrorPatch(this.LogManager.MonitorForGame, this.Reflection),
- new ObjectErrorPatch(),
- new LoadContextPatch(this.Reflection, this.OnLoadStageChanged),
- new LoadErrorPatch(this.Monitor, this.OnSaveContentRemoved),
- new ScheduleErrorPatch(this.LogManager.MonitorForGame)
+ new LoadContextPatch(this.Reflection, this.OnLoadStageChanged)
);
// add exit handler
@@ -278,7 +277,7 @@ namespace StardewModdingAPI.Framework
// log basic info
this.LogManager.HandleMarkerFiles();
- this.LogManager.LogSettingsHeader(this.Settings.DeveloperMode, this.Settings.CheckForUpdates);
+ this.LogManager.LogSettingsHeader(this.Settings.DeveloperMode, this.Settings.CheckForUpdates, this.Settings.RewriteMods);
// set window titles
this.SetWindowTitles(
@@ -517,15 +516,6 @@ namespace StardewModdingAPI.Framework
this.ScreenCommandQueue.GetValueForScreen(screenId).Add(Tuple.Create(command, name, args));
}
- /*********
- ** Show in-game warnings (for main player only)
- *********/
- // save content removed
- if (this.IsSaveContentRemoved && Context.IsWorldReady)
- {
- this.IsSaveContentRemoved = false;
- Game1.addHUDMessage(new HUDMessage(this.Translator.Get("warn.invalid-content-removed"), HUDMessage.error_type));
- }
/*********
** Run game update
@@ -827,24 +817,29 @@ namespace StardewModdingAPI.Framework
}
// raise input button events
- foreach (var pair in inputState.ButtonStates)
+ if (inputState.ButtonStates.Count > 0)
{
- SButton button = pair.Key;
- SButtonState status = pair.Value;
+ events.ButtonsChanged.Raise(new ButtonsChangedEventArgs(cursor, inputState));
- if (status == SButtonState.Pressed)
+ foreach (var pair in inputState.ButtonStates)
{
- if (this.Monitor.IsVerbose)
- this.Monitor.Log($"Events: button {button} pressed.");
+ SButton button = pair.Key;
+ SButtonState status = pair.Value;
- events.ButtonPressed.Raise(new ButtonPressedEventArgs(button, cursor, inputState));
- }
- else if (status == SButtonState.Released)
- {
- if (this.Monitor.IsVerbose)
- this.Monitor.Log($"Events: button {button} released.");
+ if (status == SButtonState.Pressed)
+ {
+ if (this.Monitor.IsVerbose)
+ this.Monitor.Log($"Events: button {button} pressed.");
- events.ButtonReleased.Raise(new ButtonReleasedEventArgs(button, cursor, inputState));
+ events.ButtonPressed.Raise(new ButtonPressedEventArgs(button, cursor, inputState));
+ }
+ else if (status == SButtonState.Released)
+ {
+ if (this.Monitor.IsVerbose)
+ this.Monitor.Log($"Events: button {button} released.");
+
+ events.ButtonReleased.Raise(new ButtonReleasedEventArgs(button, cursor, inputState));
+ }
}
}
}
@@ -1065,6 +1060,13 @@ namespace StardewModdingAPI.Framework
this.OnReturnedToTitle();
}
+ // override chatbox
+ if (newStage == LoadStage.Loaded)
+ {
+ Game1.onScreenMenus.Remove(Game1.chatBox);
+ Game1.onScreenMenus.Add(Game1.chatBox = new SChatBox(this.LogManager.MonitorForGame));
+ }
+
// raise events
this.EventManager.LoadStageChanged.Raise(new LoadStageChangedEventArgs(oldStage, newStage));
if (newStage == LoadStage.None)
@@ -1105,12 +1107,6 @@ namespace StardewModdingAPI.Framework
Game1.CustomData[migrationKey] = Constants.ApiVersion.ToString();
}
- /// <summary>Raised after custom content is removed from the save data to avoid a crash.</summary>
- internal void OnSaveContentRemoved()
- {
- this.IsSaveContentRemoved = true;
- }
-
/// <summary>A callback invoked before <see cref="Game1.newDayAfterFade"/> runs.</summary>
protected void OnNewDayAfterFade()
{
@@ -1406,7 +1402,7 @@ namespace StardewModdingAPI.Framework
// load mods
IList<IModMetadata> skippedMods = new List<IModMetadata>();
- using (AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.Platform, this.Monitor, this.Settings.ParanoidWarnings))
+ using (AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.Platform, this.Monitor, this.Settings.ParanoidWarnings, this.Settings.RewriteMods))
{
// init
HashSet<string> suppressUpdateChecks = new HashSet<string>(this.Settings.SuppressUpdateChecks, StringComparer.OrdinalIgnoreCase);
diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs
index 42a712ee..af7fa387 100644
--- a/src/SMAPI/Framework/SGame.cs
+++ b/src/SMAPI/Framework/SGame.cs
@@ -11,6 +11,7 @@ using StardewModdingAPI.Framework.Input;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.StateTracking.Snapshots;
using StardewModdingAPI.Framework.Utilities;
+using StardewModdingAPI.Utilities;
using StardewValley;
using StardewValley.BellsAndWhistles;
using StardewValley.Locations;
@@ -81,6 +82,9 @@ namespace StardewModdingAPI.Framework
/// <summary>Whether the game is creating the save file and SMAPI has already raised <see cref="IGameLoopEvents.SaveCreating"/>.</summary>
public bool IsBetweenCreateEvents { get; set; }
+ /// <summary>The cached <see cref="Farmer.UniqueMultiplayerID"/> value for this instance's player.</summary>
+ public long? PlayerId { get; private set; }
+
/// <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]
@@ -121,6 +125,18 @@ namespace StardewModdingAPI.Framework
this.OnUpdating = onUpdating;
}
+ /// <summary>Get the current input state for a button.</summary>
+ /// <param name="button">The button to check.</param>
+ /// <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)
+ throw new InvalidOperationException("SMAPI's input state is not in a ready state yet.");
+
+ return input.GetState(button);
+ }
+
/*********
** Protected methods
@@ -167,6 +183,7 @@ namespace StardewModdingAPI.Framework
try
{
this.OnUpdating(this, gameTime, () => base.Update(gameTime));
+ this.PlayerId = Game1.player?.UniqueMultiplayerID;
}
finally
{
diff --git a/src/SMAPI/Framework/SGameRunner.cs b/src/SMAPI/Framework/SGameRunner.cs
index ae06f513..45e7369c 100644
--- a/src/SMAPI/Framework/SGameRunner.cs
+++ b/src/SMAPI/Framework/SGameRunner.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using StardewModdingAPI.Framework.Events;
@@ -49,6 +50,13 @@ namespace StardewModdingAPI.Framework
/*********
** Public methods
*********/
+ /// <summary>The singleton instance.</summary>
+ public static SGameRunner Instance => (SGameRunner)GameRunner.instance;
+
+
+ /*********
+ ** Public methods
+ *********/
/// <summary>Construct an instance.</summary>
/// <param name="monitor">Encapsulates monitoring and logging for SMAPI.</param>
/// <param name="reflection">Simplifies access to private game code.</param>
@@ -99,15 +107,24 @@ namespace StardewModdingAPI.Framework
}
/// <inheritdoc />
- public override void RemoveGameInstance(Game1 instance)
+ public override void RemoveGameInstance(Game1 gameInstance)
{
- base.RemoveGameInstance(instance);
+ base.RemoveGameInstance(gameInstance);
if (this.gameInstances.Count <= 1)
EarlyConstants.LogScreenId = null;
this.UpdateForSplitScreenChanges();
}
+ /// <summary>Get the screen ID for a given player ID, if the player is local.</summary>
+ /// <param name="playerId">The player ID to check.</param>
+ public int? GetScreenId(long playerId)
+ {
+ return this.gameInstances
+ .FirstOrDefault(p => ((SGame)p).PlayerId == playerId)
+ ?.instanceId;
+ }
+
/*********
** Protected methods
@@ -136,6 +153,7 @@ namespace StardewModdingAPI.Framework
this.OnGameUpdating(gameTime, () => base.Update(gameTime));
}
+ /// <summary>Update metadata when a split screen is added or removed.</summary>
private void UpdateForSplitScreenChanges()
{
HashSet<int> oldScreenIds = new HashSet<int>(Context.ActiveScreenIds);
diff --git a/src/SMAPI/Framework/SMultiplayer.cs b/src/SMAPI/Framework/SMultiplayer.cs
index 2f89fce9..5956b63f 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(() => new Dictionary<long, MultiplayerPeer>());
+ private readonly PerScreen<IDictionary<long, MultiplayerPeer>> PeersImpl = new PerScreen<IDictionary<long, MultiplayerPeer>>(() => new Dictionary<long, MultiplayerPeer>());
/// <summary>The backing field for <see cref="HostPeer"/>.</summary>
- private readonly PerScreen<MultiplayerPeer> HostPeerImpl = new();
+ private readonly PerScreen<MultiplayerPeer> HostPeerImpl = new PerScreen<MultiplayerPeer>();
/*********
@@ -196,7 +196,13 @@ namespace StardewModdingAPI.Framework
this.Monitor.Log($"Received context for farmhand {message.FarmerID} running {(model != null ? $"SMAPI {model.ApiVersion} with {model.Mods.Length} mods" : "vanilla")}.", LogLevel.Trace);
// store peer
- MultiplayerPeer newPeer = new MultiplayerPeer(message.FarmerID, model, sendMessage, isHost: false);
+ MultiplayerPeer newPeer = new MultiplayerPeer(
+ playerID: message.FarmerID,
+ screenID: this.GetScreenId(message.FarmerID),
+ model: model,
+ sendMessage: sendMessage,
+ isHost: false
+ );
if (this.Peers.ContainsKey(message.FarmerID))
{
this.Monitor.Log($"Received mod context from farmhand {message.FarmerID}, but the game didn't see them disconnect. This may indicate issues with the network connection.", LogLevel.Info);
@@ -238,7 +244,13 @@ namespace StardewModdingAPI.Framework
if (!this.Peers.ContainsKey(message.FarmerID))
{
this.Monitor.Log($"Received connection for vanilla player {message.FarmerID}.", LogLevel.Trace);
- MultiplayerPeer peer = new MultiplayerPeer(message.FarmerID, null, sendMessage, isHost: false);
+ MultiplayerPeer peer = new MultiplayerPeer(
+ playerID: message.FarmerID,
+ screenID: this.GetScreenId(message.FarmerID),
+ model: null,
+ sendMessage: sendMessage,
+ isHost: false
+ );
this.AddPeer(peer, canBeHost: false);
}
@@ -280,7 +292,13 @@ namespace StardewModdingAPI.Framework
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);
// store peer
- MultiplayerPeer peer = new MultiplayerPeer(message.FarmerID, model, sendMessage, isHost: model?.IsHost ?? this.HostPeer == null);
+ MultiplayerPeer peer = new MultiplayerPeer(
+ playerID: message.FarmerID,
+ screenID: this.GetScreenId(message.FarmerID),
+ model: model,
+ sendMessage: sendMessage,
+ isHost: model?.IsHost ?? this.HostPeer == null
+ );
if (peer.IsHost && this.HostPeer != null)
{
this.Monitor.Log($"Rejected mod context from host player {peer.PlayerID}: already received host data from {(peer.PlayerID == this.HostPeer.PlayerID ? "that player" : $"player {peer.PlayerID}")}.", LogLevel.Error);
@@ -297,7 +315,14 @@ namespace StardewModdingAPI.Framework
if (!this.Peers.ContainsKey(message.FarmerID) && this.HostPeer == null)
{
this.Monitor.Log($"Received connection for vanilla host {message.FarmerID}.", LogLevel.Trace);
- this.AddPeer(new MultiplayerPeer(message.FarmerID, null, sendMessage, isHost: true), canBeHost: false);
+ var peer = new MultiplayerPeer(
+ playerID: message.FarmerID,
+ screenID: this.GetScreenId(message.FarmerID),
+ model: null,
+ sendMessage: sendMessage,
+ isHost: true
+ );
+ this.AddPeer(peer, canBeHost: false);
}
resume();
break;
@@ -309,7 +334,13 @@ namespace StardewModdingAPI.Framework
// store peer
if (!this.Peers.TryGetValue(message.FarmerID, out MultiplayerPeer peer))
{
- peer = new MultiplayerPeer(message.FarmerID, null, sendMessage, isHost: this.HostPeer == null);
+ peer = new MultiplayerPeer(
+ playerID: message.FarmerID,
+ screenID: this.GetScreenId(message.FarmerID),
+ model: null,
+ sendMessage: sendMessage,
+ isHost: this.HostPeer == null
+ );
this.Monitor.Log($"Received connection for vanilla {(peer.IsHost ? "host" : "farmhand")} {message.FarmerID}.", LogLevel.Trace);
this.AddPeer(peer, canBeHost: true);
}
@@ -361,34 +392,24 @@ namespace StardewModdingAPI.Framework
if (string.IsNullOrWhiteSpace(fromModID))
throw new ArgumentNullException(nameof(fromModID));
- // get target players
- long curPlayerId = Game1.player.UniqueMultiplayerID;
- bool sendToSelf = false;
- List<MultiplayerPeer> sendToPeers = new List<MultiplayerPeer>();
- if (toPlayerIDs == null)
- {
- sendToSelf = true;
- sendToPeers.AddRange(this.Peers.Values);
- }
- else
+ // get valid peers
+ var sendToPeers = this.Peers.Values.Where(p => p.HasSmapi).ToList();
+ bool sendToSelf = true;
+
+ // filter by player ID
+ if (toPlayerIDs != null)
{
- foreach (long id in toPlayerIDs.Distinct())
- {
- if (id == curPlayerId)
- sendToSelf = true;
- else if (this.Peers.TryGetValue(id, out MultiplayerPeer peer) && peer.HasSmapi)
- sendToPeers.Add(peer);
- }
+ var ids = new HashSet<long>(toPlayerIDs);
+ sendToPeers.RemoveAll(peer => !ids.Contains(peer.PlayerID));
+ sendToSelf = ids.Contains(Game1.player.UniqueMultiplayerID);
}
// filter by mod ID
if (toModIDs != null)
{
- HashSet<string> sendToMods = new HashSet<string>(toModIDs, StringComparer.OrdinalIgnoreCase);
- if (sendToSelf && toModIDs.All(id => this.ModRegistry.Get(id) == null))
- sendToSelf = false;
-
- sendToPeers.RemoveAll(peer => peer.Mods.All(mod => !sendToMods.Contains(mod.ID)));
+ var ids = new HashSet<string>(toModIDs, StringComparer.OrdinalIgnoreCase);
+ sendToPeers.RemoveAll(peer => peer.Mods.All(mod => !ids.Contains(mod.ID)));
+ sendToSelf = sendToSelf && toModIDs.Any(id => this.ModRegistry.Get(id) != null);
}
// validate recipients
@@ -505,6 +526,13 @@ namespace StardewModdingAPI.Framework
}
}
+ /// <summary>Get the screen ID for a given player ID, if the player is local.</summary>
+ /// <param name="playerId">The player ID to check.</param>
+ private int? GetScreenId(long playerId)
+ {
+ return SGameRunner.Instance.GetScreenId(playerId);
+ }
+
/// <summary>Get all connected player IDs, including the current player.</summary>
private IEnumerable<long> GetKnownPlayerIDs()
{
diff --git a/src/SMAPI/Framework/Serialization/KeybindConverter.cs b/src/SMAPI/Framework/Serialization/KeybindConverter.cs
new file mode 100644
index 00000000..93a274a8
--- /dev/null
+++ b/src/SMAPI/Framework/Serialization/KeybindConverter.cs
@@ -0,0 +1,82 @@
+using System;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using StardewModdingAPI.Toolkit.Serialization;
+using StardewModdingAPI.Utilities;
+
+namespace StardewModdingAPI.Framework.Serialization
+{
+ /// <summary>Handles deserialization of <see cref="Keybind"/> and <see cref="KeybindList"/> models.</summary>
+ internal class KeybindConverter : JsonConverter
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <inheritdoc />
+ public override bool CanRead { get; } = true;
+
+ /// <inheritdoc />
+ public override bool CanWrite { get; } = true;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Get whether this instance can convert the specified object type.</summary>
+ /// <param name="objectType">The object type.</param>
+ public override bool CanConvert(Type objectType)
+ {
+ return
+ typeof(Keybind).IsAssignableFrom(objectType)
+ || typeof(KeybindList).IsAssignableFrom(objectType);
+ }
+
+ /// <summary>Reads the JSON representation of the object.</summary>
+ /// <param name="reader">The JSON reader.</param>
+ /// <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)
+ {
+ string path = reader.Path;
+
+ switch (reader.TokenType)
+ {
+ case JsonToken.Null:
+ return objectType == typeof(Keybind)
+ ? (object)new Keybind()
+ : new KeybindList();
+
+ case JsonToken.String:
+ {
+ string str = JToken.Load(reader).Value<string>();
+
+ if (objectType == typeof(Keybind))
+ {
+ 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)
+ ? parsed
+ : throw new SParseException($"Can't parse {nameof(KeybindList)} from invalid value '{str}' (path: {path}).\n{string.Join("\n", errors)}");
+ }
+ }
+
+ default:
+ throw new SParseException($"Can't parse {objectType} from unexpected {reader.TokenType} node (path: {reader.Path}).");
+ }
+ }
+
+ /// <summary>Writes the JSON representation of the object.</summary>
+ /// <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)
+ {
+ writer.WriteValue(value?.ToString());
+ }
+ }
+}
diff --git a/src/SMAPI/IInputHelper.cs b/src/SMAPI/IInputHelper.cs
index e9768c24..2b907b0d 100644
--- a/src/SMAPI/IInputHelper.cs
+++ b/src/SMAPI/IInputHelper.cs
@@ -1,3 +1,5 @@
+using StardewModdingAPI.Utilities;
+
namespace StardewModdingAPI
{
/// <summary>Provides an API for checking and changing input state.</summary>
@@ -18,6 +20,10 @@ namespace StardewModdingAPI
/// <param name="button">The button to suppress.</param>
void Suppress(SButton button);
+ /// <summary>Suppress the keybinds which are currently down.</summary>
+ /// <param name="keybindList">The keybind list whose active keybinds to suppress.</param>
+ void SuppressActiveKeybinds(KeybindList keybindList);
+
/// <summary>Get the state of a button.</summary>
/// <param name="button">The button to check.</param>
SButtonState GetState(SButton button);
diff --git a/src/SMAPI/IMultiplayerPeer.cs b/src/SMAPI/IMultiplayerPeer.cs
index 0d4d3261..47084174 100644
--- a/src/SMAPI/IMultiplayerPeer.cs
+++ b/src/SMAPI/IMultiplayerPeer.cs
@@ -14,9 +14,16 @@ namespace StardewModdingAPI
/// <summary>Whether this is a connection to the host player.</summary>
bool IsHost { get; }
+ /// <summary>Whether this a local player on the same computer in split-screen mote.</summary>
+ bool IsSplitScreen { get; }
+
/// <summary>Whether the player has SMAPI installed.</summary>
bool HasSmapi { get; }
+ /// <summary>The player's screen ID, if applicable.</summary>
+ /// <remarks>See <see cref="Context.ScreenId"/> for details. This is only visible to players in split-screen mode. A remote player won't see this value, even if the other players are in split-screen mode.</remarks>
+ int? ScreenID { get; }
+
/// <summary>The player's OS platform, if <see cref="HasSmapi"/> is true.</summary>
GamePlatform? Platform { get; }
diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs
index bd1cc50e..4b911a83 100644
--- a/src/SMAPI/Metadata/CoreAssetPropagator.cs
+++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs
@@ -374,52 +374,6 @@ namespace StardewModdingAPI.Metadata
return this.ReloadSuspensionBridges(content, key);
/****
- ** Content\TileSheets
- ****/
- case "tilesheets\\chairtiles": // Game1.LoadContent
- MapSeat.mapChairTexture = content.Load<Texture2D>(key);
- return true;
-
- case "tilesheets\\critters": // Critter constructor
- return this.ReloadCritterTextures(content, key) > 0;
-
- case "tilesheets\\crops": // Game1.LoadContent
- Game1.cropSpriteSheet = content.Load<Texture2D>(key);
- return true;
-
- case "tilesheets\\debris": // Game1.LoadContent
- Game1.debrisSpriteSheet = content.Load<Texture2D>(key);
- return true;
-
- case "tilesheets\\emotes": // Game1.LoadContent
- Game1.emoteSpriteSheet = content.Load<Texture2D>(key);
- return true;
-
- case "tilesheets\\furniture": // Game1.LoadContent
- Furniture.furnitureTexture = content.Load<Texture2D>(key);
- return true;
-
- case "tilesheets\\furniturefront": // Game1.LoadContent
- Furniture.furnitureFrontTexture = content.Load<Texture2D>(key);
- return true;
-
- case "tilesheets\\projectiles": // Game1.LoadContent
- Projectile.projectileSheet = content.Load<Texture2D>(key);
- return true;
-
- case "tilesheets\\rain": // Game1.LoadContent
- Game1.rainTexture = content.Load<Texture2D>(key);
- return true;
-
- case "tilesheets\\tools": // Game1.ResetToolSpriteSheet
- Game1.ResetToolSpriteSheet();
- return true;
-
- case "tilesheets\\weapons": // Game1.LoadContent
- Tool.weaponsTexture = content.Load<Texture2D>(key);
- return true;
-
- /****
** Content\Maps
****/
case "maps\\menutiles": // Game1.LoadContent
@@ -455,6 +409,12 @@ namespace StardewModdingAPI.Metadata
return this.ReloadTitleButtons(content, key);
/****
+ ** Content\Strings
+ ****/
+ case "strings\\stringsfromcsfiles":
+ return this.ReloadStringsFromCsFiles(content);
+
+ /****
** Content\TileSheets
****/
case "tilesheets\\animations": // Game1.LoadContent
@@ -469,14 +429,57 @@ namespace StardewModdingAPI.Metadata
Bush.texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key));
return true;
+ case "tilesheets\\chairtiles": // Game1.LoadContent
+ MapSeat.mapChairTexture = content.Load<Texture2D>(key);
+ return true;
+
case "tilesheets\\craftables": // Game1.LoadContent
Game1.bigCraftableSpriteSheet = content.Load<Texture2D>(key);
return true;
+ case "tilesheets\\critters": // Critter constructor
+ return this.ReloadCritterTextures(content, key) > 0;
+
+ case "tilesheets\\crops": // Game1.LoadContent
+ Game1.cropSpriteSheet = content.Load<Texture2D>(key);
+ return true;
+
+ case "tilesheets\\debris": // Game1.LoadContent
+ Game1.debrisSpriteSheet = content.Load<Texture2D>(key);
+ return true;
+
+ case "tilesheets\\emotes": // Game1.LoadContent
+ Game1.emoteSpriteSheet = content.Load<Texture2D>(key);
+ return true;
+
case "tilesheets\\fruittrees": // FruitTree
FruitTree.texture = content.Load<Texture2D>(key);
return true;
+ case "tilesheets\\furniture": // Game1.LoadContent
+ Furniture.furnitureTexture = content.Load<Texture2D>(key);
+ return true;
+
+ case "tilesheets\\furniturefront": // Game1.LoadContent
+ Furniture.furnitureFrontTexture = content.Load<Texture2D>(key);
+ return true;
+
+ case "tilesheets\\projectiles": // Game1.LoadContent
+ Projectile.projectileSheet = content.Load<Texture2D>(key);
+ return true;
+
+ case "tilesheets\\rain": // Game1.LoadContent
+ Game1.rainTexture = content.Load<Texture2D>(key);
+ return true;
+
+ case "tilesheets\\tools": // Game1.ResetToolSpriteSheet
+ Game1.ResetToolSpriteSheet();
+ return true;
+
+ case "tilesheets\\weapons": // Game1.LoadContent
+ Tool.weaponsTexture = content.Load<Texture2D>(key);
+ return true;
+
/****
** Content\TerrainFeatures
****/
@@ -528,6 +531,9 @@ namespace StardewModdingAPI.Metadata
return this.ReloadTreeTextures(content, key, Tree.pineTree);
}
+ /****
+ ** Dynamic assets
+ ****/
// dynamic textures
if (this.KeyStartsWith(key, "animals\\cat"))
return this.ReloadPetOrHorseSprites<Cat>(content, key);
@@ -778,26 +784,10 @@ namespace StardewModdingAPI.Metadata
/// <param name="location">The location whose map to reload.</param>
private void ReloadMap(GameLocation location)
{
- // reset patch caches
- switch (location)
- {
- case Town _:
- this.Reflection.GetField<bool>(location, "ccRefurbished").SetValue(false);
- this.Reflection.GetField<bool>(location, "isShowingDestroyedJoja").SetValue(false);
- this.Reflection.GetField<bool>(location, "isShowingUpgradedPamHouse").SetValue(false);
- break;
-
- case Beach _:
- case BeachNightMarket _:
- case Forest _:
- this.Reflection.GetField<bool>(location, "hasShownCCUpgrade").SetValue(false);
- break;
- }
-
- // general updates
+ // reload map
location.reloadMap();
- location.updateSeasonalTileSheets();
location.updateWarps();
+ location.MakeMapModifications(force: true);
// update interior doors
location.interiorDoors.Clear();
@@ -1028,6 +1018,27 @@ namespace StardewModdingAPI.Metadata
return true;
}
+ /// <summary>Reload cached translations from the <c>Strings\StringsFromCSFiles</c> asset.</summary>
+ /// <param name="content">The content manager through which to reload the asset.</param>
+ /// <returns>Returns whether any data was reloaded.</returns>
+ /// <remarks>Derived from the <see cref="Game1.TranslateFields"/>.</remarks>
+ private bool ReloadStringsFromCsFiles(LocalizedContentManager content)
+ {
+ Game1.samBandName = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.2156");
+ Game1.elliottBookName = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.2157");
+
+ string[] dayNames = this.Reflection.GetField<string[]>(typeof(Game1), "_shortDayDisplayName").GetValue();
+ dayNames[0] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3042");
+ dayNames[1] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3043");
+ dayNames[2] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3044");
+ dayNames[3] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3045");
+ dayNames[4] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3046");
+ dayNames[5] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3047");
+ dayNames[6] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3048");
+
+ return true;
+ }
+
/****
** Helpers
****/
diff --git a/src/SMAPI/Metadata/InstructionMetadata.cs b/src/SMAPI/Metadata/InstructionMetadata.cs
index 2c1e14ce..d1699636 100644
--- a/src/SMAPI/Metadata/InstructionMetadata.cs
+++ b/src/SMAPI/Metadata/InstructionMetadata.cs
@@ -27,28 +27,32 @@ namespace StardewModdingAPI.Metadata
/// <summary>Get rewriters which detect or fix incompatible CIL instructions in mod assemblies.</summary>
/// <param name="paranoidMode">Whether to detect paranoid mode issues.</param>
/// <param name="platformChanged">Whether the assembly was rewritten for crossplatform compatibility.</param>
- public IEnumerable<IInstructionHandler> GetHandlers(bool paranoidMode, bool platformChanged)
+ /// <param name="rewriteMods">Whether to get handlers which rewrite mods for compatibility.</param>
+ public IEnumerable<IInstructionHandler> GetHandlers(bool paranoidMode, bool platformChanged, bool rewriteMods)
{
/****
** rewrite CIL to fix incompatible code
****/
// rewrite for crossplatform compatibility
- if (platformChanged)
- yield return new MethodParentRewriter(typeof(SpriteBatch), typeof(SpriteBatchFacade));
+ if (rewriteMods)
+ {
+ if (platformChanged)
+ yield return new MethodParentRewriter(typeof(SpriteBatch), typeof(SpriteBatchFacade));
- // rewrite for Stardew Valley 1.5
- yield return new FieldReplaceRewriter(typeof(DecoratableLocation), "furniture", typeof(GameLocation), nameof(GameLocation.furniture));
- yield return new FieldReplaceRewriter(typeof(Farm), "resourceClumps", typeof(GameLocation), nameof(GameLocation.resourceClumps));
- yield return new FieldReplaceRewriter(typeof(MineShaft), "resourceClumps", typeof(GameLocation), nameof(GameLocation.resourceClumps));
+ // rewrite for Stardew Valley 1.5
+ yield return new FieldReplaceRewriter(typeof(DecoratableLocation), "furniture", typeof(GameLocation), nameof(GameLocation.furniture));
+ yield return new FieldReplaceRewriter(typeof(Farm), "resourceClumps", typeof(GameLocation), nameof(GameLocation.resourceClumps));
+ yield return new FieldReplaceRewriter(typeof(MineShaft), "resourceClumps", typeof(GameLocation), nameof(GameLocation.resourceClumps));
- // heuristic rewrites
- yield return new HeuristicFieldRewriter(this.ValidateReferencesToAssemblies);
- yield return new HeuristicMethodRewriter(this.ValidateReferencesToAssemblies);
+ // heuristic rewrites
+ yield return new HeuristicFieldRewriter(this.ValidateReferencesToAssemblies);
+ yield return new HeuristicMethodRewriter(this.ValidateReferencesToAssemblies);
#if HARMONY_2
- // rewrite for SMAPI 3.6 (Harmony 1.x => 2.0 update)
- yield return new Harmony1AssemblyRewriter();
+ // rewrite for SMAPI 3.x (Harmony 1.x => 2.0 update)
+ yield return new Harmony1AssemblyRewriter();
#endif
+ }
/****
** detect mod issues
diff --git a/src/SMAPI/Patches/DialogueErrorPatch.cs b/src/SMAPI/Patches/DialogueErrorPatch.cs
deleted file mode 100644
index 215df561..00000000
--- a/src/SMAPI/Patches/DialogueErrorPatch.cs
+++ /dev/null
@@ -1,192 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
-using StardewModdingAPI.Framework.Patching;
-using StardewModdingAPI.Framework.Reflection;
-using StardewValley;
-#if HARMONY_2
-using HarmonyLib;
-using StardewModdingAPI.Framework;
-#else
-using System.Reflection;
-using Harmony;
-#endif
-
-namespace StardewModdingAPI.Patches
-{
- /// <summary>A Harmony patch for the <see cref="Dialogue"/> constructor which intercepts invalid dialogue lines and logs an error instead of crashing.</summary>
- /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks>
- [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")]
- [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")]
- internal class DialogueErrorPatch : IHarmonyPatch
- {
- /*********
- ** Fields
- *********/
- /// <summary>Writes messages to the console and log file on behalf of the game.</summary>
- private static IMonitor MonitorForGame;
-
- /// <summary>Simplifies access to private code.</summary>
- private static Reflector Reflection;
-
-
- /*********
- ** Accessors
- *********/
- /// <inheritdoc />
- public string Name => nameof(DialogueErrorPatch);
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Construct an instance.</summary>
- /// <param name="monitorForGame">Writes messages to the console and log file on behalf of the game.</param>
- /// <param name="reflector">Simplifies access to private code.</param>
- public DialogueErrorPatch(IMonitor monitorForGame, Reflector reflector)
- {
- DialogueErrorPatch.MonitorForGame = monitorForGame;
- DialogueErrorPatch.Reflection = reflector;
- }
-
-
- /// <inheritdoc />
-#if HARMONY_2
- public void Apply(Harmony harmony)
- {
- harmony.Patch(
- original: AccessTools.Constructor(typeof(Dialogue), new[] { typeof(string), typeof(NPC) }),
- finalizer: new HarmonyMethod(this.GetType(), nameof(DialogueErrorPatch.Finalize_Dialogue_Constructor))
- );
- harmony.Patch(
- original: AccessTools.Property(typeof(NPC), nameof(NPC.CurrentDialogue)).GetMethod,
- finalizer: new HarmonyMethod(this.GetType(), nameof(DialogueErrorPatch.Finalize_NPC_CurrentDialogue))
- );
- }
-#else
- public void Apply(HarmonyInstance harmony)
- {
- harmony.Patch(
- original: AccessTools.Constructor(typeof(Dialogue), new[] { typeof(string), typeof(NPC) }),
- prefix: new HarmonyMethod(this.GetType(), nameof(DialogueErrorPatch.Before_Dialogue_Constructor))
- );
- harmony.Patch(
- original: AccessTools.Property(typeof(NPC), nameof(NPC.CurrentDialogue)).GetMethod,
- prefix: new HarmonyMethod(this.GetType(), nameof(DialogueErrorPatch.Before_NPC_CurrentDialogue))
- );
- }
-#endif
-
-
- /*********
- ** Private methods
- *********/
-#if HARMONY_2
- /// <summary>The method to call after the Dialogue constructor.</summary>
- /// <param name="__instance">The instance being patched.</param>
- /// <param name="masterDialogue">The dialogue being parsed.</param>
- /// <param name="speaker">The NPC for which the dialogue is being parsed.</param>
- /// <param name="__exception">The exception thrown by the wrapped method, if any.</param>
- /// <returns>Returns the exception to throw, if any.</returns>
- private static Exception Finalize_Dialogue_Constructor(Dialogue __instance, string masterDialogue, NPC speaker, Exception __exception)
- {
- if (__exception != null)
- {
- // log message
- string name = !string.IsNullOrWhiteSpace(speaker?.Name) ? speaker.Name : null;
- DialogueErrorPatch.MonitorForGame.Log($"Failed parsing dialogue string{(name != null ? $" for {name}" : "")}:\n{masterDialogue}\n{__exception.GetLogSummary()}", LogLevel.Error);
-
- // set default dialogue
- IReflectedMethod parseDialogueString = DialogueErrorPatch.Reflection.GetMethod(__instance, "parseDialogueString");
- IReflectedMethod checkForSpecialDialogueAttributes = DialogueErrorPatch.Reflection.GetMethod(__instance, "checkForSpecialDialogueAttributes");
- parseDialogueString.Invoke("...");
- checkForSpecialDialogueAttributes.Invoke();
- }
-
- return null;
- }
-
- /// <summary>The method to call after <see cref="NPC.CurrentDialogue"/>.</summary>
- /// <param name="__instance">The instance being patched.</param>
- /// <param name="__result">The return value of the original method.</param>
- /// <param name="__exception">The exception thrown by the wrapped method, if any.</param>
- /// <returns>Returns the exception to throw, if any.</returns>
- private static Exception Finalize_NPC_CurrentDialogue(NPC __instance, ref Stack<Dialogue> __result, Exception __exception)
- {
- if (__exception == null)
- return null;
-
- DialogueErrorPatch.MonitorForGame.Log($"Failed loading current dialogue for NPC {__instance.Name}:\n{__exception.GetLogSummary()}", LogLevel.Error);
- __result = new Stack<Dialogue>();
-
- return null;
- }
-#else
-
- /// <summary>The method to call instead of the Dialogue constructor.</summary>
- /// <param name="__instance">The instance being patched.</param>
- /// <param name="masterDialogue">The dialogue being parsed.</param>
- /// <param name="speaker">The NPC for which the dialogue is being parsed.</param>
- /// <returns>Returns whether to execute the original method.</returns>
- private static bool Before_Dialogue_Constructor(Dialogue __instance, string masterDialogue, NPC speaker)
- {
- // get private members
- bool nameArraysTranslated = DialogueErrorPatch.Reflection.GetField<bool>(typeof(Dialogue), "nameArraysTranslated").GetValue();
- IReflectedMethod translateArraysOfStrings = DialogueErrorPatch.Reflection.GetMethod(typeof(Dialogue), "TranslateArraysOfStrings");
- IReflectedMethod parseDialogueString = DialogueErrorPatch.Reflection.GetMethod(__instance, "parseDialogueString");
- IReflectedMethod checkForSpecialDialogueAttributes = DialogueErrorPatch.Reflection.GetMethod(__instance, "checkForSpecialDialogueAttributes");
-
- // replicate base constructor
- __instance.dialogues ??= new List<string>();
-
- // duplicate code with try..catch
- try
- {
- if (!nameArraysTranslated)
- translateArraysOfStrings.Invoke();
- __instance.speaker = speaker;
- parseDialogueString.Invoke(masterDialogue);
- checkForSpecialDialogueAttributes.Invoke();
- }
- catch (Exception baseEx) when (baseEx.InnerException is TargetInvocationException invocationEx && invocationEx.InnerException is Exception ex)
- {
- string name = !string.IsNullOrWhiteSpace(speaker?.Name) ? speaker.Name : null;
- DialogueErrorPatch.MonitorForGame.Log($"Failed parsing dialogue string{(name != null ? $" for {name}" : "")}:\n{masterDialogue}\n{ex}", LogLevel.Error);
-
- parseDialogueString.Invoke("...");
- checkForSpecialDialogueAttributes.Invoke();
- }
-
- return false;
- }
-
- /// <summary>The method to call instead of <see cref="NPC.CurrentDialogue"/>.</summary>
- /// <param name="__instance">The instance being patched.</param>
- /// <param name="__result">The return value of the original method.</param>
- /// <param name="__originalMethod">The method being wrapped.</param>
- /// <returns>Returns whether to execute the original method.</returns>
- private static bool Before_NPC_CurrentDialogue(NPC __instance, ref Stack<Dialogue> __result, MethodInfo __originalMethod)
- {
- const string key = nameof(Before_NPC_CurrentDialogue);
- if (!PatchHelper.StartIntercept(key))
- return true;
-
- try
- {
- __result = (Stack<Dialogue>)__originalMethod.Invoke(__instance, new object[0]);
- return false;
- }
- catch (TargetInvocationException ex)
- {
- DialogueErrorPatch.MonitorForGame.Log($"Failed loading current dialogue for NPC {__instance.Name}:\n{ex.InnerException ?? ex}", LogLevel.Error);
- __result = new Stack<Dialogue>();
- return false;
- }
- finally
- {
- PatchHelper.StopIntercept(key);
- }
- }
-#endif
- }
-}
diff --git a/src/SMAPI/Patches/EventErrorPatch.cs b/src/SMAPI/Patches/EventErrorPatch.cs
deleted file mode 100644
index 46651387..00000000
--- a/src/SMAPI/Patches/EventErrorPatch.cs
+++ /dev/null
@@ -1,114 +0,0 @@
-using System.Diagnostics.CodeAnalysis;
-#if HARMONY_2
-using System;
-using HarmonyLib;
-#else
-using System.Reflection;
-using Harmony;
-#endif
-using StardewModdingAPI.Framework.Patching;
-using StardewValley;
-
-namespace StardewModdingAPI.Patches
-{
- /// <summary>A Harmony patch for <see cref="GameLocation.checkEventPrecondition"/> which intercepts invalid preconditions and logs an error instead of crashing.</summary>
- /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks>
- [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")]
- [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")]
- internal class EventErrorPatch : IHarmonyPatch
- {
- /*********
- ** Fields
- *********/
- /// <summary>Writes messages to the console and log file on behalf of the game.</summary>
- private static IMonitor MonitorForGame;
-
-
- /*********
- ** Accessors
- *********/
- /// <inheritdoc />
- public string Name => nameof(EventErrorPatch);
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Construct an instance.</summary>
- /// <param name="monitorForGame">Writes messages to the console and log file on behalf of the game.</param>
- public EventErrorPatch(IMonitor monitorForGame)
- {
- EventErrorPatch.MonitorForGame = monitorForGame;
- }
-
- /// <inheritdoc />
-#if HARMONY_2
- public void Apply(Harmony harmony)
- {
- harmony.Patch(
- original: AccessTools.Method(typeof(GameLocation), "checkEventPrecondition"),
- finalizer: new HarmonyMethod(this.GetType(), nameof(EventErrorPatch.Finalize_GameLocation_CheckEventPrecondition))
- );
- }
-#else
- public void Apply(HarmonyInstance harmony)
- {
- harmony.Patch(
- original: AccessTools.Method(typeof(GameLocation), "checkEventPrecondition"),
- prefix: new HarmonyMethod(this.GetType(), nameof(EventErrorPatch.Before_GameLocation_CheckEventPrecondition))
- );
- }
-#endif
-
-
- /*********
- ** Private methods
- *********/
-#if HARMONY_2
- /// <summary>The method to call instead of GameLocation.checkEventPrecondition.</summary>
- /// <param name="__result">The return value of the original method.</param>
- /// <param name="precondition">The precondition to be parsed.</param>
- /// <param name="__exception">The exception thrown by the wrapped method, if any.</param>
- /// <returns>Returns the exception to throw, if any.</returns>
- private static Exception Finalize_GameLocation_CheckEventPrecondition(ref int __result, string precondition, Exception __exception)
- {
- if (__exception != null)
- {
- __result = -1;
- EventErrorPatch.MonitorForGame.Log($"Failed parsing event precondition ({precondition}):\n{__exception.InnerException}", LogLevel.Error);
- }
-
- return null;
- }
-#else
- /// <summary>The method to call instead of GameLocation.checkEventPrecondition.</summary>
- /// <param name="__instance">The instance being patched.</param>
- /// <param name="__result">The return value of the original method.</param>
- /// <param name="precondition">The precondition to be parsed.</param>
- /// <param name="__originalMethod">The method being wrapped.</param>
- /// <returns>Returns whether to execute the original method.</returns>
- private static bool Before_GameLocation_CheckEventPrecondition(GameLocation __instance, ref int __result, string precondition, MethodInfo __originalMethod)
- {
- const string key = nameof(Before_GameLocation_CheckEventPrecondition);
- if (!PatchHelper.StartIntercept(key))
- return true;
-
- try
- {
- __result = (int)__originalMethod.Invoke(__instance, new object[] { precondition });
- return false;
- }
- catch (TargetInvocationException ex)
- {
- __result = -1;
- EventErrorPatch.MonitorForGame.Log($"Failed parsing event precondition ({precondition}):\n{ex.InnerException}", LogLevel.Error);
- return false;
- }
- finally
- {
- PatchHelper.StopIntercept(key);
- }
- }
-#endif
- }
-}
diff --git a/src/SMAPI/Patches/LoadErrorPatch.cs b/src/SMAPI/Patches/LoadErrorPatch.cs
deleted file mode 100644
index f5ee5d71..00000000
--- a/src/SMAPI/Patches/LoadErrorPatch.cs
+++ /dev/null
@@ -1,157 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
-using System.Linq;
-#if HARMONY_2
-using HarmonyLib;
-#else
-using Harmony;
-#endif
-using StardewModdingAPI.Framework.Exceptions;
-using StardewModdingAPI.Framework.Patching;
-using StardewValley;
-using StardewValley.Buildings;
-using StardewValley.Locations;
-
-namespace StardewModdingAPI.Patches
-{
- /// <summary>A Harmony patch for <see cref="SaveGame"/> which prevents some errors due to broken save data.</summary>
- /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks>
- [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")]
- [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")]
- internal class LoadErrorPatch : IHarmonyPatch
- {
- /*********
- ** Fields
- *********/
- /// <summary>Writes messages to the console and log file.</summary>
- private static IMonitor Monitor;
-
- /// <summary>A callback invoked when custom content is removed from the save data to avoid a crash.</summary>
- private static Action OnContentRemoved;
-
-
- /*********
- ** Accessors
- *********/
- /// <inheritdoc />
- public string Name => nameof(LoadErrorPatch);
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Construct an instance.</summary>
- /// <param name="monitor">Writes messages to the console and log file.</param>
- /// <param name="onContentRemoved">A callback invoked when custom content is removed from the save data to avoid a crash.</param>
- public LoadErrorPatch(IMonitor monitor, Action onContentRemoved)
- {
- LoadErrorPatch.Monitor = monitor;
- LoadErrorPatch.OnContentRemoved = onContentRemoved;
- }
-
-
- /// <inheritdoc />
-#if HARMONY_2
- public void Apply(Harmony harmony)
-#else
- public void Apply(HarmonyInstance harmony)
-#endif
- {
- harmony.Patch(
- original: AccessTools.Method(typeof(SaveGame), nameof(SaveGame.loadDataToLocations)),
- prefix: new HarmonyMethod(this.GetType(), nameof(LoadErrorPatch.Before_SaveGame_LoadDataToLocations))
- );
- }
-
-
- /*********
- ** Private methods
- *********/
- /// <summary>The method to call instead of <see cref="SaveGame.loadDataToLocations"/>.</summary>
- /// <param name="gamelocations">The game locations being loaded.</param>
- /// <returns>Returns whether to execute the original method.</returns>
- private static bool Before_SaveGame_LoadDataToLocations(List<GameLocation> gamelocations)
- {
- bool removedAny =
- LoadErrorPatch.RemoveBrokenBuildings(gamelocations)
- | LoadErrorPatch.RemoveInvalidNpcs(gamelocations);
-
- if (removedAny)
- LoadErrorPatch.OnContentRemoved();
-
- return true;
- }
-
- /// <summary>Remove buildings which don't exist in the game data.</summary>
- /// <param name="locations">The current game locations.</param>
- private static bool RemoveBrokenBuildings(IEnumerable<GameLocation> locations)
- {
- bool removedAny = false;
-
- foreach (BuildableGameLocation location in locations.OfType<BuildableGameLocation>())
- {
- foreach (Building building in location.buildings.ToArray())
- {
- try
- {
- BluePrint _ = new BluePrint(building.buildingType.Value);
- }
- catch (SContentLoadException)
- {
- LoadErrorPatch.Monitor.Log($"Removed invalid building type '{building.buildingType.Value}' in {location.Name} ({building.tileX}, {building.tileY}) to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom building mod?)", LogLevel.Warn);
- location.buildings.Remove(building);
- removedAny = true;
- }
- }
- }
-
- return removedAny;
- }
-
- /// <summary>Remove NPCs which don't exist in the game data.</summary>
- /// <param name="locations">The current game locations.</param>
- private static bool RemoveInvalidNpcs(IEnumerable<GameLocation> locations)
- {
- bool removedAny = false;
-
- IDictionary<string, string> data = Game1.content.Load<Dictionary<string, string>>("Data\\NPCDispositions");
- foreach (GameLocation location in LoadErrorPatch.GetAllLocations(locations))
- {
- foreach (NPC npc in location.characters.ToArray())
- {
- if (npc.isVillager() && !data.ContainsKey(npc.Name))
- {
- try
- {
- npc.reloadSprite(); // this won't crash for special villagers like Bouncer
- }
- catch
- {
- LoadErrorPatch.Monitor.Log($"Removed invalid villager '{npc.Name}' in {location.Name} ({npc.getTileLocation()}) to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom NPC mod?)", LogLevel.Warn);
- location.characters.Remove(npc);
- removedAny = true;
- }
- }
- }
- }
-
- return removedAny;
- }
-
- /// <summary>Get all locations, including building interiors.</summary>
- /// <param name="locations">The main game locations.</param>
- private static IEnumerable<GameLocation> GetAllLocations(IEnumerable<GameLocation> locations)
- {
- foreach (GameLocation location in locations)
- {
- yield return location;
- if (location is BuildableGameLocation buildableLocation)
- {
- foreach (GameLocation interior in buildableLocation.buildings.Select(p => p.indoors.Value).Where(p => p != null))
- yield return interior;
- }
- }
- }
- }
-}
diff --git a/src/SMAPI/Patches/ObjectErrorPatch.cs b/src/SMAPI/Patches/ObjectErrorPatch.cs
deleted file mode 100644
index 64b8e6b6..00000000
--- a/src/SMAPI/Patches/ObjectErrorPatch.cs
+++ /dev/null
@@ -1,143 +0,0 @@
-using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
-using StardewModdingAPI.Framework.Patching;
-using StardewValley;
-using StardewValley.Menus;
-using SObject = StardewValley.Object;
-#if HARMONY_2
-using System;
-using HarmonyLib;
-#else
-using System.Reflection;
-using Harmony;
-#endif
-
-namespace StardewModdingAPI.Patches
-{
- /// <summary>A Harmony patch for <see cref="SObject.getDescription"/> which intercepts crashes due to the item no longer existing.</summary>
- /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks>
- [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")]
- [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")]
- internal class ObjectErrorPatch : IHarmonyPatch
- {
- /*********
- ** Accessors
- *********/
- /// <inheritdoc />
- public string Name => nameof(ObjectErrorPatch);
-
-
- /*********
- ** Public methods
- *********/
- /// <inheritdoc />
-#if HARMONY_2
- public void Apply(Harmony harmony)
-#else
- public void Apply(HarmonyInstance harmony)
-#endif
- {
- // object.getDescription
- harmony.Patch(
- original: AccessTools.Method(typeof(SObject), nameof(SObject.getDescription)),
- prefix: new HarmonyMethod(this.GetType(), nameof(ObjectErrorPatch.Before_Object_GetDescription))
- );
-
- // object.getDisplayName
- harmony.Patch(
- original: AccessTools.Method(typeof(SObject), "loadDisplayName"),
-#if HARMONY_2
- finalizer: new HarmonyMethod(this.GetType(), nameof(ObjectErrorPatch.Finalize_Object_loadDisplayName))
-#else
- prefix: new HarmonyMethod(this.GetType(), nameof(ObjectErrorPatch.Before_Object_loadDisplayName))
-#endif
- );
-
- // IClickableMenu.drawToolTip
- harmony.Patch(
- original: AccessTools.Method(typeof(IClickableMenu), nameof(IClickableMenu.drawToolTip)),
- prefix: new HarmonyMethod(this.GetType(), nameof(ObjectErrorPatch.Before_IClickableMenu_DrawTooltip))
- );
- }
-
-
- /*********
- ** Private methods
- *********/
- /// <summary>The method to call instead of <see cref="StardewValley.Object.getDescription"/>.</summary>
- /// <param name="__instance">The instance being patched.</param>
- /// <param name="__result">The patched method's return value.</param>
- /// <returns>Returns whether to execute the original method.</returns>
- private static bool Before_Object_GetDescription(SObject __instance, ref string __result)
- {
- // invalid bigcraftables crash instead of showing '???' like invalid non-bigcraftables
- if (!__instance.IsRecipe && __instance.bigCraftable.Value && !Game1.bigCraftablesInformation.ContainsKey(__instance.ParentSheetIndex))
- {
- __result = "???";
- return false;
- }
-
- return true;
- }
-
-#if HARMONY_2
- /// <summary>The method to call after <see cref="StardewValley.Object.loadDisplayName"/>.</summary>
- /// <param name="__result">The patched method's return value.</param>
- /// <param name="__exception">The exception thrown by the wrapped method, if any.</param>
- /// <returns>Returns the exception to throw, if any.</returns>
- private static Exception Finalize_Object_loadDisplayName(ref string __result, Exception __exception)
- {
- if (__exception is KeyNotFoundException)
- {
- __result = "???";
- return null;
- }
-
- return __exception;
- }
-#else
- /// <summary>The method to call instead of <see cref="StardewValley.Object.loadDisplayName"/>.</summary>
- /// <param name="__instance">The instance being patched.</param>
- /// <param name="__result">The patched method's return value.</param>
- /// <param name="__originalMethod">The method being wrapped.</param>
- /// <returns>Returns whether to execute the original method.</returns>
- private static bool Before_Object_loadDisplayName(SObject __instance, ref string __result, MethodInfo __originalMethod)
- {
- const string key = nameof(Before_Object_loadDisplayName);
- if (!PatchHelper.StartIntercept(key))
- return true;
-
- try
- {
- __result = (string)__originalMethod.Invoke(__instance, new object[0]);
- return false;
- }
- catch (TargetInvocationException ex) when (ex.InnerException is KeyNotFoundException)
- {
- __result = "???";
- return false;
- }
- catch
- {
- return true;
- }
- finally
- {
- PatchHelper.StopIntercept(key);
- }
- }
-#endif
-
- /// <summary>The method to call instead of <see cref="IClickableMenu.drawToolTip"/>.</summary>
- /// <param name="hoveredItem">The item for which to draw a tooltip.</param>
- /// <returns>Returns whether to execute the original method.</returns>
- private static bool Before_IClickableMenu_DrawTooltip(Item hoveredItem)
- {
- // invalid edible item cause crash when drawing tooltips
- if (hoveredItem is SObject obj && obj.Edibility != -300 && !Game1.objectInformation.ContainsKey(obj.ParentSheetIndex))
- return false;
-
- return true;
- }
- }
-}
diff --git a/src/SMAPI/Patches/ScheduleErrorPatch.cs b/src/SMAPI/Patches/ScheduleErrorPatch.cs
deleted file mode 100644
index 1d58a292..00000000
--- a/src/SMAPI/Patches/ScheduleErrorPatch.cs
+++ /dev/null
@@ -1,115 +0,0 @@
-using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
-using StardewModdingAPI.Framework.Patching;
-using StardewValley;
-#if HARMONY_2
-using System;
-using HarmonyLib;
-using StardewModdingAPI.Framework;
-#else
-using System.Reflection;
-using Harmony;
-#endif
-
-namespace StardewModdingAPI.Patches
-{
- /// <summary>A Harmony patch for <see cref="NPC.parseMasterSchedule"/> which intercepts crashes due to invalid schedule data.</summary>
- /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks>
- [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")]
- [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")]
- internal class ScheduleErrorPatch : IHarmonyPatch
- {
- /*********
- ** Fields
- *********/
- /// <summary>Writes messages to the console and log file on behalf of the game.</summary>
- private static IMonitor MonitorForGame;
-
-
- /*********
- ** Accessors
- *********/
- /// <inheritdoc />
- public string Name => nameof(ScheduleErrorPatch);
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Construct an instance.</summary>
- /// <param name="monitorForGame">Writes messages to the console and log file on behalf of the game.</param>
- public ScheduleErrorPatch(IMonitor monitorForGame)
- {
- ScheduleErrorPatch.MonitorForGame = monitorForGame;
- }
-
- /// <inheritdoc />
-#if HARMONY_2
- public void Apply(Harmony harmony)
-#else
- public void Apply(HarmonyInstance harmony)
-#endif
- {
- harmony.Patch(
- original: AccessTools.Method(typeof(NPC), nameof(NPC.parseMasterSchedule)),
-#if HARMONY_2
- finalizer: new HarmonyMethod(this.GetType(), nameof(ScheduleErrorPatch.Finalize_NPC_parseMasterSchedule))
-#else
- prefix: new HarmonyMethod(this.GetType(), nameof(ScheduleErrorPatch.Before_NPC_parseMasterSchedule))
-#endif
- );
- }
-
-
- /*********
- ** Private methods
- *********/
-#if HARMONY_2
- /// <summary>The method to call instead of <see cref="NPC.parseMasterSchedule"/>.</summary>
- /// <param name="rawData">The raw schedule data to parse.</param>
- /// <param name="__instance">The instance being patched.</param>
- /// <param name="__result">The patched method's return value.</param>
- /// <param name="__exception">The exception thrown by the wrapped method, if any.</param>
- /// <returns>Returns the exception to throw, if any.</returns>
- private static Exception Finalize_NPC_parseMasterSchedule(string rawData, NPC __instance, ref Dictionary<int, SchedulePathDescription> __result, Exception __exception)
- {
- if (__exception != null)
- {
- ScheduleErrorPatch.MonitorForGame.Log($"Failed parsing schedule for NPC {__instance.Name}:\n{rawData}\n{__exception.GetLogSummary()}", LogLevel.Error);
- __result = new Dictionary<int, SchedulePathDescription>();
- }
-
- return null;
- }
-#else
- /// <summary>The method to call instead of <see cref="NPC.parseMasterSchedule"/>.</summary>
- /// <param name="rawData">The raw schedule data to parse.</param>
- /// <param name="__instance">The instance being patched.</param>
- /// <param name="__result">The patched method's return value.</param>
- /// <param name="__originalMethod">The method being wrapped.</param>
- /// <returns>Returns whether to execute the original method.</returns>
- private static bool Before_NPC_parseMasterSchedule(string rawData, NPC __instance, ref Dictionary<int, SchedulePathDescription> __result, MethodInfo __originalMethod)
- {
- const string key = nameof(Before_NPC_parseMasterSchedule);
- if (!PatchHelper.StartIntercept(key))
- return true;
-
- try
- {
- __result = (Dictionary<int, SchedulePathDescription>)__originalMethod.Invoke(__instance, new object[] { rawData });
- return false;
- }
- catch (TargetInvocationException ex)
- {
- ScheduleErrorPatch.MonitorForGame.Log($"Failed parsing schedule for NPC {__instance.Name}:\n{rawData}\n{ex.InnerException ?? ex}", LogLevel.Error);
- __result = new Dictionary<int, SchedulePathDescription>();
- return false;
- }
- finally
- {
- PatchHelper.StopIntercept(key);
- }
- }
-#endif
- }
-}
diff --git a/src/SMAPI/Properties/AssemblyInfo.cs b/src/SMAPI/Properties/AssemblyInfo.cs
index ee8a1674..ae758e9b 100644
--- a/src/SMAPI/Properties/AssemblyInfo.cs
+++ b/src/SMAPI/Properties/AssemblyInfo.cs
@@ -1,4 +1,5 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("SMAPI.Tests")]
+[assembly: InternalsVisibleTo("ErrorHandler")]
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // Moq for unit testing
diff --git a/src/SMAPI/SButtonState.cs b/src/SMAPI/SButtonState.cs
index 2b78da27..5f3e8d3c 100644
--- a/src/SMAPI/SButtonState.cs
+++ b/src/SMAPI/SButtonState.cs
@@ -17,7 +17,7 @@ namespace StardewModdingAPI
}
/// <summary>Extension methods for <see cref="SButtonState"/>.</summary>
- internal static class InputStatusExtensions
+ public static class InputStatusExtensions
{
/// <summary>Whether the button was pressed or held.</summary>
/// <param name="state">The button state.</param>
diff --git a/src/SMAPI/SMAPI.config.json b/src/SMAPI/SMAPI.config.json
index 6ba64fe7..7a710f14 100644
--- a/src/SMAPI/SMAPI.config.json
+++ b/src/SMAPI/SMAPI.config.json
@@ -34,6 +34,12 @@ copy all the settings, or you may cause bugs due to overridden changes in future
"DeveloperMode": true,
/**
+ * Whether SMAPI should rewrite mods for compatibility. This may prevent older mods from
+ * loading, but bypasses a Visual Studio crash when debugging.
+ */
+ "RewriteMods": true,
+
+ /**
* Whether to add a section to the 'mod issues' list for mods which directly use potentially
* sensitive .NET APIs like file or shell access. Note that many mods do this legitimately as
* part of their normal functionality, so these warnings are meaningless without further
@@ -113,6 +119,7 @@ copy all the settings, or you may cause bugs due to overridden changes in future
*/
"SuppressUpdateChecks": [
"SMAPI.ConsoleCommands",
+ "SMAPI.ErrorHandler",
"SMAPI.SaveBackup"
]
}
diff --git a/src/SMAPI/Utilities/Keybind.cs b/src/SMAPI/Utilities/Keybind.cs
new file mode 100644
index 00000000..dd8d2861
--- /dev/null
+++ b/src/SMAPI/Utilities/Keybind.cs
@@ -0,0 +1,139 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using StardewModdingAPI.Framework;
+
+namespace StardewModdingAPI.Utilities
+{
+ /// <summary>A single multi-key binding which can be triggered by the player.</summary>
+ /// <remarks>NOTE: this is part of <see cref="KeybindList"/>, and usually shouldn't be used directly.</remarks>
+ public class Keybind
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>Get the current input state for a button.</summary>
+ [Obsolete("This property should only be used for unit tests.")]
+ internal Func<SButton, SButtonState> GetButtonState { get; set; } = SGame.GetInputState;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The buttons that must be down to activate the keybind.</summary>
+ public SButton[] Buttons { get; }
+
+ /// <summary>Whether any keys are bound.</summary>
+ public bool IsBound { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="buttons">The buttons that must be down to activate the keybind.</param>
+ public Keybind(params SButton[] buttons)
+ {
+ this.Buttons = buttons;
+ this.IsBound = buttons.Any(p => p != SButton.None);
+ }
+
+ /// <summary>Parse a keybind string, if it's valid.</summary>
+ /// <param name="input">The keybind string. See remarks on <see cref="ToString"/> for format details.</param>
+ /// <param name="parsed">The parsed keybind, if valid.</param>
+ /// <param name="errors">The parse errors, if any.</param>
+ public static bool TryParse(string input, out Keybind parsed, out string[] errors)
+ {
+ // empty input
+ if (string.IsNullOrWhiteSpace(input))
+ {
+ parsed = new Keybind(SButton.None);
+ errors = new string[0];
+ return true;
+ }
+
+ // parse buttons
+ string[] rawButtons = input.Split('+');
+ SButton[] buttons = new SButton[rawButtons.Length];
+ List<string> rawErrors = new List<string>();
+ for (int i = 0; i < buttons.Length; i++)
+ {
+ string rawButton = rawButtons[i].Trim();
+ if (string.IsNullOrWhiteSpace(rawButton))
+ rawErrors.Add("Invalid empty button value");
+ else if (!Enum.TryParse(rawButton, ignoreCase: true, out SButton button))
+ {
+ string error = $"Invalid button value '{rawButton}'";
+
+ switch (rawButton.ToLower())
+ {
+ case "shift":
+ error += $" (did you mean {SButton.LeftShift}?)";
+ break;
+
+ case "ctrl":
+ case "control":
+ error += $" (did you mean {SButton.LeftControl}?)";
+ break;
+
+ case "alt":
+ error += $" (did you mean {SButton.LeftAlt}?)";
+ break;
+ }
+
+ rawErrors.Add(error);
+ }
+ else
+ buttons[i] = button;
+ }
+
+ // build result
+ if (rawErrors.Any())
+ {
+ parsed = null;
+ errors = rawErrors.ToArray();
+ return false;
+ }
+ else
+ {
+ parsed = new Keybind(buttons);
+ errors = new string[0];
+ return true;
+ }
+ }
+
+ /// <summary>Get the keybind state relative to the previous tick.</summary>
+ public SButtonState GetState()
+ {
+ SButtonState[] states = this.Buttons.Select(this.GetButtonState).Distinct().ToArray();
+
+ // single state
+ if (states.Length == 1)
+ return states[0];
+
+ // if any key has no state, the whole set wasn't enabled last tick
+ if (states.Contains(SButtonState.None))
+ return SButtonState.None;
+
+ // mix of held + pressed => pressed
+ if (states.All(p => p == SButtonState.Pressed || p == SButtonState.Held))
+ return SButtonState.Pressed;
+
+ // mix of held + released => released
+ if (states.All(p => p == SButtonState.Held || p == SButtonState.Released))
+ return SButtonState.Released;
+
+ // not down last tick or now
+ return SButtonState.None;
+ }
+
+ /// <summary>Get a string representation of the keybind.</summary>
+ /// <remarks>A keybind is serialized to a string like <c>LeftControl + S</c>, where each key is separated with <c>+</c>. The key order is commutative, so <c>LeftControl + S</c> and <c>S + LeftControl</c> are identical.</remarks>
+ public override string ToString()
+ {
+ return this.Buttons.Length > 0
+ ? string.Join(" + ", this.Buttons)
+ : SButton.None.ToString();
+ }
+ }
+}
diff --git a/src/SMAPI/Utilities/KeybindList.cs b/src/SMAPI/Utilities/KeybindList.cs
new file mode 100644
index 00000000..1845285a
--- /dev/null
+++ b/src/SMAPI/Utilities/KeybindList.cs
@@ -0,0 +1,161 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using StardewModdingAPI.Toolkit.Serialization;
+
+namespace StardewModdingAPI.Utilities
+{
+ /// <summary>A set of multi-key bindings which can be triggered by the player.</summary>
+ public class KeybindList
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The individual keybinds.</summary>
+ public Keybind[] Keybinds { get; }
+
+ /// <summary>Whether any keys are bound.</summary>
+ public bool IsBound { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="keybinds">The underlying keybinds.</param>
+ /// <remarks>See <see cref="Parse"/> or <see cref="TryParse"/> to parse it from a string representation. You can also use this type directly in your config or JSON data models, and it'll be parsed by SMAPI.</remarks>
+ public KeybindList(params Keybind[] keybinds)
+ {
+ this.Keybinds = keybinds.Where(p => p.IsBound).ToArray();
+ this.IsBound = this.Keybinds.Any();
+ }
+
+ /// <summary>Parse a keybind list from a string, and throw an exception if it's not valid.</summary>
+ /// <param name="input">The keybind string. See remarks on <see cref="ToString"/> for format details.</param>
+ /// <exception cref="FormatException">The <paramref name="input"/> format is invalid.</exception>
+ public static KeybindList Parse(string input)
+ {
+ return KeybindList.TryParse(input, out KeybindList parsed, out string[] errors)
+ ? parsed
+ : throw new SParseException($"Can't parse {nameof(Keybind)} from invalid value '{input}'.\n{string.Join("\n", errors)}");
+ }
+
+ /// <summary>Try to parse a keybind list from a string.</summary>
+ /// <param name="input">The keybind string. See remarks on <see cref="ToString"/> for format details.</param>
+ /// <param name="parsed">The parsed keybind list, if valid.</param>
+ /// <param name="errors">The errors that occurred while parsing the input, if any.</param>
+ public static bool TryParse(string input, out KeybindList parsed, out string[] errors)
+ {
+ // empty input
+ if (string.IsNullOrWhiteSpace(input))
+ {
+ parsed = new KeybindList();
+ errors = new string[0];
+ return true;
+ }
+
+ // parse buttons
+ var rawErrors = new List<string>();
+ var keybinds = new List<Keybind>();
+ foreach (string rawSet in input.Split(','))
+ {
+ if (string.IsNullOrWhiteSpace(rawSet))
+ continue;
+
+ if (!Keybind.TryParse(rawSet, out Keybind keybind, out string[] curErrors))
+ rawErrors.AddRange(curErrors);
+ else
+ keybinds.Add(keybind);
+ }
+
+ // build result
+ if (rawErrors.Any())
+ {
+ parsed = null;
+ errors = rawErrors.Distinct().ToArray();
+ return false;
+ }
+ else
+ {
+ parsed = new KeybindList(keybinds.ToArray());
+ errors = new string[0];
+ return true;
+ }
+ }
+
+ /// <summary>Get a keybind list for a single keybind.</summary>
+ /// <param name="buttons">The buttons that must be down to activate the keybind.</param>
+ public static KeybindList ForSingle(params SButton[] buttons)
+ {
+ return new KeybindList(
+ new Keybind(buttons)
+ );
+ }
+
+ /// <summary>Get the overall keybind list state relative to the previous tick.</summary>
+ /// <remarks>States are transitive across keybind. For example, if one keybind is 'released' and another is 'pressed', the state of the keybind list is 'held'.</remarks>
+ public SButtonState GetState()
+ {
+ bool wasPressed = false;
+ bool isPressed = false;
+
+ foreach (Keybind keybind in this.Keybinds)
+ {
+ switch (keybind.GetState())
+ {
+ case SButtonState.Pressed:
+ isPressed = true;
+ break;
+
+ case SButtonState.Held:
+ wasPressed = true;
+ isPressed = true;
+ break;
+
+ case SButtonState.Released:
+ wasPressed = true;
+ break;
+ }
+ }
+
+ if (wasPressed == isPressed)
+ {
+ return wasPressed
+ ? SButtonState.Held
+ : SButtonState.None;
+ }
+
+ return wasPressed
+ ? SButtonState.Released
+ : SButtonState.Pressed;
+ }
+
+ /// <summary>Get whether any of the button sets are pressed.</summary>
+ public bool IsDown()
+ {
+ SButtonState state = this.GetState();
+ return state == SButtonState.Pressed || state == SButtonState.Held;
+ }
+
+ /// <summary>Get whether the input binding was just pressed this tick.</summary>
+ public bool JustPressed()
+ {
+ return this.GetState() == SButtonState.Pressed;
+ }
+
+ /// <summary>Get the keybind which is currently down, if any. If there are multiple keybinds down, the first one is returned.</summary>
+ public Keybind GetKeybindCurrentlyDown()
+ {
+ return this.Keybinds.FirstOrDefault(p => p.GetState().IsDown());
+ }
+
+ /// <summary>Get a string representation of the input binding.</summary>
+ /// <remarks>A keybind list is serialized to a string like <c>LeftControl + S, LeftAlt + S</c>, where each multi-key binding is separated with <c>,</c> and the keys within each keybind are separated with <c>+</c>. The key order is commutative, so <c>LeftControl + S</c> and <c>S + LeftControl</c> are identical.</remarks>
+ public override string ToString()
+ {
+ return this.Keybinds.Length > 0
+ ? string.Join(", ", this.Keybinds.Select(p => p.ToString()))
+ : SButton.None.ToString();
+ }
+ }
+}
diff --git a/src/SMAPI/Utilities/PerScreen.cs b/src/SMAPI/Utilities/PerScreen.cs
index 89d08e87..20b8fbce 100644
--- a/src/SMAPI/Utilities/PerScreen.cs
+++ b/src/SMAPI/Utilities/PerScreen.cs
@@ -11,10 +11,10 @@ namespace StardewModdingAPI.Utilities
/*********
** Fields
*********/
- /// <summary>Create the initial value for a player.</summary>
+ /// <summary>Create the initial value for a screen.</summary>
private readonly Func<T> CreateNewState;
- /// <summary>The tracked values for each player.</summary>
+ /// <summary>The tracked values for each screen.</summary>
private readonly IDictionary<int, T> States = new Dictionary<int, T>();
/// <summary>The last <see cref="Context.LastRemovedScreenId"/> value for which this instance was updated.</summary>
@@ -24,8 +24,8 @@ namespace StardewModdingAPI.Utilities
/*********
** Accessors
*********/
- /// <summary>The value for the current player.</summary>
- /// <remarks>The value is initialized the first time it's requested for that player, unless it's set manually first.</remarks>
+ /// <summary>The value for the current screen.</summary>
+ /// <remarks>The value is initialized the first time it's requested for that screen, unless it's set manually first.</remarks>
public T Value
{
get => this.GetValueForScreen(Context.ScreenId);
@@ -41,47 +41,66 @@ namespace StardewModdingAPI.Utilities
: this(null) { }
/// <summary>Construct an instance.</summary>
- /// <param name="createNewState">Create the initial state for a player screen.</param>
+ /// <param name="createNewState">Create the initial state for a screen.</param>
public PerScreen(Func<T> createNewState)
{
this.CreateNewState = createNewState ?? (() => default);
}
+ /// <summary>Get all active values by screen ID. This doesn't initialize the value for a screen ID if it's not created yet.</summary>
+ public IEnumerable<KeyValuePair<int, T>> GetActiveValues()
+ {
+ this.RemoveDeadScreens();
+ return this.States.ToArray();
+ }
+
/// <summary>Get the value for a given screen ID, creating it if needed.</summary>
/// <param name="screenId">The screen ID to check.</param>
- internal T GetValueForScreen(int screenId)
+ public T GetValueForScreen(int screenId)
{
- this.RemoveDeadPlayers();
+ this.RemoveDeadScreens();
return this.States.TryGetValue(screenId, out T state)
? state
: this.States[screenId] = this.CreateNewState();
}
- /// <summary>Set the value for a given screen ID, creating it if needed.</summary>
+ /// <summary>Set the value for a given screen ID.</summary>
/// <param name="screenId">The screen ID whose value set.</param>
/// <param name="value">The value to set.</param>
- internal void SetValueForScreen(int screenId, T value)
+ public void SetValueForScreen(int screenId, T value)
{
- this.RemoveDeadPlayers();
+ this.RemoveDeadScreens();
this.States[screenId] = value;
}
+ /// <summary>Remove all active values.</summary>
+ public void ResetAllScreens()
+ {
+ this.RemoveScreens(p => true);
+ }
+
/*********
** Private methods
*********/
- /// <summary>Remove players who are no longer have a split-screen index.</summary>
- /// <returns>Returns whether any players were removed.</returns>
- private void RemoveDeadPlayers()
+ /// <summary>Remove screens which are no longer active.</summary>
+ private void RemoveDeadScreens()
{
if (this.LastRemovedScreenId == Context.LastRemovedScreenId)
return;
-
this.LastRemovedScreenId = Context.LastRemovedScreenId;
- foreach (int id in this.States.Keys.ToArray())
+
+ this.RemoveScreens(id => !Context.HasScreenId(id));
+ }
+
+ /// <summary>Remove screens matching a condition.</summary>
+ /// <param name="shouldRemove">Returns whether a screen ID should be removed.</param>
+ private void RemoveScreens(Func<int, bool> shouldRemove)
+ {
+ foreach (var pair in this.States.ToArray())
{
- if (!Context.HasScreenId(id))
- this.States.Remove(id);
+ if (shouldRemove(pair.Key))
+ this.States.Remove(pair.Key);
}
}
}
diff --git a/src/SMAPI/i18n/de.json b/src/SMAPI/i18n/de.json
index a8cbd83b..595c3eff 100644
--- a/src/SMAPI/i18n/de.json
+++ b/src/SMAPI/i18n/de.json
@@ -1,10 +1,6 @@
{
- // error messages
- "warn.invalid-content-removed": "Ungültiger Inhalt wurde entfernt, um einen Absturz zu verhindern (siehe SMAPI Konsole für weitere Informationen).",
-
// short date format for SDate
// tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2)
"generic.date": "{{season}} {{day}}",
"generic.date-with-year": "{{season}} {{day}} im Jahr {{year}}"
-
}
diff --git a/src/SMAPI/i18n/default.json b/src/SMAPI/i18n/default.json
index 7a3d3ed5..7e1f9c4d 100644
--- a/src/SMAPI/i18n/default.json
+++ b/src/SMAPI/i18n/default.json
@@ -1,7 +1,4 @@
{
- // error messages
- "warn.invalid-content-removed": "Invalid content was removed to prevent a crash (see the SMAPI console for info).",
-
// short date format for SDate
// tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2)
"generic.date": "{{season}} {{day}}",
diff --git a/src/SMAPI/i18n/es.json b/src/SMAPI/i18n/es.json
index c9843991..76228d7d 100644
--- a/src/SMAPI/i18n/es.json
+++ b/src/SMAPI/i18n/es.json
@@ -1,7 +1,4 @@
{
- // error messages
- "warn.invalid-content-removed": "Se ha quitado contenido inválido para evitar un cierre forzoso (revisa la consola de SMAPI para más información).",
-
// short date format for SDate
// tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2)
"generic.date": "{{seasonLowercase}} {{day}}",
diff --git a/src/SMAPI/i18n/fr.json b/src/SMAPI/i18n/fr.json
index 5969aa20..e32ee712 100644
--- a/src/SMAPI/i18n/fr.json
+++ b/src/SMAPI/i18n/fr.json
@@ -1,7 +1,4 @@
{
- // error messages
- "warn.invalid-content-removed": "Le contenu non valide a été supprimé afin d'éviter un plantage (voir la console de SMAPI pour plus d'informations).",
-
// short date format for SDate
// tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2)
"generic.date": "{{day}} {{seasonLowercase}}",
diff --git a/src/SMAPI/i18n/hu.json b/src/SMAPI/i18n/hu.json
index 785012f4..2e3b7264 100644
--- a/src/SMAPI/i18n/hu.json
+++ b/src/SMAPI/i18n/hu.json
@@ -1,7 +1,4 @@
{
- // error messages
- "warn.invalid-content-removed": "Érvénytelen elemek kerültek eltávolításra, hogy a játék ne omoljon össze (további információk a SMAPI konzolon).",
-
// short date format for SDate
// tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2)
"generic.date": "{{season}} {{day}}",
diff --git a/src/SMAPI/i18n/it.json b/src/SMAPI/i18n/it.json
index 3b3351c3..7ada11f0 100644
--- a/src/SMAPI/i18n/it.json
+++ b/src/SMAPI/i18n/it.json
@@ -1,7 +1,4 @@
{
- // error messages
- "warn.invalid-content-removed": "Contenuto non valido rimosso per prevenire un crash (Guarda la console di SMAPI per maggiori informazioni).",
-
// short date format for SDate
// tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2)
"generic.date": "{{day}} {{season}}",
diff --git a/src/SMAPI/i18n/ja.json b/src/SMAPI/i18n/ja.json
index 1f814bfa..c95ac1b1 100644
--- a/src/SMAPI/i18n/ja.json
+++ b/src/SMAPI/i18n/ja.json
@@ -1,7 +1,4 @@
{
- // error messages
- "warn.invalid-content-removed": "クラッシュを防ぐために無効なコンテンツを取り除きました (詳細はSMAPIコンソールを参照)",
-
// short date format for SDate
// tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2)
"generic.date": "{{season}} {{day}}日",
diff --git a/src/SMAPI/i18n/ko.json b/src/SMAPI/i18n/ko.json
index d5bbffa4..8d267e5e 100644
--- a/src/SMAPI/i18n/ko.json
+++ b/src/SMAPI/i18n/ko.json
@@ -1,7 +1,4 @@
{
- // error messages
- "warn.invalid-content-removed": "충돌을 방지하기 위해 잘못된 컨텐츠가 제거되었습니다 (자세한 내용은 SMAPI 콘솔 참조).",
-
// short date format for SDate
// tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2)
"generic.date": "{{season}} {{day}}",
diff --git a/src/SMAPI/i18n/pt.json b/src/SMAPI/i18n/pt.json
index e8460922..7a08b08f 100644
--- a/src/SMAPI/i18n/pt.json
+++ b/src/SMAPI/i18n/pt.json
@@ -1,7 +1,4 @@
{
- // error messages
- "warn.invalid-content-removed": "Conteúdo inválido foi removido para prevenir uma falha (veja o console do SMAPI para mais informações).",
-
// short date format for SDate
// tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2)
"generic.date": "{{season}} {{day}}",
diff --git a/src/SMAPI/i18n/ru.json b/src/SMAPI/i18n/ru.json
index 002fdbf8..b8ff55c4 100644
--- a/src/SMAPI/i18n/ru.json
+++ b/src/SMAPI/i18n/ru.json
@@ -1,7 +1,4 @@
{
- // error messages
- "warn.invalid-content-removed": "Недопустимое содержимое было удалено, чтобы предотвратить сбой (см. информацию в консоли SMAPI)",
-
// short date format for SDate
// tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2)
"generic.date": "{{season}}, {{day}}-е число",
diff --git a/src/SMAPI/i18n/tr.json b/src/SMAPI/i18n/tr.json
index 2a6e83a1..e97a48ba 100644
--- a/src/SMAPI/i18n/tr.json
+++ b/src/SMAPI/i18n/tr.json
@@ -1,7 +1,4 @@
{
- // error messages
- "warn.invalid-content-removed": "Yanlış paketlenmiş bir içerik, oyunun çökmemesi için yüklenmedi (SMAPI konsol penceresinde detaylı bilgi mevcut).",
-
// short date format for SDate
// tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2)
"generic.date": "{{day}} {{season}}",
diff --git a/src/SMAPI/i18n/zh.json b/src/SMAPI/i18n/zh.json
index cdbe3b74..36d459de 100644
--- a/src/SMAPI/i18n/zh.json
+++ b/src/SMAPI/i18n/zh.json
@@ -1,7 +1,4 @@
{
- // error messages
- "warn.invalid-content-removed": "非法内容已移除以防游戏闪退(查看SMAPI控制台获得更多信息)",
-
// short date format for SDate
// tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2)
"generic.date": "{{season}}{{day}}日",