diff options
Diffstat (limited to 'src/SMAPI')
137 files changed, 5369 insertions, 2169 deletions
diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index bd512fb1..6969bd7e 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -29,10 +29,10 @@ namespace StardewModdingAPI ** Public ****/ /// <summary>SMAPI's current semantic version.</summary> - public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("2.7.0"); + public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("2.8.1"); /// <summary>The minimum supported version of Stardew Valley.</summary> - public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.3.28"); + public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.3.32"); /// <summary>The maximum supported version of Stardew Valley.</summary> public static ISemanticVersion MaximumGameVersion { get; } = null; @@ -64,11 +64,14 @@ namespace StardewModdingAPI /// <summary>The URL of the SMAPI home page.</summary> internal const string HomePageUrl = "https://smapi.io"; + /// <summary>The absolute path to the folder containing SMAPI's internal files.</summary> + internal static readonly string InternalFilesPath = Program.DllSearchPath; + /// <summary>The file path for the SMAPI configuration file.</summary> - internal static string ApiConfigPath => Path.Combine(Constants.ExecutionPath, $"{typeof(Program).Assembly.GetName().Name}.config.json"); + internal static string ApiConfigPath => Path.Combine(Constants.InternalFilesPath, "StardewModdingAPI.config.json"); /// <summary>The file path for the SMAPI metadata file.</summary> - internal static string ApiMetadataPath => Path.Combine(Constants.ExecutionPath, $"{typeof(Program).Assembly.GetName().Name}.metadata.json"); + internal static string ApiMetadataPath => Path.Combine(Constants.InternalFilesPath, "StardewModdingAPI.metadata.json"); /// <summary>The filename prefix used for all SMAPI logs.</summary> internal static string LogNamePrefix { get; } = "SMAPI-"; @@ -79,14 +82,14 @@ namespace StardewModdingAPI /// <summary>The filename extension for SMAPI log files.</summary> internal static string LogExtension { get; } = "txt"; - /// <summary>A copy of the log leading up to the previous fatal crash, if any.</summary> + /// <summary>The file path for the log containing the previous fatal crash, if any.</summary> internal static string FatalCrashLog => Path.Combine(Constants.LogDir, "SMAPI-crash.txt"); /// <summary>The file path which stores a fatal crash message for the next run.</summary> - internal static string FatalCrashMarker => Path.Combine(Constants.ExecutionPath, "StardewModdingAPI.crash.marker"); + internal static string FatalCrashMarker => Path.Combine(Constants.InternalFilesPath, "StardewModdingAPI.crash.marker"); /// <summary>The file path which stores the detected update version for the next run.</summary> - internal static string UpdateMarker => Path.Combine(Constants.ExecutionPath, "StardewModdingAPI.update.marker"); + internal static string UpdateMarker => Path.Combine(Constants.InternalFilesPath, "StardewModdingAPI.update.marker"); /// <summary>The full path to the folder containing mods.</summary> internal static string DefaultModsPath { get; } = Path.Combine(Constants.ExecutionPath, "Mods"); @@ -104,6 +107,26 @@ namespace StardewModdingAPI /********* ** Internal methods *********/ + /// <summary>Get the SMAPI version to recommend for an older game version, if any.</summary> + /// <param name="version">The game version to search.</param> + /// <returns>Returns the compatible SMAPI version, or <c>null</c> if none was found.</returns> + internal static ISemanticVersion GetCompatibleApiVersion(ISemanticVersion version) + { + switch (version.ToString()) + { + case "1.3.28": + return new SemanticVersion(2, 7, 0); + + case "1.2.30": + case "1.2.31": + case "1.2.32": + case "1.2.33": + return new SemanticVersion(2, 5, 5); + } + + return null; + } + /// <summary>Get metadata for mapping assemblies to the current platform.</summary> /// <param name="targetPlatform">The target game platform.</param> internal static PlatformAssemblyMap GetAssemblyMap(Platform targetPlatform) diff --git a/src/SMAPI/Context.cs b/src/SMAPI/Context.cs index 3905699e..c7aed81d 100644 --- a/src/SMAPI/Context.cs +++ b/src/SMAPI/Context.cs @@ -17,7 +17,7 @@ namespace StardewModdingAPI public static bool IsWorldReady { get; internal set; } /// <summary>Whether <see cref="IsWorldReady"/> is true and the player is free to act in the world (no menu is displayed, no cutscene is in progress, etc).</summary> - public static bool IsPlayerFree => Context.IsWorldReady && Game1.activeClickableMenu == null && !Game1.dialogueUp && (!Game1.eventUp || Game1.isFestival()); + public static bool IsPlayerFree => Context.IsWorldReady && Game1.currentLocation != null && Game1.activeClickableMenu == null && !Game1.dialogueUp && (!Game1.eventUp || Game1.isFestival()); /// <summary>Whether <see cref="IsPlayerFree"/> is true and the player is free to move (e.g. not using a tool).</summary> public static bool CanPlayerMove => Context.IsPlayerFree && Game1.player.CanMove; diff --git a/src/SMAPI/Enums/SkillType.cs b/src/SMAPI/Enums/SkillType.cs new file mode 100644 index 00000000..10518ec9 --- /dev/null +++ b/src/SMAPI/Enums/SkillType.cs @@ -0,0 +1,26 @@ +using StardewValley; + +namespace StardewModdingAPI.Enums +{ + /// <summary>The player skill types.</summary> + public enum SkillType + { + /// <summary>The combat skill.</summary> + Combat = Farmer.combatSkill, + + /// <summary>The farming skill.</summary> + Farming = Farmer.farmingSkill, + + /// <summary>The fishing skill.</summary> + Fishing = Farmer.fishingSkill, + + /// <summary>The foraging skill.</summary> + Foraging = Farmer.foragingSkill, + + /// <summary>The mining skill.</summary> + Mining = Farmer.miningSkill, + + /// <summary>The luck skill.</summary> + Luck = Farmer.luckSkill + } +} diff --git a/src/SMAPI/Events/WorldBuildingListChangedEventArgs.cs b/src/SMAPI/Events/BuildingListChangedEventArgs.cs index e73b9396..9bc691fc 100644 --- a/src/SMAPI/Events/WorldBuildingListChangedEventArgs.cs +++ b/src/SMAPI/Events/BuildingListChangedEventArgs.cs @@ -7,7 +7,7 @@ using StardewValley.Buildings; namespace StardewModdingAPI.Events { /// <summary>Event arguments for a <see cref="IWorldEvents.BuildingListChanged"/> event.</summary> - public class WorldBuildingListChangedEventArgs : EventArgs + public class BuildingListChangedEventArgs : EventArgs { /********* ** Accessors @@ -29,7 +29,7 @@ namespace StardewModdingAPI.Events /// <param name="location">The location which changed.</param> /// <param name="added">The buildings added to the location.</param> /// <param name="removed">The buildings removed from the location.</param> - public WorldBuildingListChangedEventArgs(GameLocation location, IEnumerable<Building> added, IEnumerable<Building> removed) + public BuildingListChangedEventArgs(GameLocation location, IEnumerable<Building> added, IEnumerable<Building> removed) { this.Location = location; this.Added = added.ToArray(); diff --git a/src/SMAPI/Events/InputButtonPressedEventArgs.cs b/src/SMAPI/Events/ButtonPressedEventArgs.cs index 8c6844dd..9e6c187f 100644 --- a/src/SMAPI/Events/InputButtonPressedEventArgs.cs +++ b/src/SMAPI/Events/ButtonPressedEventArgs.cs @@ -4,7 +4,7 @@ using StardewModdingAPI.Framework.Input; namespace StardewModdingAPI.Events { /// <summary>Event arguments when a button is pressed.</summary> - public class InputButtonPressedEventArgs : EventArgs + public class ButtonPressedEventArgs : EventArgs { /********* ** Properties @@ -30,7 +30,7 @@ namespace StardewModdingAPI.Events /// <param name="button">The button on the controller, keyboard, or mouse.</param> /// <param name="cursor">The cursor position.</param> /// <param name="inputState">The game's current input state.</param> - internal InputButtonPressedEventArgs(SButton button, ICursorPosition cursor, SInputState inputState) + internal ButtonPressedEventArgs(SButton button, ICursorPosition cursor, SInputState inputState) { this.Button = button; this.Cursor = cursor; diff --git a/src/SMAPI/Events/InputButtonReleasedEventArgs.cs b/src/SMAPI/Events/ButtonReleasedEventArgs.cs index 4b0bc326..2a289bc7 100644 --- a/src/SMAPI/Events/InputButtonReleasedEventArgs.cs +++ b/src/SMAPI/Events/ButtonReleasedEventArgs.cs @@ -4,7 +4,7 @@ using StardewModdingAPI.Framework.Input; namespace StardewModdingAPI.Events { /// <summary>Event arguments when a button is released.</summary> - public class InputButtonReleasedEventArgs : EventArgs + public class ButtonReleasedEventArgs : EventArgs { /********* ** Properties @@ -30,7 +30,7 @@ namespace StardewModdingAPI.Events /// <param name="button">The button on the controller, keyboard, or mouse.</param> /// <param name="cursor">The cursor position.</param> /// <param name="inputState">The game's current input state.</param> - internal InputButtonReleasedEventArgs(SButton button, ICursorPosition cursor, SInputState inputState) + internal ButtonReleasedEventArgs(SButton button, ICursorPosition cursor, SInputState inputState) { this.Button = button; this.Cursor = cursor; diff --git a/src/SMAPI/Events/ContentEvents.cs b/src/SMAPI/Events/ContentEvents.cs index 63645258..3ee0560b 100644 --- a/src/SMAPI/Events/ContentEvents.cs +++ b/src/SMAPI/Events/ContentEvents.cs @@ -19,8 +19,8 @@ namespace StardewModdingAPI.Events /// <summary>Raised after the content language changes.</summary> public static event EventHandler<EventArgsValueChanged<string>> AfterLocaleChanged { - add => ContentEvents.EventManager.Content_LocaleChanged.Add(value); - remove => ContentEvents.EventManager.Content_LocaleChanged.Remove(value); + add => ContentEvents.EventManager.Legacy_LocaleChanged.Add(value); + remove => ContentEvents.EventManager.Legacy_LocaleChanged.Remove(value); } diff --git a/src/SMAPI/Events/ControlEvents.cs b/src/SMAPI/Events/ControlEvents.cs index a3994d1d..56a4fa3f 100644 --- a/src/SMAPI/Events/ControlEvents.cs +++ b/src/SMAPI/Events/ControlEvents.cs @@ -20,57 +20,57 @@ namespace StardewModdingAPI.Events /// <summary>Raised when the <see cref="KeyboardState"/> changes. That happens when the player presses or releases a key.</summary> public static event EventHandler<EventArgsKeyboardStateChanged> KeyboardChanged { - add => ControlEvents.EventManager.Legacy_Control_KeyboardChanged.Add(value); - remove => ControlEvents.EventManager.Legacy_Control_KeyboardChanged.Remove(value); + add => ControlEvents.EventManager.Legacy_KeyboardChanged.Add(value); + remove => ControlEvents.EventManager.Legacy_KeyboardChanged.Remove(value); } /// <summary>Raised after the player presses a keyboard key.</summary> public static event EventHandler<EventArgsKeyPressed> KeyPressed { - add => ControlEvents.EventManager.Legacy_Control_KeyPressed.Add(value); - remove => ControlEvents.EventManager.Legacy_Control_KeyPressed.Remove(value); + add => ControlEvents.EventManager.Legacy_KeyPressed.Add(value); + remove => ControlEvents.EventManager.Legacy_KeyPressed.Remove(value); } /// <summary>Raised after the player releases a keyboard key.</summary> public static event EventHandler<EventArgsKeyPressed> KeyReleased { - add => ControlEvents.EventManager.Legacy_Control_KeyReleased.Add(value); - remove => ControlEvents.EventManager.Legacy_Control_KeyReleased.Remove(value); + add => ControlEvents.EventManager.Legacy_KeyReleased.Add(value); + remove => ControlEvents.EventManager.Legacy_KeyReleased.Remove(value); } /// <summary>Raised when the <see cref="MouseState"/> changes. That happens when the player moves the mouse, scrolls the mouse wheel, or presses/releases a button.</summary> public static event EventHandler<EventArgsMouseStateChanged> MouseChanged { - add => ControlEvents.EventManager.Legacy_Control_MouseChanged.Add(value); - remove => ControlEvents.EventManager.Legacy_Control_MouseChanged.Remove(value); + add => ControlEvents.EventManager.Legacy_MouseChanged.Add(value); + remove => ControlEvents.EventManager.Legacy_MouseChanged.Remove(value); } /// <summary>The player pressed a controller button. This event isn't raised for trigger buttons.</summary> public static event EventHandler<EventArgsControllerButtonPressed> ControllerButtonPressed { - add => ControlEvents.EventManager.Legacy_Control_ControllerButtonPressed.Add(value); - remove => ControlEvents.EventManager.Legacy_Control_ControllerButtonPressed.Remove(value); + add => ControlEvents.EventManager.Legacy_ControllerButtonPressed.Add(value); + remove => ControlEvents.EventManager.Legacy_ControllerButtonPressed.Remove(value); } /// <summary>The player released a controller button. This event isn't raised for trigger buttons.</summary> public static event EventHandler<EventArgsControllerButtonReleased> ControllerButtonReleased { - add => ControlEvents.EventManager.Legacy_Control_ControllerButtonReleased.Add(value); - remove => ControlEvents.EventManager.Legacy_Control_ControllerButtonReleased.Remove(value); + add => ControlEvents.EventManager.Legacy_ControllerButtonReleased.Add(value); + remove => ControlEvents.EventManager.Legacy_ControllerButtonReleased.Remove(value); } /// <summary>The player pressed a controller trigger button.</summary> public static event EventHandler<EventArgsControllerTriggerPressed> ControllerTriggerPressed { - add => ControlEvents.EventManager.Legacy_Control_ControllerTriggerPressed.Add(value); - remove => ControlEvents.EventManager.Legacy_Control_ControllerTriggerPressed.Remove(value); + add => ControlEvents.EventManager.Legacy_ControllerTriggerPressed.Add(value); + remove => ControlEvents.EventManager.Legacy_ControllerTriggerPressed.Remove(value); } /// <summary>The player released a controller trigger button.</summary> public static event EventHandler<EventArgsControllerTriggerReleased> ControllerTriggerReleased { - add => ControlEvents.EventManager.Legacy_Control_ControllerTriggerReleased.Add(value); - remove => ControlEvents.EventManager.Legacy_Control_ControllerTriggerReleased.Remove(value); + add => ControlEvents.EventManager.Legacy_ControllerTriggerReleased.Add(value); + remove => ControlEvents.EventManager.Legacy_ControllerTriggerReleased.Remove(value); } diff --git a/src/SMAPI/Events/InputCursorMovedEventArgs.cs b/src/SMAPI/Events/CursorMovedEventArgs.cs index 53aac5b3..453743b9 100644 --- a/src/SMAPI/Events/InputCursorMovedEventArgs.cs +++ b/src/SMAPI/Events/CursorMovedEventArgs.cs @@ -3,7 +3,7 @@ using System; namespace StardewModdingAPI.Events { /// <summary>Event arguments when the in-game cursor is moved.</summary> - public class InputCursorMovedEventArgs : EventArgs + public class CursorMovedEventArgs : EventArgs { /********* ** Accessors @@ -21,7 +21,7 @@ namespace StardewModdingAPI.Events /// <summary>Construct an instance.</summary> /// <param name="oldPosition">The previous cursor position.</param> /// <param name="newPosition">The new cursor position.</param> - public InputCursorMovedEventArgs(ICursorPosition oldPosition, ICursorPosition newPosition) + public CursorMovedEventArgs(ICursorPosition oldPosition, ICursorPosition newPosition) { this.OldPosition = oldPosition; this.NewPosition = newPosition; diff --git a/src/SMAPI/Events/GameLoopLaunchedEventArgs.cs b/src/SMAPI/Events/DayEndingEventArgs.cs index 6a42e4f9..5cb433bc 100644 --- a/src/SMAPI/Events/GameLoopLaunchedEventArgs.cs +++ b/src/SMAPI/Events/DayEndingEventArgs.cs @@ -2,6 +2,6 @@ using System; namespace StardewModdingAPI.Events { - /// <summary>Event arguments for an <see cref="IGameLoopEvents.Launched"/> event.</summary> - public class GameLoopLaunchedEventArgs : EventArgs { } + /// <summary>Event arguments for an <see cref="IGameLoopEvents.DayEnding"/> event.</summary> + public class DayEndingEventArgs : EventArgs { } } diff --git a/src/SMAPI/Events/DayStartedEventArgs.cs b/src/SMAPI/Events/DayStartedEventArgs.cs new file mode 100644 index 00000000..45823628 --- /dev/null +++ b/src/SMAPI/Events/DayStartedEventArgs.cs @@ -0,0 +1,7 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IGameLoopEvents.DayStarted"/> event.</summary> + public class DayStartedEventArgs : EventArgs { } +} diff --git a/src/SMAPI/Events/WorldDebrisListChangedEventArgs.cs b/src/SMAPI/Events/DebrisListChangedEventArgs.cs index aad9c24d..1337bd3b 100644 --- a/src/SMAPI/Events/WorldDebrisListChangedEventArgs.cs +++ b/src/SMAPI/Events/DebrisListChangedEventArgs.cs @@ -6,7 +6,7 @@ using StardewValley; namespace StardewModdingAPI.Events { /// <summary>Event arguments for a <see cref="IWorldEvents.DebrisListChanged"/> event.</summary> - public class WorldDebrisListChangedEventArgs : EventArgs + public class DebrisListChangedEventArgs : EventArgs { /********* ** Accessors @@ -28,7 +28,7 @@ namespace StardewModdingAPI.Events /// <param name="location">The location which changed.</param> /// <param name="added">The debris added to the location.</param> /// <param name="removed">The debris removed from the location.</param> - public WorldDebrisListChangedEventArgs(GameLocation location, IEnumerable<Debris> added, IEnumerable<Debris> removed) + public DebrisListChangedEventArgs(GameLocation location, IEnumerable<Debris> added, IEnumerable<Debris> removed) { this.Location = location; this.Added = added.ToArray(); diff --git a/src/SMAPI/Events/EventArgsInventoryChanged.cs b/src/SMAPI/Events/EventArgsInventoryChanged.cs index 1fdca834..3a2354b6 100644 --- a/src/SMAPI/Events/EventArgsInventoryChanged.cs +++ b/src/SMAPI/Events/EventArgsInventoryChanged.cs @@ -30,7 +30,7 @@ namespace StardewModdingAPI.Events /// <summary>Construct an instance.</summary> /// <param name="inventory">The player's inventory.</param> /// <param name="changedItems">The inventory changes.</param> - public EventArgsInventoryChanged(IList<Item> inventory, List<ItemStackChange> changedItems) + public EventArgsInventoryChanged(IList<Item> inventory, ItemStackChange[] changedItems) { this.Inventory = inventory; this.Added = changedItems.Where(n => n.ChangeType == ChangeType.Added).ToList(); diff --git a/src/SMAPI/Events/EventArgsLevelUp.cs b/src/SMAPI/Events/EventArgsLevelUp.cs index fe6696d4..e9a697e7 100644 --- a/src/SMAPI/Events/EventArgsLevelUp.cs +++ b/src/SMAPI/Events/EventArgsLevelUp.cs @@ -1,4 +1,5 @@ using System; +using StardewModdingAPI.Enums; namespace StardewModdingAPI.Events { @@ -18,22 +19,22 @@ namespace StardewModdingAPI.Events public enum LevelType { /// <summary>The combat skill.</summary> - Combat, + Combat = SkillType.Combat, /// <summary>The farming skill.</summary> - Farming, + Farming = SkillType.Farming, /// <summary>The fishing skill.</summary> - Fishing, + Fishing = SkillType.Fishing, /// <summary>The foraging skill.</summary> - Foraging, + Foraging = SkillType.Foraging, /// <summary>The mining skill.</summary> - Mining, + Mining = SkillType.Mining, /// <summary>The luck skill.</summary> - Luck + Luck = SkillType.Luck } diff --git a/src/SMAPI/Events/GameEvents.cs b/src/SMAPI/Events/GameEvents.cs index 92879280..952b3570 100644 --- a/src/SMAPI/Events/GameEvents.cs +++ b/src/SMAPI/Events/GameEvents.cs @@ -19,57 +19,57 @@ namespace StardewModdingAPI.Events /// <summary>Raised when the game updates its state (≈60 times per second).</summary> public static event EventHandler UpdateTick { - add => GameEvents.EventManager.Game_UpdateTick.Add(value); - remove => GameEvents.EventManager.Game_UpdateTick.Remove(value); + add => GameEvents.EventManager.Legacy_UpdateTick.Add(value); + remove => GameEvents.EventManager.Legacy_UpdateTick.Remove(value); } /// <summary>Raised every other tick (≈30 times per second).</summary> public static event EventHandler SecondUpdateTick { - add => GameEvents.EventManager.Game_SecondUpdateTick.Add(value); - remove => GameEvents.EventManager.Game_SecondUpdateTick.Remove(value); + add => GameEvents.EventManager.Legacy_SecondUpdateTick.Add(value); + remove => GameEvents.EventManager.Legacy_SecondUpdateTick.Remove(value); } /// <summary>Raised every fourth tick (≈15 times per second).</summary> public static event EventHandler FourthUpdateTick { - add => GameEvents.EventManager.Game_FourthUpdateTick.Add(value); - remove => GameEvents.EventManager.Game_FourthUpdateTick.Remove(value); + add => GameEvents.EventManager.Legacy_FourthUpdateTick.Add(value); + remove => GameEvents.EventManager.Legacy_FourthUpdateTick.Remove(value); } /// <summary>Raised every eighth tick (≈8 times per second).</summary> public static event EventHandler EighthUpdateTick { - add => GameEvents.EventManager.Game_EighthUpdateTick.Add(value); - remove => GameEvents.EventManager.Game_EighthUpdateTick.Remove(value); + add => GameEvents.EventManager.Legacy_EighthUpdateTick.Add(value); + remove => GameEvents.EventManager.Legacy_EighthUpdateTick.Remove(value); } /// <summary>Raised every 15th tick (≈4 times per second).</summary> public static event EventHandler QuarterSecondTick { - add => GameEvents.EventManager.Game_QuarterSecondTick.Add(value); - remove => GameEvents.EventManager.Game_QuarterSecondTick.Remove(value); + add => GameEvents.EventManager.Legacy_QuarterSecondTick.Add(value); + remove => GameEvents.EventManager.Legacy_QuarterSecondTick.Remove(value); } /// <summary>Raised every 30th tick (≈twice per second).</summary> public static event EventHandler HalfSecondTick { - add => GameEvents.EventManager.Game_HalfSecondTick.Add(value); - remove => GameEvents.EventManager.Game_HalfSecondTick.Remove(value); + add => GameEvents.EventManager.Legacy_HalfSecondTick.Add(value); + remove => GameEvents.EventManager.Legacy_HalfSecondTick.Remove(value); } /// <summary>Raised every 60th tick (≈once per second).</summary> public static event EventHandler OneSecondTick { - add => GameEvents.EventManager.Game_OneSecondTick.Add(value); - remove => GameEvents.EventManager.Game_OneSecondTick.Remove(value); + add => GameEvents.EventManager.Legacy_OneSecondTick.Add(value); + remove => GameEvents.EventManager.Legacy_OneSecondTick.Remove(value); } /// <summary>Raised once after the game initialises and all <see cref="IMod.Entry"/> methods have been called.</summary> public static event EventHandler FirstUpdateTick { - add => GameEvents.EventManager.Game_FirstUpdateTick.Add(value); - remove => GameEvents.EventManager.Game_FirstUpdateTick.Remove(value); + add => GameEvents.EventManager.Legacy_FirstUpdateTick.Add(value); + remove => GameEvents.EventManager.Legacy_FirstUpdateTick.Remove(value); } diff --git a/src/SMAPI/Events/GameLaunchedEventArgs.cs b/src/SMAPI/Events/GameLaunchedEventArgs.cs new file mode 100644 index 00000000..a4c78754 --- /dev/null +++ b/src/SMAPI/Events/GameLaunchedEventArgs.cs @@ -0,0 +1,7 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IGameLoopEvents.GameLaunched"/> event.</summary> + public class GameLaunchedEventArgs : EventArgs { } +} diff --git a/src/SMAPI/Events/GraphicsEvents.cs b/src/SMAPI/Events/GraphicsEvents.cs index e1ff4ee7..53f04822 100644 --- a/src/SMAPI/Events/GraphicsEvents.cs +++ b/src/SMAPI/Events/GraphicsEvents.cs @@ -19,8 +19,8 @@ namespace StardewModdingAPI.Events /// <summary>Raised after the game window is resized.</summary> public static event EventHandler Resize { - add => GraphicsEvents.EventManager.Graphics_Resize.Add(value); - remove => GraphicsEvents.EventManager.Graphics_Resize.Remove(value); + add => GraphicsEvents.EventManager.Legacy_Resize.Add(value); + remove => GraphicsEvents.EventManager.Legacy_Resize.Remove(value); } /**** @@ -29,15 +29,15 @@ namespace StardewModdingAPI.Events /// <summary>Raised before drawing the world to the screen.</summary> public static event EventHandler OnPreRenderEvent { - add => GraphicsEvents.EventManager.Graphics_OnPreRenderEvent.Add(value); - remove => GraphicsEvents.EventManager.Graphics_OnPreRenderEvent.Remove(value); + add => GraphicsEvents.EventManager.Legacy_OnPreRenderEvent.Add(value); + remove => GraphicsEvents.EventManager.Legacy_OnPreRenderEvent.Remove(value); } /// <summary>Raised after drawing the world to the screen.</summary> public static event EventHandler OnPostRenderEvent { - add => GraphicsEvents.EventManager.Graphics_OnPostRenderEvent.Add(value); - remove => GraphicsEvents.EventManager.Graphics_OnPostRenderEvent.Remove(value); + add => GraphicsEvents.EventManager.Legacy_OnPostRenderEvent.Add(value); + remove => GraphicsEvents.EventManager.Legacy_OnPostRenderEvent.Remove(value); } /**** @@ -46,15 +46,15 @@ namespace StardewModdingAPI.Events /// <summary>Raised before drawing the HUD (item toolbar, clock, etc) to the screen. The HUD is available at this point, but not necessarily visible. (For example, the event is raised even if a menu is open.)</summary> public static event EventHandler OnPreRenderHudEvent { - add => GraphicsEvents.EventManager.Graphics_OnPreRenderHudEvent.Add(value); - remove => GraphicsEvents.EventManager.Graphics_OnPreRenderHudEvent.Remove(value); + add => GraphicsEvents.EventManager.Legacy_OnPreRenderHudEvent.Add(value); + remove => GraphicsEvents.EventManager.Legacy_OnPreRenderHudEvent.Remove(value); } /// <summary>Raised after drawing the HUD (item toolbar, clock, etc) to the screen. The HUD is available at this point, but not necessarily visible. (For example, the event is raised even if a menu is open.)</summary> public static event EventHandler OnPostRenderHudEvent { - add => GraphicsEvents.EventManager.Graphics_OnPostRenderHudEvent.Add(value); - remove => GraphicsEvents.EventManager.Graphics_OnPostRenderHudEvent.Remove(value); + add => GraphicsEvents.EventManager.Legacy_OnPostRenderHudEvent.Add(value); + remove => GraphicsEvents.EventManager.Legacy_OnPostRenderHudEvent.Remove(value); } /**** @@ -63,15 +63,15 @@ namespace StardewModdingAPI.Events /// <summary>Raised before drawing a menu to the screen during a draw loop. This includes the game's internal menus like the title screen.</summary> public static event EventHandler OnPreRenderGuiEvent { - add => GraphicsEvents.EventManager.Graphics_OnPreRenderGuiEvent.Add(value); - remove => GraphicsEvents.EventManager.Graphics_OnPreRenderGuiEvent.Remove(value); + add => GraphicsEvents.EventManager.Legacy_OnPreRenderGuiEvent.Add(value); + remove => GraphicsEvents.EventManager.Legacy_OnPreRenderGuiEvent.Remove(value); } /// <summary>Raised after drawing a menu to the screen during a draw loop. This includes the game's internal menus like the title screen.</summary> public static event EventHandler OnPostRenderGuiEvent { - add => GraphicsEvents.EventManager.Graphics_OnPostRenderGuiEvent.Add(value); - remove => GraphicsEvents.EventManager.Graphics_OnPostRenderGuiEvent.Remove(value); + add => GraphicsEvents.EventManager.Legacy_OnPostRenderGuiEvent.Add(value); + remove => GraphicsEvents.EventManager.Legacy_OnPostRenderGuiEvent.Remove(value); } diff --git a/src/SMAPI/Events/IDisplayEvents.cs b/src/SMAPI/Events/IDisplayEvents.cs new file mode 100644 index 00000000..dbf8d90f --- /dev/null +++ b/src/SMAPI/Events/IDisplayEvents.cs @@ -0,0 +1,39 @@ +using System; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events related to UI and drawing to the screen.</summary> + public interface IDisplayEvents + { + /// <summary>Raised after a game menu is opened, closed, or replaced.</summary> + event EventHandler<MenuChangedEventArgs> MenuChanged; + + /// <summary>Raised before the game draws anything to the screen in a draw tick, as soon as the sprite batch is opened. The sprite batch may be closed and reopened multiple times after this event is called, but it's only raised once per draw tick. This event isn't useful for drawing to the screen, since the game will draw over it.</summary> + event EventHandler<RenderingEventArgs> Rendering; + + /// <summary>Raised after the game draws to the sprite patch in a draw tick, just before the final sprite batch is rendered to the screen. Since the game may open/close the sprite batch multiple times in a draw tick, the sprite batch may not contain everything being drawn and some things may already be rendered to the screen. Content drawn to the sprite batch at this point will be drawn over all vanilla content (including menus, HUD, and cursor).</summary> + event EventHandler<RenderedEventArgs> Rendered; + + /// <summary>Raised before the game world is drawn to the screen. This event isn't useful for drawing to the screen, since the game will draw over it.</summary> + event EventHandler<RenderingWorldEventArgs> RenderingWorld; + + /// <summary>Raised after the game world is drawn to the sprite patch, before it's rendered to the screen. Content drawn to the sprite batch at this point will be drawn over the world, but under any active menu, HUD elements, or cursor.</summary> + event EventHandler<RenderedWorldEventArgs> RenderedWorld; + + /// <summary>When a menu is open (<see cref="Game1.activeClickableMenu"/> isn't null), raised before that menu is drawn to the screen. This includes the game's internal menus like the title screen. Content drawn to the sprite batch at this point will appear under the menu.</summary> + event EventHandler<RenderingActiveMenuEventArgs> RenderingActiveMenu; + + /// <summary>When a menu is open (<see cref="Game1.activeClickableMenu"/> isn't null), raised after that menu is drawn to the sprite batch but before it's rendered to the screen. Content drawn to the sprite batch at this point will appear over the menu and menu cursor.</summary> + event EventHandler<RenderedActiveMenuEventArgs> RenderedActiveMenu; + + /// <summary>Raised before drawing the HUD (item toolbar, clock, etc) to the screen. The vanilla HUD may be hidden at this point (e.g. because a menu is open). Content drawn to the sprite batch at this point will appear under the HUD.</summary> + event EventHandler<RenderingHudEventArgs> RenderingHud; + + /// <summary>Raised after drawing the HUD (item toolbar, clock, etc) to the sprite batch, but before it's rendered to the screen. The vanilla HUD may be hidden at this point (e.g. because a menu is open). Content drawn to the sprite batch at this point will appear over the HUD.</summary> + event EventHandler<RenderedHudEventArgs> RenderedHud; + + /// <summary>Raised after the game window is resized.</summary> + event EventHandler<WindowResizedEventArgs> WindowResized; + } +} diff --git a/src/SMAPI/Events/IGameLoopEvents.cs b/src/SMAPI/Events/IGameLoopEvents.cs index a56b3de3..e1900f79 100644 --- a/src/SMAPI/Events/IGameLoopEvents.cs +++ b/src/SMAPI/Events/IGameLoopEvents.cs @@ -6,12 +6,39 @@ namespace StardewModdingAPI.Events public interface IGameLoopEvents { /// <summary>Raised after the game is launched, right before the first update tick. This happens once per game session (unrelated to loading saves). All mods are loaded and initialised at this point, so this is a good time to set up mod integrations.</summary> - event EventHandler<GameLoopLaunchedEventArgs> Launched; + event EventHandler<GameLaunchedEventArgs> GameLaunched; - /// <summary>Raised before the game performs its overall update tick (≈60 times per second).</summary> - event EventHandler<GameLoopUpdatingEventArgs> Updating; + /// <summary>Raised before the game state is updated (≈60 times per second).</summary> + event EventHandler<UpdateTickingEventArgs> UpdateTicking; - /// <summary>Raised after the game performs its overall update tick (≈60 times per second).</summary> - event EventHandler<GameLoopUpdatedEventArgs> Updated; + /// <summary>Raised after the game state is updated (≈60 times per second).</summary> + event EventHandler<UpdateTickedEventArgs> UpdateTicked; + + /// <summary>Raised before the game creates a new save file.</summary> + event EventHandler<SaveCreatingEventArgs> SaveCreating; + + /// <summary>Raised after the game finishes creating the save file.</summary> + event EventHandler<SaveCreatedEventArgs> SaveCreated; + + /// <summary>Raised before the game begins writes data to the save file (except the initial save creation).</summary> + event EventHandler<SavingEventArgs> Saving; + + /// <summary>Raised after the game finishes writing data to the save file (except the initial save creation).</summary> + event EventHandler<SavedEventArgs> Saved; + + /// <summary>Raised after the player loads a save slot.</summary> + event EventHandler<SaveLoadedEventArgs> SaveLoaded; + + /// <summary>Raised after the game begins a new day (including when the player loads a save).</summary> + event EventHandler<DayStartedEventArgs> DayStarted; + + /// <summary>Raised before the game ends the current day. This happens before it starts setting up the next day and before <see cref="Saving"/>.</summary> + event EventHandler<DayEndingEventArgs> DayEnding; + + /// <summary>Raised after the in-game clock time changes.</summary> + event EventHandler<TimeChangedEventArgs> TimeChanged; + + /// <summary>Raised after the game returns to the title screen.</summary> + event EventHandler<ReturnedToTitleEventArgs> ReturnedToTitle; } } diff --git a/src/SMAPI/Events/IInputEvents.cs b/src/SMAPI/Events/IInputEvents.cs index 8e2ef406..5c40a438 100644 --- a/src/SMAPI/Events/IInputEvents.cs +++ b/src/SMAPI/Events/IInputEvents.cs @@ -6,15 +6,15 @@ namespace StardewModdingAPI.Events public interface IInputEvents { /// <summary>Raised after the player presses a button on the keyboard, controller, or mouse.</summary> - event EventHandler<InputButtonPressedEventArgs> ButtonPressed; + event EventHandler<ButtonPressedEventArgs> ButtonPressed; /// <summary>Raised after the player releases a button on the keyboard, controller, or mouse.</summary> - event EventHandler<InputButtonReleasedEventArgs> ButtonReleased; + event EventHandler<ButtonReleasedEventArgs> ButtonReleased; /// <summary>Raised after the player moves the in-game cursor.</summary> - event EventHandler<InputCursorMovedEventArgs> CursorMoved; + event EventHandler<CursorMovedEventArgs> CursorMoved; /// <summary>Raised after the player scrolls the mouse wheel.</summary> - event EventHandler<InputMouseWheelScrolledEventArgs> MouseWheelScrolled; + event EventHandler<MouseWheelScrolledEventArgs> MouseWheelScrolled; } } diff --git a/src/SMAPI/Events/IModEvents.cs b/src/SMAPI/Events/IModEvents.cs index cf2f8cb8..bd7ab880 100644 --- a/src/SMAPI/Events/IModEvents.cs +++ b/src/SMAPI/Events/IModEvents.cs @@ -3,13 +3,25 @@ namespace StardewModdingAPI.Events /// <summary>Manages access to events raised by SMAPI.</summary> public interface IModEvents { + /// <summary>Events related to UI and drawing to the screen.</summary> + IDisplayEvents Display { get; } + /// <summary>Events linked to the game's update loop. The update loop runs roughly ≈60 times/second to run game logic like state changes, action handling, etc. These can be useful, but you should consider more semantic events like <see cref="Input"/> if possible.</summary> IGameLoopEvents GameLoop { get; } /// <summary>Events raised when the player provides input using a controller, keyboard, or mouse.</summary> IInputEvents Input { get; } + /// <summary>Events raised for multiplayer messages and connections.</summary> + IMultiplayerEvents Multiplayer { get; } + + /// <summary>Events raised when the player data changes.</summary> + IPlayerEvents Player { get; } + /// <summary>Events raised when something changes in the world.</summary> IWorldEvents World { get; } + + /// <summary>Events serving specialised edge cases that shouldn't be used by most mods.</summary> + ISpecialisedEvents Specialised { get; } } } diff --git a/src/SMAPI/Events/IMultiplayerEvents.cs b/src/SMAPI/Events/IMultiplayerEvents.cs new file mode 100644 index 00000000..4a31f48e --- /dev/null +++ b/src/SMAPI/Events/IMultiplayerEvents.cs @@ -0,0 +1,17 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events raised for multiplayer messages and connections.</summary> + public interface IMultiplayerEvents + { + /// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection, so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary> + event EventHandler<PeerContextReceivedEventArgs> PeerContextReceived; + + /// <summary>Raised after a mod message is received over the network.</summary> + event EventHandler<ModMessageReceivedEventArgs> ModMessageReceived; + + /// <summary>Raised after the connection with a peer is severed.</summary> + event EventHandler<PeerDisconnectedEventArgs> PeerDisconnected; + } +} diff --git a/src/SMAPI/Events/IPlayerEvents.cs b/src/SMAPI/Events/IPlayerEvents.cs new file mode 100644 index 00000000..81e17b1a --- /dev/null +++ b/src/SMAPI/Events/IPlayerEvents.cs @@ -0,0 +1,17 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events raised when the player data changes.</summary> + public interface IPlayerEvents + { + /// <summary>Raised after items are added or removed to a player's inventory. NOTE: this event is currently only raised for the current player.</summary> + event EventHandler<InventoryChangedEventArgs> InventoryChanged; + + /// <summary>Raised after a player skill level changes. This happens as soon as they level up, not when the game notifies the player after their character goes to bed. NOTE: this event is currently only raised for the current player.</summary> + event EventHandler<LevelChangedEventArgs> LevelChanged; + + /// <summary>Raised after a player warps to a new location. NOTE: this event is currently only raised for the current player.</summary> + event EventHandler<WarpedEventArgs> Warped; + } +} diff --git a/src/SMAPI/Events/ISpecialisedEvents.cs b/src/SMAPI/Events/ISpecialisedEvents.cs new file mode 100644 index 00000000..928cd05d --- /dev/null +++ b/src/SMAPI/Events/ISpecialisedEvents.cs @@ -0,0 +1,14 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events serving specialised edge cases that shouldn't be used by most mods.</summary> + public interface ISpecialisedEvents + { + /// <summary>Raised before the game state is updated (≈60 times per second), regardless of normal SMAPI validation. This event is not thread-safe and may be invoked while game logic is running asynchronously. Changes to game state in this method may crash the game or corrupt an in-progress save. Do not use this event unless you're fully aware of the context in which your code will be run. Mods using this event will trigger a stability warning in the SMAPI console.</summary> + event EventHandler<UnvalidatedUpdateTickingEventArgs> UnvalidatedUpdateTicking; + + /// <summary>Raised after the game state is updated (≈60 times per second), regardless of normal SMAPI validation. This event is not thread-safe and may be invoked while game logic is running asynchronously. Changes to game state in this method may crash the game or corrupt an in-progress save. Do not use this event unless you're fully aware of the context in which your code will be run. Mods using this event will trigger a stability warning in the SMAPI console.</summary> + event EventHandler<UnvalidatedUpdateTickedEventArgs> UnvalidatedUpdateTicked; + } +} diff --git a/src/SMAPI/Events/IWorldEvents.cs b/src/SMAPI/Events/IWorldEvents.cs index d4efb53b..0ceffcc1 100644 --- a/src/SMAPI/Events/IWorldEvents.cs +++ b/src/SMAPI/Events/IWorldEvents.cs @@ -6,24 +6,24 @@ namespace StardewModdingAPI.Events public interface IWorldEvents { /// <summary>Raised after a game location is added or removed.</summary> - event EventHandler<WorldLocationListChangedEventArgs> LocationListChanged; + event EventHandler<LocationListChangedEventArgs> LocationListChanged; /// <summary>Raised after buildings are added or removed in a location.</summary> - event EventHandler<WorldBuildingListChangedEventArgs> BuildingListChanged; + event EventHandler<BuildingListChangedEventArgs> BuildingListChanged; /// <summary>Raised after debris are added or removed in a location.</summary> - event EventHandler<WorldDebrisListChangedEventArgs> DebrisListChanged; + event EventHandler<DebrisListChangedEventArgs> DebrisListChanged; /// <summary>Raised after large terrain features (like bushes) are added or removed in a location.</summary> - event EventHandler<WorldLargeTerrainFeatureListChangedEventArgs> LargeTerrainFeatureListChanged; + event EventHandler<LargeTerrainFeatureListChangedEventArgs> LargeTerrainFeatureListChanged; /// <summary>Raised after NPCs are added or removed in a location.</summary> - event EventHandler<WorldNpcListChangedEventArgs> NpcListChanged; + event EventHandler<NpcListChangedEventArgs> NpcListChanged; /// <summary>Raised after objects are added or removed in a location.</summary> - event EventHandler<WorldObjectListChangedEventArgs> ObjectListChanged; + event EventHandler<ObjectListChangedEventArgs> ObjectListChanged; /// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary> - event EventHandler<WorldTerrainFeatureListChangedEventArgs> TerrainFeatureListChanged; + event EventHandler<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged; } } diff --git a/src/SMAPI/Events/InputEvents.cs b/src/SMAPI/Events/InputEvents.cs index e62d6ee6..4c1781a5 100644 --- a/src/SMAPI/Events/InputEvents.cs +++ b/src/SMAPI/Events/InputEvents.cs @@ -19,15 +19,15 @@ namespace StardewModdingAPI.Events /// <summary>Raised when the player presses a button on the keyboard, controller, or mouse.</summary> public static event EventHandler<EventArgsInput> ButtonPressed { - add => InputEvents.EventManager.Legacy_Input_ButtonPressed.Add(value); - remove => InputEvents.EventManager.Legacy_Input_ButtonPressed.Remove(value); + add => InputEvents.EventManager.Legacy_ButtonPressed.Add(value); + remove => InputEvents.EventManager.Legacy_ButtonPressed.Remove(value); } /// <summary>Raised when the player releases a keyboard key on the keyboard, controller, or mouse.</summary> public static event EventHandler<EventArgsInput> ButtonReleased { - add => InputEvents.EventManager.Legacy_Input_ButtonReleased.Add(value); - remove => InputEvents.EventManager.Legacy_Input_ButtonReleased.Remove(value); + add => InputEvents.EventManager.Legacy_ButtonReleased.Add(value); + remove => InputEvents.EventManager.Legacy_ButtonReleased.Remove(value); } diff --git a/src/SMAPI/Events/InventoryChangedEventArgs.cs b/src/SMAPI/Events/InventoryChangedEventArgs.cs new file mode 100644 index 00000000..a081611b --- /dev/null +++ b/src/SMAPI/Events/InventoryChangedEventArgs.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IPlayerEvents.InventoryChanged"/> event.</summary> + public class InventoryChangedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The player whose inventory changed.</summary> + public Farmer Player { get; } + + /// <summary>The added items.</summary> + public IEnumerable<Item> Added { get; } + + /// <summary>The removed items.</summary> + public IEnumerable<Item> Removed { get; } + + /// <summary>The items whose stack sizes changed, with the relative change.</summary> + public IEnumerable<ItemStackSizeChange> QuantityChanged { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="player">The player whose inventory changed.</param> + /// <param name="changedItems">The inventory changes.</param> + public InventoryChangedEventArgs(Farmer player, ItemStackChange[] changedItems) + { + this.Player = player; + this.Added = changedItems + .Where(n => n.ChangeType == ChangeType.Added) + .Select(p => p.Item) + .ToArray(); + + this.Removed = changedItems + .Where(n => n.ChangeType == ChangeType.Removed) + .Select(p => p.Item) + .ToArray(); + + this.QuantityChanged = changedItems + .Where(n => n.ChangeType == ChangeType.StackChange) + .Select(change => new ItemStackSizeChange( + item: change.Item, + oldSize: change.Item.Stack - change.StackChange, + newSize: change.Item.Stack + )) + .ToArray(); + } + } +} diff --git a/src/SMAPI/Events/ItemStackSizeChange.cs b/src/SMAPI/Events/ItemStackSizeChange.cs new file mode 100644 index 00000000..35369be2 --- /dev/null +++ b/src/SMAPI/Events/ItemStackSizeChange.cs @@ -0,0 +1,35 @@ +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>An inventory item stack size change.</summary> + public class ItemStackSizeChange + { + /********* + ** Accessors + *********/ + /// <summary>The item whose stack size changed.</summary> + public Item Item { get; } + + /// <summary>The previous stack size.</summary> + public int OldSize { get; } + + /// <summary>The new stack size.</summary> + public int NewSize { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="item">The item whose stack size changed.</param> + /// <param name="oldSize">The previous stack size.</param> + /// <param name="newSize">The new stack size.</param> + public ItemStackSizeChange(Item item, int oldSize, int newSize) + { + this.Item = item; + this.OldSize = oldSize; + this.NewSize = newSize; + } + } +} diff --git a/src/SMAPI/Events/WorldLargeTerrainFeatureListChangedEventArgs.cs b/src/SMAPI/Events/LargeTerrainFeatureListChangedEventArgs.cs index 053a0e41..63b12687 100644 --- a/src/SMAPI/Events/WorldLargeTerrainFeatureListChangedEventArgs.cs +++ b/src/SMAPI/Events/LargeTerrainFeatureListChangedEventArgs.cs @@ -7,7 +7,7 @@ using StardewValley.TerrainFeatures; namespace StardewModdingAPI.Events { /// <summary>Event arguments for a <see cref="IWorldEvents.LargeTerrainFeatureListChanged"/> event.</summary> - public class WorldLargeTerrainFeatureListChangedEventArgs : EventArgs + public class LargeTerrainFeatureListChangedEventArgs : EventArgs { /********* ** Accessors @@ -29,7 +29,7 @@ namespace StardewModdingAPI.Events /// <param name="location">The location which changed.</param> /// <param name="added">The large terrain features added to the location.</param> /// <param name="removed">The large terrain features removed from the location.</param> - public WorldLargeTerrainFeatureListChangedEventArgs(GameLocation location, IEnumerable<LargeTerrainFeature> added, IEnumerable<LargeTerrainFeature> removed) + public LargeTerrainFeatureListChangedEventArgs(GameLocation location, IEnumerable<LargeTerrainFeature> added, IEnumerable<LargeTerrainFeature> removed) { this.Location = location; this.Added = added.ToArray(); diff --git a/src/SMAPI/Events/LevelChangedEventArgs.cs b/src/SMAPI/Events/LevelChangedEventArgs.cs new file mode 100644 index 00000000..174094c7 --- /dev/null +++ b/src/SMAPI/Events/LevelChangedEventArgs.cs @@ -0,0 +1,42 @@ +using System; +using StardewModdingAPI.Enums; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="IPlayerEvents.LevelChanged"/> event.</summary> + public class LevelChangedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The player whose skill level changed.</summary> + public Farmer Player { get; } + + /// <summary>The skill whose level changed.</summary> + public SkillType Skill { get; } + + /// <summary>The previous skill level.</summary> + public int OldLevel { get; } + + /// <summary>The new skill level.</summary> + public int NewLevel { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="player">The player whose skill level changed.</param> + /// <param name="skill">The skill whose level changed.</param> + /// <param name="oldLevel">The previous skill level.</param> + /// <param name="newLevel">The new skill level.</param> + public LevelChangedEventArgs(Farmer player, SkillType skill, int oldLevel, int newLevel) + { + this.Player = player; + this.Skill = skill; + this.OldLevel = oldLevel; + this.NewLevel = newLevel; + } + } +} diff --git a/src/SMAPI/Events/LocationEvents.cs b/src/SMAPI/Events/LocationEvents.cs index e2108de0..81f547ae 100644 --- a/src/SMAPI/Events/LocationEvents.cs +++ b/src/SMAPI/Events/LocationEvents.cs @@ -19,22 +19,22 @@ namespace StardewModdingAPI.Events /// <summary>Raised after a game location is added or removed.</summary> public static event EventHandler<EventArgsLocationsChanged> LocationsChanged { - add => LocationEvents.EventManager.Legacy_Location_LocationsChanged.Add(value); - remove => LocationEvents.EventManager.Legacy_Location_LocationsChanged.Remove(value); + add => LocationEvents.EventManager.Legacy_LocationsChanged.Add(value); + remove => LocationEvents.EventManager.Legacy_LocationsChanged.Remove(value); } /// <summary>Raised after buildings are added or removed in a location.</summary> public static event EventHandler<EventArgsLocationBuildingsChanged> BuildingsChanged { - add => LocationEvents.EventManager.Legacy_Location_BuildingsChanged.Add(value); - remove => LocationEvents.EventManager.Legacy_Location_BuildingsChanged.Remove(value); + add => LocationEvents.EventManager.Legacy_BuildingsChanged.Add(value); + remove => LocationEvents.EventManager.Legacy_BuildingsChanged.Remove(value); } /// <summary>Raised after objects are added or removed in a location.</summary> public static event EventHandler<EventArgsLocationObjectsChanged> ObjectsChanged { - add => LocationEvents.EventManager.Legacy_Location_ObjectsChanged.Add(value); - remove => LocationEvents.EventManager.Legacy_Location_ObjectsChanged.Remove(value); + add => LocationEvents.EventManager.Legacy_ObjectsChanged.Add(value); + remove => LocationEvents.EventManager.Legacy_ObjectsChanged.Remove(value); } diff --git a/src/SMAPI/Events/WorldLocationListChangedEventArgs.cs b/src/SMAPI/Events/LocationListChangedEventArgs.cs index 8bc26a43..e93f0a80 100644 --- a/src/SMAPI/Events/WorldLocationListChangedEventArgs.cs +++ b/src/SMAPI/Events/LocationListChangedEventArgs.cs @@ -6,7 +6,7 @@ using StardewValley; namespace StardewModdingAPI.Events { /// <summary>Event arguments for a <see cref="IWorldEvents.LocationListChanged"/> event.</summary> - public class WorldLocationListChangedEventArgs : EventArgs + public class LocationListChangedEventArgs : EventArgs { /********* ** Accessors @@ -24,7 +24,7 @@ namespace StardewModdingAPI.Events /// <summary>Construct an instance.</summary> /// <param name="added">The added locations.</param> /// <param name="removed">The removed locations.</param> - public WorldLocationListChangedEventArgs(IEnumerable<GameLocation> added, IEnumerable<GameLocation> removed) + public LocationListChangedEventArgs(IEnumerable<GameLocation> added, IEnumerable<GameLocation> removed) { this.Added = added.ToArray(); this.Removed = removed.ToArray(); diff --git a/src/SMAPI/Events/MenuChangedEventArgs.cs b/src/SMAPI/Events/MenuChangedEventArgs.cs new file mode 100644 index 00000000..e1c049a2 --- /dev/null +++ b/src/SMAPI/Events/MenuChangedEventArgs.cs @@ -0,0 +1,31 @@ +using System; +using StardewValley.Menus; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IDisplayEvents.MenuChanged"/> event.</summary> + public class MenuChangedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The previous menu.</summary> + public IClickableMenu OldMenu { get; } + + /// <summary>The current menu.</summary> + public IClickableMenu NewMenu { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="oldMenu">The previous menu.</param> + /// <param name="newMenu">The current menu.</param> + public MenuChangedEventArgs(IClickableMenu oldMenu, IClickableMenu newMenu) + { + this.OldMenu = oldMenu; + this.NewMenu = newMenu; + } + } +} diff --git a/src/SMAPI/Events/MenuEvents.cs b/src/SMAPI/Events/MenuEvents.cs index 7fcc3844..362b5070 100644 --- a/src/SMAPI/Events/MenuEvents.cs +++ b/src/SMAPI/Events/MenuEvents.cs @@ -19,15 +19,15 @@ namespace StardewModdingAPI.Events /// <summary>Raised after a game menu is opened or replaced with another menu. This event is not invoked when a menu is closed.</summary> public static event EventHandler<EventArgsClickableMenuChanged> MenuChanged { - add => MenuEvents.EventManager.Menu_Changed.Add(value); - remove => MenuEvents.EventManager.Menu_Changed.Remove(value); + add => MenuEvents.EventManager.Legacy_MenuChanged.Add(value); + remove => MenuEvents.EventManager.Legacy_MenuChanged.Remove(value); } /// <summary>Raised after a game menu is closed.</summary> public static event EventHandler<EventArgsClickableMenuClosed> MenuClosed { - add => MenuEvents.EventManager.Menu_Closed.Add(value); - remove => MenuEvents.EventManager.Menu_Closed.Remove(value); + add => MenuEvents.EventManager.Legacy_MenuClosed.Add(value); + remove => MenuEvents.EventManager.Legacy_MenuClosed.Remove(value); } diff --git a/src/SMAPI/Events/MineEvents.cs b/src/SMAPI/Events/MineEvents.cs index 5ee4001b..f5565a76 100644 --- a/src/SMAPI/Events/MineEvents.cs +++ b/src/SMAPI/Events/MineEvents.cs @@ -19,8 +19,8 @@ namespace StardewModdingAPI.Events /// <summary>Raised after the player warps to a new level of the mine.</summary> public static event EventHandler<EventArgsMineLevelChanged> MineLevelChanged { - add => MineEvents.EventManager.Mine_LevelChanged.Add(value); - remove => MineEvents.EventManager.Mine_LevelChanged.Remove(value); + add => MineEvents.EventManager.Legacy_MineLevelChanged.Add(value); + remove => MineEvents.EventManager.Legacy_MineLevelChanged.Remove(value); } diff --git a/src/SMAPI/Events/ModMessageReceivedEventArgs.cs b/src/SMAPI/Events/ModMessageReceivedEventArgs.cs new file mode 100644 index 00000000..49366ec6 --- /dev/null +++ b/src/SMAPI/Events/ModMessageReceivedEventArgs.cs @@ -0,0 +1,46 @@ +using System; +using StardewModdingAPI.Framework.Networking; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IMultiplayerEvents.ModMessageReceived"/> event.</summary> + public class ModMessageReceivedEventArgs : EventArgs + { + /********* + ** Properties + *********/ + /// <summary>The underlying message model.</summary> + private readonly ModMessageModel Message; + + + /********* + ** Accessors + *********/ + /// <summary>The unique ID of the player from whose computer the message was sent.</summary> + public long FromPlayerID => this.Message.FromPlayerID; + + /// <summary>The unique ID of the mod which sent the message.</summary> + public string FromModID => this.Message.FromModID; + + /// <summary>A message type which can be used to decide whether it's the one you want to handle, like <c>SetPlayerLocation</c>. This doesn't need to be globally unique, so mods should check the <see cref="FromModID"/>.</summary> + public string Type => this.Message.Type; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="message">The received message.</param> + internal ModMessageReceivedEventArgs(ModMessageModel message) + { + this.Message = message; + } + + /// <summary>Read the message data into the given model type.</summary> + /// <typeparam name="TModel">The message model type.</typeparam> + public TModel ReadAs<TModel>() + { + return this.Message.Data.ToObject<TModel>(); + } + } +} diff --git a/src/SMAPI/Events/InputMouseWheelScrolledEventArgs.cs b/src/SMAPI/Events/MouseWheelScrolledEventArgs.cs index 9afab9cc..3ab9d412 100644 --- a/src/SMAPI/Events/InputMouseWheelScrolledEventArgs.cs +++ b/src/SMAPI/Events/MouseWheelScrolledEventArgs.cs @@ -3,7 +3,7 @@ using System; namespace StardewModdingAPI.Events { /// <summary>Event arguments when the player scrolls the mouse wheel.</summary> - public class InputMouseWheelScrolledEventArgs : EventArgs + public class MouseWheelScrolledEventArgs : EventArgs { /********* ** Accessors @@ -28,7 +28,7 @@ namespace StardewModdingAPI.Events /// <param name="position">The cursor position.</param> /// <param name="oldValue">The old scroll value.</param> /// <param name="newValue">The new scroll value.</param> - public InputMouseWheelScrolledEventArgs(ICursorPosition position, int oldValue, int newValue) + public MouseWheelScrolledEventArgs(ICursorPosition position, int oldValue, int newValue) { this.Position = position; this.OldValue = oldValue; diff --git a/src/SMAPI/Events/MultiplayerEvents.cs b/src/SMAPI/Events/MultiplayerEvents.cs index f96ecba5..49de380e 100644 --- a/src/SMAPI/Events/MultiplayerEvents.cs +++ b/src/SMAPI/Events/MultiplayerEvents.cs @@ -19,29 +19,29 @@ namespace StardewModdingAPI.Events /// <summary>Raised before the game syncs changes from other players.</summary> public static event EventHandler BeforeMainSync { - add => MultiplayerEvents.EventManager.Multiplayer_BeforeMainSync.Add(value); - remove => MultiplayerEvents.EventManager.Multiplayer_BeforeMainSync.Remove(value); + add => MultiplayerEvents.EventManager.Legacy_BeforeMainSync.Add(value); + remove => MultiplayerEvents.EventManager.Legacy_BeforeMainSync.Remove(value); } /// <summary>Raised after the game syncs changes from other players.</summary> public static event EventHandler AfterMainSync { - add => MultiplayerEvents.EventManager.Multiplayer_AfterMainSync.Add(value); - remove => MultiplayerEvents.EventManager.Multiplayer_AfterMainSync.Remove(value); + add => MultiplayerEvents.EventManager.Legacy_AfterMainSync.Add(value); + remove => MultiplayerEvents.EventManager.Legacy_AfterMainSync.Remove(value); } /// <summary>Raised before the game broadcasts changes to other players.</summary> public static event EventHandler BeforeMainBroadcast { - add => MultiplayerEvents.EventManager.Multiplayer_BeforeMainBroadcast.Add(value); - remove => MultiplayerEvents.EventManager.Multiplayer_BeforeMainBroadcast.Remove(value); + add => MultiplayerEvents.EventManager.Legacy_BeforeMainBroadcast.Add(value); + remove => MultiplayerEvents.EventManager.Legacy_BeforeMainBroadcast.Remove(value); } /// <summary>Raised after the game broadcasts changes to other players.</summary> public static event EventHandler AfterMainBroadcast { - add => MultiplayerEvents.EventManager.Multiplayer_AfterMainBroadcast.Add(value); - remove => MultiplayerEvents.EventManager.Multiplayer_AfterMainBroadcast.Remove(value); + add => MultiplayerEvents.EventManager.Legacy_AfterMainBroadcast.Add(value); + remove => MultiplayerEvents.EventManager.Legacy_AfterMainBroadcast.Remove(value); } diff --git a/src/SMAPI/Events/WorldNpcListChangedEventArgs.cs b/src/SMAPI/Events/NpcListChangedEventArgs.cs index e251f894..eca28244 100644 --- a/src/SMAPI/Events/WorldNpcListChangedEventArgs.cs +++ b/src/SMAPI/Events/NpcListChangedEventArgs.cs @@ -6,7 +6,7 @@ using StardewValley; namespace StardewModdingAPI.Events { /// <summary>Event arguments for a <see cref="IWorldEvents.NpcListChanged"/> event.</summary> - public class WorldNpcListChangedEventArgs : EventArgs + public class NpcListChangedEventArgs : EventArgs { /********* ** Accessors @@ -28,7 +28,7 @@ namespace StardewModdingAPI.Events /// <param name="location">The location which changed.</param> /// <param name="added">The NPCs added to the location.</param> /// <param name="removed">The NPCs removed from the location.</param> - public WorldNpcListChangedEventArgs(GameLocation location, IEnumerable<NPC> added, IEnumerable<NPC> removed) + public NpcListChangedEventArgs(GameLocation location, IEnumerable<NPC> added, IEnumerable<NPC> removed) { this.Location = location; this.Added = added.ToArray(); diff --git a/src/SMAPI/Events/WorldObjectListChangedEventArgs.cs b/src/SMAPI/Events/ObjectListChangedEventArgs.cs index 5623a49b..55a4034f 100644 --- a/src/SMAPI/Events/WorldObjectListChangedEventArgs.cs +++ b/src/SMAPI/Events/ObjectListChangedEventArgs.cs @@ -8,7 +8,7 @@ using Object = StardewValley.Object; namespace StardewModdingAPI.Events { /// <summary>Event arguments for a <see cref="IWorldEvents.ObjectListChanged"/> event.</summary> - public class WorldObjectListChangedEventArgs : EventArgs + public class ObjectListChangedEventArgs : EventArgs { /********* ** Accessors @@ -30,7 +30,7 @@ namespace StardewModdingAPI.Events /// <param name="location">The location which changed.</param> /// <param name="added">The objects added to the location.</param> /// <param name="removed">The objects removed from the location.</param> - public WorldObjectListChangedEventArgs(GameLocation location, IEnumerable<KeyValuePair<Vector2, Object>> added, IEnumerable<KeyValuePair<Vector2, Object>> removed) + public ObjectListChangedEventArgs(GameLocation location, IEnumerable<KeyValuePair<Vector2, Object>> added, IEnumerable<KeyValuePair<Vector2, Object>> removed) { this.Location = location; this.Added = added.ToArray(); diff --git a/src/SMAPI/Events/PeerContextReceivedEventArgs.cs b/src/SMAPI/Events/PeerContextReceivedEventArgs.cs new file mode 100644 index 00000000..151a295c --- /dev/null +++ b/src/SMAPI/Events/PeerContextReceivedEventArgs.cs @@ -0,0 +1,25 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IMultiplayerEvents.PeerContextReceived"/> event.</summary> + public class PeerContextReceivedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The peer whose metadata was received.</summary> + public IMultiplayerPeer Peer { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="peer">The peer whose metadata was received.</param> + internal PeerContextReceivedEventArgs(IMultiplayerPeer peer) + { + this.Peer = peer; + } + } +} diff --git a/src/SMAPI/Events/PeerDisconnectedEventArgs.cs b/src/SMAPI/Events/PeerDisconnectedEventArgs.cs new file mode 100644 index 00000000..8517988a --- /dev/null +++ b/src/SMAPI/Events/PeerDisconnectedEventArgs.cs @@ -0,0 +1,25 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IMultiplayerEvents.PeerDisconnected"/> event.</summary> + public class PeerDisconnectedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The peer who disconnected.</summary> + public IMultiplayerPeer Peer { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="peer">The peer who disconnected.</param> + internal PeerDisconnectedEventArgs(IMultiplayerPeer peer) + { + this.Peer = peer; + } + } +} diff --git a/src/SMAPI/Events/PlayerEvents.cs b/src/SMAPI/Events/PlayerEvents.cs index 6e7050e3..bfc1b569 100644 --- a/src/SMAPI/Events/PlayerEvents.cs +++ b/src/SMAPI/Events/PlayerEvents.cs @@ -19,22 +19,22 @@ namespace StardewModdingAPI.Events /// <summary>Raised after the player's inventory changes in any way (added or removed item, sorted, etc).</summary> public static event EventHandler<EventArgsInventoryChanged> InventoryChanged { - add => PlayerEvents.EventManager.Player_InventoryChanged.Add(value); - remove => PlayerEvents.EventManager.Player_InventoryChanged.Remove(value); + add => PlayerEvents.EventManager.Legacy_InventoryChanged.Add(value); + remove => PlayerEvents.EventManager.Legacy_InventoryChanged.Remove(value); } /// <summary>Raised after the player levels up a skill. This happens as soon as they level up, not when the game notifies the player after their character goes to bed.</summary> public static event EventHandler<EventArgsLevelUp> LeveledUp { - add => PlayerEvents.EventManager.Player_LeveledUp.Add(value); - remove => PlayerEvents.EventManager.Player_LeveledUp.Remove(value); + add => PlayerEvents.EventManager.Legacy_LeveledUp.Add(value); + remove => PlayerEvents.EventManager.Legacy_LeveledUp.Remove(value); } /// <summary>Raised after the player warps to a new location.</summary> public static event EventHandler<EventArgsPlayerWarped> Warped { - add => PlayerEvents.EventManager.Player_Warped.Add(value); - remove => PlayerEvents.EventManager.Player_Warped.Remove(value); + add => PlayerEvents.EventManager.Legacy_PlayerWarped.Add(value); + remove => PlayerEvents.EventManager.Legacy_PlayerWarped.Remove(value); } diff --git a/src/SMAPI/Events/RenderedActiveMenuEventArgs.cs b/src/SMAPI/Events/RenderedActiveMenuEventArgs.cs new file mode 100644 index 00000000..efd4163b --- /dev/null +++ b/src/SMAPI/Events/RenderedActiveMenuEventArgs.cs @@ -0,0 +1,16 @@ +using System; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IDisplayEvents.RenderedActiveMenu"/> event.</summary> + public class RenderedActiveMenuEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The sprite batch being drawn. Add anything you want to appear on-screen to this sprite batch.</summary> + public SpriteBatch SpriteBatch => Game1.spriteBatch; + } +} diff --git a/src/SMAPI/Events/RenderedEventArgs.cs b/src/SMAPI/Events/RenderedEventArgs.cs new file mode 100644 index 00000000..d6341b19 --- /dev/null +++ b/src/SMAPI/Events/RenderedEventArgs.cs @@ -0,0 +1,16 @@ +using System; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IDisplayEvents.Rendered"/> event.</summary> + public class RenderedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The sprite batch being drawn. Add anything you want to appear on-screen to this sprite batch.</summary> + public SpriteBatch SpriteBatch => Game1.spriteBatch; + } +} diff --git a/src/SMAPI/Events/RenderedHudEventArgs.cs b/src/SMAPI/Events/RenderedHudEventArgs.cs new file mode 100644 index 00000000..46e89013 --- /dev/null +++ b/src/SMAPI/Events/RenderedHudEventArgs.cs @@ -0,0 +1,16 @@ +using System; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IDisplayEvents.RenderedHud"/> event.</summary> + public class RenderedHudEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The sprite batch being drawn. Add anything you want to appear on-screen to this sprite batch.</summary> + public SpriteBatch SpriteBatch => Game1.spriteBatch; + } +} diff --git a/src/SMAPI/Events/RenderedWorldEventArgs.cs b/src/SMAPI/Events/RenderedWorldEventArgs.cs new file mode 100644 index 00000000..56145381 --- /dev/null +++ b/src/SMAPI/Events/RenderedWorldEventArgs.cs @@ -0,0 +1,16 @@ +using System; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IDisplayEvents.RenderedWorld"/> event.</summary> + public class RenderedWorldEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The sprite batch being drawn. Add anything you want to appear on-screen to this sprite batch.</summary> + public SpriteBatch SpriteBatch => Game1.spriteBatch; + } +} diff --git a/src/SMAPI/Events/RenderingActiveMenuEventArgs.cs b/src/SMAPI/Events/RenderingActiveMenuEventArgs.cs new file mode 100644 index 00000000..103f56df --- /dev/null +++ b/src/SMAPI/Events/RenderingActiveMenuEventArgs.cs @@ -0,0 +1,16 @@ +using System; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IDisplayEvents.RenderingActiveMenu"/> event.</summary> + public class RenderingActiveMenuEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The sprite batch being drawn. Add anything you want to appear on-screen to this sprite batch.</summary> + public SpriteBatch SpriteBatch => Game1.spriteBatch; + } +} diff --git a/src/SMAPI/Events/RenderingEventArgs.cs b/src/SMAPI/Events/RenderingEventArgs.cs new file mode 100644 index 00000000..5acbef09 --- /dev/null +++ b/src/SMAPI/Events/RenderingEventArgs.cs @@ -0,0 +1,16 @@ +using System; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IDisplayEvents.Rendering"/> event.</summary> + public class RenderingEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The sprite batch being drawn. Add anything you want to appear on-screen to this sprite batch.</summary> + public SpriteBatch SpriteBatch => Game1.spriteBatch; + } +} diff --git a/src/SMAPI/Events/RenderingHudEventArgs.cs b/src/SMAPI/Events/RenderingHudEventArgs.cs new file mode 100644 index 00000000..84c96ecd --- /dev/null +++ b/src/SMAPI/Events/RenderingHudEventArgs.cs @@ -0,0 +1,16 @@ +using System; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IDisplayEvents.RenderingHud"/> event.</summary> + public class RenderingHudEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The sprite batch being drawn. Add anything you want to appear on-screen to this sprite batch.</summary> + public SpriteBatch SpriteBatch => Game1.spriteBatch; + } +} diff --git a/src/SMAPI/Events/RenderingWorldEventArgs.cs b/src/SMAPI/Events/RenderingWorldEventArgs.cs new file mode 100644 index 00000000..d0d44789 --- /dev/null +++ b/src/SMAPI/Events/RenderingWorldEventArgs.cs @@ -0,0 +1,16 @@ +using System; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IDisplayEvents.RenderingWorld"/> event.</summary> + public class RenderingWorldEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The sprite batch being drawn. Add anything you want to appear on-screen to this sprite batch.</summary> + public SpriteBatch SpriteBatch => Game1.spriteBatch; + } +} diff --git a/src/SMAPI/Events/ReturnedToTitleEventArgs.cs b/src/SMAPI/Events/ReturnedToTitleEventArgs.cs new file mode 100644 index 00000000..96309cde --- /dev/null +++ b/src/SMAPI/Events/ReturnedToTitleEventArgs.cs @@ -0,0 +1,7 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IGameLoopEvents.ReturnedToTitle"/> event.</summary> + public class ReturnedToTitleEventArgs : EventArgs { } +} diff --git a/src/SMAPI/Events/SaveCreatedEventArgs.cs b/src/SMAPI/Events/SaveCreatedEventArgs.cs new file mode 100644 index 00000000..5ae22531 --- /dev/null +++ b/src/SMAPI/Events/SaveCreatedEventArgs.cs @@ -0,0 +1,7 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IGameLoopEvents.SaveCreated"/> event.</summary> + public class SaveCreatedEventArgs : EventArgs { } +} diff --git a/src/SMAPI/Events/SaveCreatingEventArgs.cs b/src/SMAPI/Events/SaveCreatingEventArgs.cs new file mode 100644 index 00000000..3c83f421 --- /dev/null +++ b/src/SMAPI/Events/SaveCreatingEventArgs.cs @@ -0,0 +1,7 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IGameLoopEvents.SaveCreating"/> event.</summary> + public class SaveCreatingEventArgs : EventArgs { } +} diff --git a/src/SMAPI/Events/SaveEvents.cs b/src/SMAPI/Events/SaveEvents.cs index 62184282..731bf9d1 100644 --- a/src/SMAPI/Events/SaveEvents.cs +++ b/src/SMAPI/Events/SaveEvents.cs @@ -19,43 +19,43 @@ namespace StardewModdingAPI.Events /// <summary>Raised before the game creates the save file.</summary> public static event EventHandler BeforeCreate { - add => SaveEvents.EventManager.Save_BeforeCreate.Add(value); - remove => SaveEvents.EventManager.Save_BeforeCreate.Remove(value); + add => SaveEvents.EventManager.Legacy_BeforeCreateSave.Add(value); + remove => SaveEvents.EventManager.Legacy_BeforeCreateSave.Remove(value); } /// <summary>Raised after the game finishes creating the save file.</summary> public static event EventHandler AfterCreate { - add => SaveEvents.EventManager.Save_AfterCreate.Add(value); - remove => SaveEvents.EventManager.Save_AfterCreate.Remove(value); + add => SaveEvents.EventManager.Legacy_AfterCreateSave.Add(value); + remove => SaveEvents.EventManager.Legacy_AfterCreateSave.Remove(value); } /// <summary>Raised before the game begins writes data to the save file.</summary> public static event EventHandler BeforeSave { - add => SaveEvents.EventManager.Save_BeforeSave.Add(value); - remove => SaveEvents.EventManager.Save_BeforeSave.Remove(value); + add => SaveEvents.EventManager.Legacy_BeforeSave.Add(value); + remove => SaveEvents.EventManager.Legacy_BeforeSave.Remove(value); } /// <summary>Raised after the game finishes writing data to the save file.</summary> public static event EventHandler AfterSave { - add => SaveEvents.EventManager.Save_AfterSave.Add(value); - remove => SaveEvents.EventManager.Save_AfterSave.Remove(value); + add => SaveEvents.EventManager.Legacy_AfterSave.Add(value); + remove => SaveEvents.EventManager.Legacy_AfterSave.Remove(value); } /// <summary>Raised after the player loads a save slot.</summary> public static event EventHandler AfterLoad { - add => SaveEvents.EventManager.Save_AfterLoad.Add(value); - remove => SaveEvents.EventManager.Save_AfterLoad.Remove(value); + add => SaveEvents.EventManager.Legacy_AfterLoad.Add(value); + remove => SaveEvents.EventManager.Legacy_AfterLoad.Remove(value); } /// <summary>Raised after the game returns to the title screen.</summary> public static event EventHandler AfterReturnToTitle { - add => SaveEvents.EventManager.Save_AfterReturnToTitle.Add(value); - remove => SaveEvents.EventManager.Save_AfterReturnToTitle.Remove(value); + add => SaveEvents.EventManager.Legacy_AfterReturnToTitle.Add(value); + remove => SaveEvents.EventManager.Legacy_AfterReturnToTitle.Remove(value); } diff --git a/src/SMAPI/Events/SaveLoadedEventArgs.cs b/src/SMAPI/Events/SaveLoadedEventArgs.cs new file mode 100644 index 00000000..f8aaa7f7 --- /dev/null +++ b/src/SMAPI/Events/SaveLoadedEventArgs.cs @@ -0,0 +1,7 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IGameLoopEvents.SaveLoaded"/> event.</summary> + public class SaveLoadedEventArgs : EventArgs { } +} diff --git a/src/SMAPI/Events/SavedEventArgs.cs b/src/SMAPI/Events/SavedEventArgs.cs new file mode 100644 index 00000000..a4e90729 --- /dev/null +++ b/src/SMAPI/Events/SavedEventArgs.cs @@ -0,0 +1,7 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IGameLoopEvents.Saved"/> event.</summary> + public class SavedEventArgs : EventArgs { } +} diff --git a/src/SMAPI/Events/SavingEventArgs.cs b/src/SMAPI/Events/SavingEventArgs.cs new file mode 100644 index 00000000..f323ca9e --- /dev/null +++ b/src/SMAPI/Events/SavingEventArgs.cs @@ -0,0 +1,7 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IGameLoopEvents.Saving"/> event.</summary> + public class SavingEventArgs : EventArgs { } +} diff --git a/src/SMAPI/Events/SpecialisedEvents.cs b/src/SMAPI/Events/SpecialisedEvents.cs index 33ebf3b2..bdf25ccb 100644 --- a/src/SMAPI/Events/SpecialisedEvents.cs +++ b/src/SMAPI/Events/SpecialisedEvents.cs @@ -3,7 +3,7 @@ using StardewModdingAPI.Framework.Events; namespace StardewModdingAPI.Events { - /// <summary>Events serving specialised edge cases that shouldn't be used by most mod.</summary> + /// <summary>Events serving specialised edge cases that shouldn't be used by most mods.</summary> public static class SpecialisedEvents { /********* @@ -19,8 +19,8 @@ namespace StardewModdingAPI.Events /// <summary>Raised when the game updates its state (≈60 times per second), regardless of normal SMAPI validation. This event is not thread-safe and may be invoked while game logic is running asynchronously. Changes to game state in this method may crash the game or corrupt an in-progress save. Do not use this event unless you're fully aware of the context in which your code will be run. Mods using this method will trigger a stability warning in the SMAPI console.</summary> public static event EventHandler UnvalidatedUpdateTick { - add => SpecialisedEvents.EventManager.Specialised_UnvalidatedUpdateTick.Add(value); - remove => SpecialisedEvents.EventManager.Specialised_UnvalidatedUpdateTick.Remove(value); + add => SpecialisedEvents.EventManager.Legacy_UnvalidatedUpdateTick.Add(value); + remove => SpecialisedEvents.EventManager.Legacy_UnvalidatedUpdateTick.Remove(value); } diff --git a/src/SMAPI/Events/WorldTerrainFeatureListChangedEventArgs.cs b/src/SMAPI/Events/TerrainFeatureListChangedEventArgs.cs index cb089811..562b1d3c 100644 --- a/src/SMAPI/Events/WorldTerrainFeatureListChangedEventArgs.cs +++ b/src/SMAPI/Events/TerrainFeatureListChangedEventArgs.cs @@ -8,7 +8,7 @@ using StardewValley.TerrainFeatures; namespace StardewModdingAPI.Events { /// <summary>Event arguments for a <see cref="IWorldEvents.TerrainFeatureListChanged"/> event.</summary> - public class WorldTerrainFeatureListChangedEventArgs : EventArgs + public class TerrainFeatureListChangedEventArgs : EventArgs { /********* ** Accessors @@ -30,7 +30,7 @@ namespace StardewModdingAPI.Events /// <param name="location">The location which changed.</param> /// <param name="added">The terrain features added to the location.</param> /// <param name="removed">The terrain features removed from the location.</param> - public WorldTerrainFeatureListChangedEventArgs(GameLocation location, IEnumerable<KeyValuePair<Vector2, TerrainFeature>> added, IEnumerable<KeyValuePair<Vector2, TerrainFeature>> removed) + public TerrainFeatureListChangedEventArgs(GameLocation location, IEnumerable<KeyValuePair<Vector2, TerrainFeature>> added, IEnumerable<KeyValuePair<Vector2, TerrainFeature>> removed) { this.Location = location; this.Added = added.ToArray(); diff --git a/src/SMAPI/Events/TimeChangedEventArgs.cs b/src/SMAPI/Events/TimeChangedEventArgs.cs new file mode 100644 index 00000000..fd472092 --- /dev/null +++ b/src/SMAPI/Events/TimeChangedEventArgs.cs @@ -0,0 +1,30 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IGameLoopEvents.TimeChanged"/> event.</summary> + public class TimeChangedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The previous time of day in 24-hour notation (like 1600 for 4pm). The clock time resets when the player sleeps, so 2am (before sleeping) is 2600.</summary> + public int OldTime { get; } + + /// <summary>The current time of day in 24-hour notation (like 1600 for 4pm). The clock time resets when the player sleeps, so 2am (before sleeping) is 2600.</summary> + public int NewTime { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="oldTime">The previous time of day in 24-hour notation (like 1600 for 4pm).</param> + /// <param name="newTime">The current time of day in 24-hour notation (like 1600 for 4pm).</param> + public TimeChangedEventArgs(int oldTime, int newTime) + { + this.OldTime = oldTime; + this.NewTime = newTime; + } + } +} diff --git a/src/SMAPI/Events/TimeEvents.cs b/src/SMAPI/Events/TimeEvents.cs index f769fd08..311ffe9e 100644 --- a/src/SMAPI/Events/TimeEvents.cs +++ b/src/SMAPI/Events/TimeEvents.cs @@ -19,15 +19,15 @@ namespace StardewModdingAPI.Events /// <summary>Raised after the game begins a new day, including when loading a save.</summary> public static event EventHandler AfterDayStarted { - add => TimeEvents.EventManager.Time_AfterDayStarted.Add(value); - remove => TimeEvents.EventManager.Time_AfterDayStarted.Remove(value); + add => TimeEvents.EventManager.Legacy_AfterDayStarted.Add(value); + remove => TimeEvents.EventManager.Legacy_AfterDayStarted.Remove(value); } /// <summary>Raised after the in-game clock changes.</summary> public static event EventHandler<EventArgsIntChanged> TimeOfDayChanged { - add => TimeEvents.EventManager.Time_TimeOfDayChanged.Add(value); - remove => TimeEvents.EventManager.Time_TimeOfDayChanged.Remove(value); + add => TimeEvents.EventManager.Legacy_TimeOfDayChanged.Add(value); + remove => TimeEvents.EventManager.Legacy_TimeOfDayChanged.Remove(value); } diff --git a/src/SMAPI/Events/UnvalidatedUpdateTickedEventArgs.cs b/src/SMAPI/Events/UnvalidatedUpdateTickedEventArgs.cs new file mode 100644 index 00000000..5638bdb7 --- /dev/null +++ b/src/SMAPI/Events/UnvalidatedUpdateTickedEventArgs.cs @@ -0,0 +1,36 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="ISpecialisedEvents.UnvalidatedUpdateTicked"/> event.</summary> + public class UnvalidatedUpdateTickedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The number of ticks elapsed since the game started, including the current tick.</summary> + public uint Ticks { get; } + + /// <summary>Whether <see cref="Ticks"/> is a multiple of 60, which happens approximately once per second.</summary> + public bool IsOneSecond { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="ticks">The number of ticks elapsed since the game started, including the current tick.</param> + public UnvalidatedUpdateTickedEventArgs(uint ticks) + { + this.Ticks = ticks; + this.IsOneSecond = this.IsMultipleOf(60); + } + + /// <summary>Get whether <see cref="Ticks"/> is a multiple of the given <paramref name="number"/>. This is mainly useful if you want to run logic intermittently (e.g. <code>e.IsMultipleOf(30)</code> for every half-second).</summary> + /// <param name="number">The factor to check.</param> + public bool IsMultipleOf(uint number) + { + return this.Ticks % number == 0; + } + } +} diff --git a/src/SMAPI/Events/UnvalidatedUpdateTickingEventArgs.cs b/src/SMAPI/Events/UnvalidatedUpdateTickingEventArgs.cs new file mode 100644 index 00000000..ebadbb99 --- /dev/null +++ b/src/SMAPI/Events/UnvalidatedUpdateTickingEventArgs.cs @@ -0,0 +1,36 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="ISpecialisedEvents.UnvalidatedUpdateTicking"/> event.</summary> + public class UnvalidatedUpdateTickingEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The number of ticks elapsed since the game started, including the current tick.</summary> + public uint Ticks { get; } + + /// <summary>Whether <see cref="Ticks"/> is a multiple of 60, which happens approximately once per second.</summary> + public bool IsOneSecond { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="ticks">The number of ticks elapsed since the game started, including the current tick.</param> + public UnvalidatedUpdateTickingEventArgs(uint ticks) + { + this.Ticks = ticks; + this.IsOneSecond = this.IsMultipleOf(60); + } + + /// <summary>Get whether <see cref="Ticks"/> is a multiple of the given <paramref name="number"/>. This is mainly useful if you want to run logic intermittently (e.g. <code>e.IsMultipleOf(30)</code> for every half-second).</summary> + /// <param name="number">The factor to check.</param> + public bool IsMultipleOf(uint number) + { + return this.Ticks % number == 0; + } + } +} diff --git a/src/SMAPI/Events/GameLoopUpdatedEventArgs.cs b/src/SMAPI/Events/UpdateTickedEventArgs.cs index 3ad34b69..56912643 100644 --- a/src/SMAPI/Events/GameLoopUpdatedEventArgs.cs +++ b/src/SMAPI/Events/UpdateTickedEventArgs.cs @@ -2,8 +2,8 @@ using System; namespace StardewModdingAPI.Events { - /// <summary>Event arguments for an <see cref="IGameLoopEvents.Updated"/> event.</summary> - public class GameLoopUpdatedEventArgs : EventArgs + /// <summary>Event arguments for an <see cref="IGameLoopEvents.UpdateTicked"/> event.</summary> + public class UpdateTickedEventArgs : EventArgs { /********* ** Accessors @@ -20,7 +20,7 @@ namespace StardewModdingAPI.Events *********/ /// <summary>Construct an instance.</summary> /// <param name="ticks">The number of ticks elapsed since the game started, including the current tick.</param> - public GameLoopUpdatedEventArgs(uint ticks) + public UpdateTickedEventArgs(uint ticks) { this.Ticks = ticks; this.IsOneSecond = this.IsMultipleOf(60); diff --git a/src/SMAPI/Events/GameLoopUpdatingEventArgs.cs b/src/SMAPI/Events/UpdateTickingEventArgs.cs index d6a8b5c2..5998fd9b 100644 --- a/src/SMAPI/Events/GameLoopUpdatingEventArgs.cs +++ b/src/SMAPI/Events/UpdateTickingEventArgs.cs @@ -2,8 +2,8 @@ using System; namespace StardewModdingAPI.Events { - /// <summary>Event arguments for an <see cref="IGameLoopEvents.Updating"/> event.</summary> - public class GameLoopUpdatingEventArgs : EventArgs + /// <summary>Event arguments for an <see cref="IGameLoopEvents.UpdateTicking"/> event.</summary> + public class UpdateTickingEventArgs : EventArgs { /********* ** Accessors @@ -20,7 +20,7 @@ namespace StardewModdingAPI.Events *********/ /// <summary>Construct an instance.</summary> /// <param name="ticks">The number of ticks elapsed since the game started, including the current tick.</param> - public GameLoopUpdatingEventArgs(uint ticks) + public UpdateTickingEventArgs(uint ticks) { this.Ticks = ticks; this.IsOneSecond = this.IsMultipleOf(60); diff --git a/src/SMAPI/Events/WarpedEventArgs.cs b/src/SMAPI/Events/WarpedEventArgs.cs new file mode 100644 index 00000000..1b1c7381 --- /dev/null +++ b/src/SMAPI/Events/WarpedEventArgs.cs @@ -0,0 +1,37 @@ +using System; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IPlayerEvents.Warped"/> event.</summary> + public class WarpedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The player who warped to a new location.</summary> + public Farmer Player { get; } + + /// <summary>The player's previous location.</summary> + public GameLocation OldLocation { get; } + + /// <summary>The player's current location.</summary> + public GameLocation NewLocation { get; } + + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="player">The player who warped to a new location.</param> + /// <param name="oldLocation">The player's previous location.</param> + /// <param name="newLocation">The player's current location.</param> + public WarpedEventArgs(Farmer player, GameLocation oldLocation, GameLocation newLocation) + { + this.Player = player; + this.NewLocation = newLocation; + this.OldLocation = oldLocation; + } + } +} diff --git a/src/SMAPI/Events/WindowResizedEventArgs.cs b/src/SMAPI/Events/WindowResizedEventArgs.cs new file mode 100644 index 00000000..a990ba9d --- /dev/null +++ b/src/SMAPI/Events/WindowResizedEventArgs.cs @@ -0,0 +1,31 @@ +using System; +using Microsoft.Xna.Framework; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IDisplayEvents.WindowResized"/> event.</summary> + public class WindowResizedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The previous window size.</summary> + public Point OldSize { get; } + + /// <summary>The current window size.</summary> + public Point NewSize { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="oldSize">The previous window size.</param> + /// <param name="newSize">The current window size.</param> + public WindowResizedEventArgs(Point oldSize, Point newSize) + { + this.OldSize = oldSize; + this.NewSize = newSize; + } + } +} diff --git a/src/SMAPI/Framework/Command.cs b/src/SMAPI/Framework/Command.cs index 943e018d..8c9df47d 100644 --- a/src/SMAPI/Framework/Command.cs +++ b/src/SMAPI/Framework/Command.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace StardewModdingAPI.Framework { @@ -8,8 +8,8 @@ namespace StardewModdingAPI.Framework /********* ** Accessor *********/ - /// <summary>The friendly name for the mod that registered the command.</summary> - public string ModName { get; } + /// <summary>The mod that registered the command (or <c>null</c> if registered by SMAPI).</summary> + public IModMetadata Mod { get; } /// <summary>The command name, which the user must type to trigger it.</summary> public string Name { get; } @@ -25,13 +25,13 @@ namespace StardewModdingAPI.Framework ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="modName">The friendly name for the mod that registered the command.</param> + /// <param name="mod">The mod that registered the command (or <c>null</c> if registered by SMAPI).</param> /// <param name="name">The command name, which the user must type to trigger it.</param> /// <param name="documentation">The human-readable documentation shown when the player runs the built-in 'help' command.</param> /// <param name="callback">The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user.</param> - public Command(string modName, string name, string documentation, Action<string, string[]> callback) + public Command(IModMetadata mod, string name, string documentation, Action<string, string[]> callback) { - this.ModName = modName; + this.Mod = mod; this.Name = name; this.Documentation = documentation; this.Callback = callback; diff --git a/src/SMAPI/Framework/CommandManager.cs b/src/SMAPI/Framework/CommandManager.cs index f9651ed9..aabe99c3 100644 --- a/src/SMAPI/Framework/CommandManager.cs +++ b/src/SMAPI/Framework/CommandManager.cs @@ -19,7 +19,7 @@ namespace StardewModdingAPI.Framework ** Public methods *********/ /// <summary>Add a console command.</summary> - /// <param name="modName">The friendly mod name for this instance.</param> + /// <param name="mod">The mod adding the command (or <c>null</c> for a SMAPI command).</param> /// <param name="name">The command name, which the user must type to trigger it.</param> /// <param name="documentation">The human-readable documentation shown when the player runs the built-in 'help' command.</param> /// <param name="callback">The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user.</param> @@ -27,7 +27,7 @@ namespace StardewModdingAPI.Framework /// <exception cref="ArgumentNullException">The <paramref name="name"/> or <paramref name="callback"/> is null or empty.</exception> /// <exception cref="FormatException">The <paramref name="name"/> is not a valid format.</exception> /// <exception cref="ArgumentException">There's already a command with that name.</exception> - public void Add(string modName, string name, string documentation, Action<string, string[]> callback, bool allowNullCallback = false) + public void Add(IModMetadata mod, string name, string documentation, Action<string, string[]> callback, bool allowNullCallback = false) { name = this.GetNormalisedName(name); @@ -44,7 +44,7 @@ namespace StardewModdingAPI.Framework throw new ArgumentException(nameof(callback), $"Can't register the '{name}' command because there's already a command with that name."); // add command - this.Commands.Add(name, new Command(modName, name, documentation, callback)); + this.Commands.Add(name, new Command(mod, name, documentation, callback)); } /// <summary>Get a command by its unique name.</summary> @@ -65,19 +65,30 @@ namespace StardewModdingAPI.Framework .OrderBy(p => p.Name); } - /// <summary>Trigger a command.</summary> - /// <param name="input">The raw command input.</param> - /// <returns>Returns whether a matching command was triggered.</returns> - public bool Trigger(string input) + /// <summary>Try to parse a raw line of user input into an executable command.</summary> + /// <param name="input">The raw user input.</param> + /// <param name="name">The parsed command name.</param> + /// <param name="args">The parsed command arguments.</param> + /// <param name="command">The command which can handle the input.</param> + /// <returns>Returns true if the input was successfully parsed and matched to a command; else false.</returns> + public bool TryParse(string input, out string name, out string[] args, out Command command) { + // ignore if blank if (string.IsNullOrWhiteSpace(input)) + { + name = null; + args = null; + command = null; return false; + } - string[] args = this.ParseArgs(input); - string name = args[0]; + // parse input + args = this.ParseArgs(input); + name = this.GetNormalisedName(args[0]); args = args.Skip(1).ToArray(); - return this.Trigger(name, args); + // get command + return this.Commands.TryGetValue(name, out command); } /// <summary>Trigger a command.</summary> diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs index 5c7b87de..f970762a 100644 --- a/src/SMAPI/Framework/Content/AssetDataForImage.cs +++ b/src/SMAPI/Framework/Content/AssetDataForImage.cs @@ -8,6 +8,14 @@ namespace StardewModdingAPI.Framework.Content internal class AssetDataForImage : AssetData<Texture2D>, IAssetDataForImage { /********* + ** Properties + *********/ + /// <summary>The minimum value to consider non-transparent.</summary> + /// <remarks>On Linux/Mac, fully transparent pixels may have an alpha up to 4 for some reason.</remarks> + private const byte MinOpacity = 5; + + + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> @@ -53,13 +61,40 @@ namespace StardewModdingAPI.Framework.Content // merge data in overlay mode if (patchMode == PatchMode.Overlay) { + // get target data + Color[] targetData = new Color[pixelCount]; + target.GetData(0, targetArea, targetData, 0, pixelCount); + + // merge pixels Color[] newData = new Color[targetArea.Value.Width * targetArea.Value.Height]; target.GetData(0, targetArea, newData, 0, newData.Length); for (int i = 0; i < sourceData.Length; i++) { - Color pixel = sourceData[i]; - if (pixel.A > 4) // not transparent (note: on Linux/Mac, fully transparent pixels may have an alpha up to 4 for some reason) - newData[i] = pixel; + Color above = sourceData[i]; + Color below = targetData[i]; + + // shortcut transparency + if (above.A < AssetDataForImage.MinOpacity) + continue; + if (below.A < AssetDataForImage.MinOpacity) + { + newData[i] = above; + continue; + } + + // merge pixels + // This performs a conventional alpha blend for the pixels, which are already + // premultiplied by the content pipeline. The formula is derived from + // https://blogs.msdn.microsoft.com/shawnhar/2009/11/06/premultiplied-alpha/. + // Note: don't use named arguments here since they're different between + // Linux/Mac and Windows. + float alphaBelow = 1 - (above.A / 255f); + newData[i] = new Color( + (int)(above.R + (below.R * alphaBelow)), // r + (int)(above.G + (below.G * alphaBelow)), // g + (int)(above.B + (below.B * alphaBelow)), // b + Math.Max(above.A, below.A) // a + ); } sourceData = newData; } diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index 24ce69ea..ed76a925 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -165,63 +165,25 @@ namespace StardewModdingAPI.Framework.ContentManagers return file; } - /// <summary>Premultiply a texture's alpha values to avoid transparency issues in the game. This is only possible if the game isn't currently drawing.</summary> + /// <summary>Premultiply a texture's alpha values to avoid transparency issues in the game.</summary> /// <param name="texture">The texture to premultiply.</param> /// <returns>Returns a premultiplied texture.</returns> - /// <remarks>Based on <a href="https://gist.github.com/Layoric/6255384">code by Layoric</a>.</remarks> + /// <remarks>Based on <a href="https://gamedev.stackexchange.com/a/26037">code by David Gouveia</a>.</remarks> private Texture2D PremultiplyTransparency(Texture2D texture) { - // validate - if (Context.IsInDrawLoop) - throw new NotSupportedException("Can't load a PNG file while the game is drawing to the screen. Make sure you load content outside the draw loop."); - - // process texture - SpriteBatch spriteBatch = Game1.spriteBatch; - GraphicsDevice gpu = Game1.graphics.GraphicsDevice; - using (RenderTarget2D renderTarget = new RenderTarget2D(Game1.graphics.GraphicsDevice, texture.Width, texture.Height)) - { - // create blank render target to premultiply - gpu.SetRenderTarget(renderTarget); - gpu.Clear(Color.Black); - - // multiply each color by the source alpha, and write just the color values into the final texture - spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState - { - ColorDestinationBlend = Blend.Zero, - ColorWriteChannels = ColorWriteChannels.Red | ColorWriteChannels.Green | ColorWriteChannels.Blue, - AlphaDestinationBlend = Blend.Zero, - AlphaSourceBlend = Blend.SourceAlpha, - ColorSourceBlend = Blend.SourceAlpha - }); - spriteBatch.Draw(texture, texture.Bounds, Color.White); - spriteBatch.End(); - - // copy the alpha values from the source texture into the final one without multiplying them - spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState - { - ColorWriteChannels = ColorWriteChannels.Alpha, - AlphaDestinationBlend = Blend.Zero, - ColorDestinationBlend = Blend.Zero, - AlphaSourceBlend = Blend.One, - ColorSourceBlend = Blend.One - }); - spriteBatch.Draw(texture, texture.Bounds, Color.White); - spriteBatch.End(); - - // release GPU - gpu.SetRenderTarget(null); - - // extract premultiplied data - Color[] data = new Color[texture.Width * texture.Height]; - renderTarget.GetData(data); - - // unset texture from GPU to regain control - gpu.Textures[0] = null; - - // update texture with premultiplied data - texture.SetData(data); - } - + // Textures loaded by Texture2D.FromStream are already premultiplied on Linux/Mac, even + // though the XNA documentation explicitly says otherwise. That's a glitch in MonoGame + // fixed in newer versions, but the game uses a bundled version that will always be + // affected. See https://github.com/MonoGame/MonoGame/issues/4820 for more info. + if (Constants.TargetPlatform != GamePlatform.Windows) + return texture; + + // premultiply pixels + Color[] data = new Color[texture.Width * texture.Height]; + texture.GetData(data); + for (int i = 0; i < data.Length; i++) + data[i] = Color.FromNonPremultiplied(data[i].ToVector4()); + texture.SetData(data); return texture; } } diff --git a/src/SMAPI/Framework/ContentPack.cs b/src/SMAPI/Framework/ContentPack.cs index 62d8b80d..49285388 100644 --- a/src/SMAPI/Framework/ContentPack.cs +++ b/src/SMAPI/Framework/ContentPack.cs @@ -51,14 +51,32 @@ namespace StardewModdingAPI.Framework /// <typeparam name="TModel">The model type.</typeparam> /// <param name="path">The file path relative to the contnet directory.</param> /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> public TModel ReadJsonFile<TModel>(string path) where TModel : class { + if (!PathUtilities.IsSafeRelativePath(path)) + throw new InvalidOperationException($"You must call {nameof(IContentPack)}.{nameof(this.ReadJsonFile)} with a relative path."); + path = Path.Combine(this.DirectoryPath, PathUtilities.NormalisePathSeparators(path)); return this.JsonHelper.ReadJsonFileIfExists(path, out TModel model) ? model : null; } + /// <summary>Save data to a JSON file in the content pack's folder.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="path">The file path relative to the mod folder.</param> + /// <param name="data">The arbitrary data to save.</param> + /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> + public void WriteJsonFile<TModel>(string path, TModel data) where TModel : class + { + if (!PathUtilities.IsSafeRelativePath(path)) + throw new InvalidOperationException($"You must call {nameof(IContentPack)}.{nameof(this.WriteJsonFile)} with a relative path."); + + path = Path.Combine(this.DirectoryPath, PathUtilities.NormalisePathSeparators(path)); + this.JsonHelper.WriteJsonFile(path, data); + } + /// <summary>Load content from the content pack folder (if not already cached), and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary> /// <typeparam name="T">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, and dictionaries; other types may be supported by the game's content pipeline.</typeparam> /// <param name="key">The local path to a content file relative to the content pack folder.</param> diff --git a/src/SMAPI/Framework/DeprecationManager.cs b/src/SMAPI/Framework/DeprecationManager.cs index 7a824a05..0fde67ee 100644 --- a/src/SMAPI/Framework/DeprecationManager.cs +++ b/src/SMAPI/Framework/DeprecationManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; namespace StardewModdingAPI.Framework { @@ -18,6 +19,9 @@ namespace StardewModdingAPI.Framework /// <summary>Tracks the installed mods.</summary> private readonly ModRegistry ModRegistry; + /// <summary>The queued deprecation warnings to display.</summary> + private readonly IList<DeprecationWarning> QueuedWarnings = new List<DeprecationWarning>(); + /********* ** Public methods @@ -51,29 +55,40 @@ namespace StardewModdingAPI.Framework if (!this.MarkWarned(source ?? "<unknown>", nounPhrase, version)) return; - // build message - string message = $"{source ?? "An unknown mod"} uses deprecated code ({nounPhrase} is deprecated since SMAPI {version})."; - if (source == null) - message += $"{Environment.NewLine}{Environment.StackTrace}"; + // queue warning + this.QueuedWarnings.Add(new DeprecationWarning(source, nounPhrase, version, severity)); + } - // log message - switch (severity) + /// <summary>Print any queued messages.</summary> + public void PrintQueued() + { + foreach (DeprecationWarning warning in this.QueuedWarnings.OrderBy(p => p.ModName).ThenBy(p => p.NounPhrase)) { - case DeprecationLevel.Notice: - this.Monitor.Log(message, LogLevel.Trace); - break; + // build message + string message = $"{warning.ModName ?? "An unknown mod"} uses deprecated code ({warning.NounPhrase} is deprecated since SMAPI {warning.Version})."; + if (warning.ModName == null) + message += $"{Environment.NewLine}{Environment.StackTrace}"; + + // log message + switch (warning.Level) + { + case DeprecationLevel.Notice: + this.Monitor.Log(message, LogLevel.Trace); + break; - case DeprecationLevel.Info: - this.Monitor.Log(message, LogLevel.Debug); - break; + case DeprecationLevel.Info: + this.Monitor.Log(message, LogLevel.Debug); + break; - case DeprecationLevel.PendingRemoval: - this.Monitor.Log(message, LogLevel.Warn); - break; + case DeprecationLevel.PendingRemoval: + this.Monitor.Log(message, LogLevel.Warn); + break; - default: - throw new NotSupportedException($"Unknown deprecation level '{severity}'"); + default: + throw new NotSupportedException($"Unknown deprecation level '{warning.Level}'."); + } } + this.QueuedWarnings.Clear(); } /// <summary>Mark a deprecation warning as already logged.</summary> diff --git a/src/SMAPI/Framework/DeprecationWarning.cs b/src/SMAPI/Framework/DeprecationWarning.cs new file mode 100644 index 00000000..25415012 --- /dev/null +++ b/src/SMAPI/Framework/DeprecationWarning.cs @@ -0,0 +1,38 @@ +namespace StardewModdingAPI.Framework +{ + /// <summary>A deprecation warning for a mod.</summary> + internal class DeprecationWarning + { + /********* + ** Accessors + *********/ + /// <summary>The affected mod's display name.</summary> + public string ModName { get; } + + /// <summary>A noun phrase describing what is deprecated.</summary> + public string NounPhrase { get; } + + /// <summary>The SMAPI version which deprecated it.</summary> + public string Version { get; } + + /// <summary>The deprecation level for the affected code.</summary> + public DeprecationLevel Level { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="modName">The affected mod's display name.</param> + /// <param name="nounPhrase">A noun phrase describing what is deprecated.</param> + /// <param name="version">The SMAPI version which deprecated it.</param> + /// <param name="level">The deprecation level for the affected code.</param> + public DeprecationWarning(string modName, string nounPhrase, string version, DeprecationLevel level) + { + this.ModName = modName; + this.NounPhrase = nounPhrase; + this.Version = version; + this.Level = level; + } + } +} diff --git a/src/SMAPI/Framework/Events/EventManager.cs b/src/SMAPI/Framework/Events/EventManager.cs index 168ddde0..b9d1c453 100644 --- a/src/SMAPI/Framework/Events/EventManager.cs +++ b/src/SMAPI/Framework/Events/EventManager.cs @@ -12,55 +12,148 @@ namespace StardewModdingAPI.Framework.Events ** Events (new) *********/ /**** + ** Display + ****/ + /// <summary>Raised after a game menu is opened, closed, or replaced.</summary> + public readonly ManagedEvent<MenuChangedEventArgs> MenuChanged; + + /// <summary>Raised before the game draws anything to the screen in a draw tick, as soon as the sprite batch is opened. The sprite batch may be closed and reopened multiple times after this event is called, but it's only raised once per draw tick. This event isn't useful for drawing to the screen, since the game will draw over it.</summary> + public readonly ManagedEvent<RenderingEventArgs> Rendering; + + /// <summary>Raised after the game draws to the sprite patch in a draw tick, just before the final sprite batch is rendered to the screen. Since the game may open/close the sprite batch multiple times in a draw tick, the sprite batch may not contain everything being drawn and some things may already be rendered to the screen. Content drawn to the sprite batch at this point will be drawn over all vanilla content (including menus, HUD, and cursor).</summary> + public readonly ManagedEvent<RenderedEventArgs> Rendered; + + /// <summary>Raised before the game world is drawn to the screen.</summary> + public readonly ManagedEvent<RenderingWorldEventArgs> RenderingWorld; + + /// <summary>Raised after the game world is drawn to the sprite patch, before it's rendered to the screen.</summary> + public readonly ManagedEvent<RenderedWorldEventArgs> RenderedWorld; + + /// <summary>When a menu is open (<see cref="StardewValley.Game1.activeClickableMenu"/> isn't null), raised before that menu is drawn to the screen.</summary> + public readonly ManagedEvent<RenderingActiveMenuEventArgs> RenderingActiveMenu; + + /// <summary>When a menu is open (<see cref="StardewValley.Game1.activeClickableMenu"/> isn't null), raised after that menu is drawn to the sprite batch but before it's rendered to the screen.</summary> + public readonly ManagedEvent<RenderedActiveMenuEventArgs> RenderedActiveMenu; + + /// <summary>Raised before drawing the HUD (item toolbar, clock, etc) to the screen.</summary> + public readonly ManagedEvent<RenderingHudEventArgs> RenderingHud; + + /// <summary>Raised after drawing the HUD (item toolbar, clock, etc) to the sprite batch, but before it's rendered to the screen.</summary> + public readonly ManagedEvent<RenderedHudEventArgs> RenderedHud; + + /// <summary>Raised after the game window is resized.</summary> + public readonly ManagedEvent<WindowResizedEventArgs> WindowResized; + + /**** ** Game loop ****/ /// <summary>Raised after the game is launched, right before the first update tick.</summary> - public readonly ManagedEvent<GameLoopLaunchedEventArgs> GameLoop_Launched; + public readonly ManagedEvent<GameLaunchedEventArgs> GameLaunched; /// <summary>Raised before the game performs its overall update tick (≈60 times per second).</summary> - public readonly ManagedEvent<GameLoopUpdatingEventArgs> GameLoop_Updating; + public readonly ManagedEvent<UpdateTickingEventArgs> UpdateTicking; /// <summary>Raised after the game performs its overall update tick (≈60 times per second).</summary> - public readonly ManagedEvent<GameLoopUpdatedEventArgs> GameLoop_Updated; + public readonly ManagedEvent<UpdateTickedEventArgs> UpdateTicked; + + /// <summary>Raised before the game creates the save file.</summary> + public readonly ManagedEvent<SaveCreatingEventArgs> SaveCreating; + + /// <summary>Raised after the game finishes creating the save file.</summary> + public readonly ManagedEvent<SaveCreatedEventArgs> SaveCreated; + + /// <summary>Raised before the game begins writes data to the save file (except the initial save creation).</summary> + public readonly ManagedEvent<SavingEventArgs> Saving; + + /// <summary>Raised after the game finishes writing data to the save file (except the initial save creation).</summary> + public readonly ManagedEvent<SavedEventArgs> Saved; + + /// <summary>Raised after the player loads a save slot.</summary> + public readonly ManagedEvent<SaveLoadedEventArgs> SaveLoaded; + + /// <summary>Raised after the game begins a new day, including when loading a save.</summary> + public readonly ManagedEvent<DayStartedEventArgs> DayStarted; + + /// <summary>Raised before the game ends the current day. This happens before it starts setting up the next day and before <see cref="Saving"/>.</summary> + public readonly ManagedEvent<DayEndingEventArgs> DayEnding; + + /// <summary>Raised after the in-game clock time changes.</summary> + public readonly ManagedEvent<TimeChangedEventArgs> TimeChanged; + + /// <summary>Raised after the game returns to the title screen.</summary> + public readonly ManagedEvent<ReturnedToTitleEventArgs> ReturnedToTitle; /**** ** Input ****/ /// <summary>Raised after the player presses a button on the keyboard, controller, or mouse.</summary> - public readonly ManagedEvent<InputButtonPressedEventArgs> Input_ButtonPressed; + public readonly ManagedEvent<ButtonPressedEventArgs> ButtonPressed; /// <summary>Raised after the player released a button on the keyboard, controller, or mouse.</summary> - public readonly ManagedEvent<InputButtonReleasedEventArgs> Input_ButtonReleased; + public readonly ManagedEvent<ButtonReleasedEventArgs> ButtonReleased; /// <summary>Raised after the player moves the in-game cursor.</summary> - public readonly ManagedEvent<InputCursorMovedEventArgs> Input_CursorMoved; + public readonly ManagedEvent<CursorMovedEventArgs> CursorMoved; /// <summary>Raised after the player scrolls the mouse wheel.</summary> - public readonly ManagedEvent<InputMouseWheelScrolledEventArgs> Input_MouseWheelScrolled; + public readonly ManagedEvent<MouseWheelScrolledEventArgs> MouseWheelScrolled; + + /**** + ** Multiplayer + ****/ + /// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection, so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary> + public readonly ManagedEvent<PeerContextReceivedEventArgs> PeerContextReceived; + + /// <summary>Raised after a mod message is received over the network.</summary> + public readonly ManagedEvent<ModMessageReceivedEventArgs> ModMessageReceived; + + /// <summary>Raised after the connection with a peer is severed.</summary> + public readonly ManagedEvent<PeerDisconnectedEventArgs> PeerDisconnected; + + /**** + ** Player + ****/ + /// <summary>Raised after items are added or removed to a player's inventory.</summary> + public readonly ManagedEvent<InventoryChangedEventArgs> InventoryChanged; + + /// <summary>Raised after a player skill level changes. This happens as soon as they level up, not when the game notifies the player after their character goes to bed.</summary> + public readonly ManagedEvent<LevelChangedEventArgs> LevelChanged; + + /// <summary>Raised after a player warps to a new location.</summary> + public readonly ManagedEvent<WarpedEventArgs> Warped; /**** ** World ****/ /// <summary>Raised after a game location is added or removed.</summary> - public readonly ManagedEvent<WorldLocationListChangedEventArgs> World_LocationListChanged; + public readonly ManagedEvent<LocationListChangedEventArgs> LocationListChanged; /// <summary>Raised after buildings are added or removed in a location.</summary> - public readonly ManagedEvent<WorldBuildingListChangedEventArgs> World_BuildingListChanged; + public readonly ManagedEvent<BuildingListChangedEventArgs> BuildingListChanged; /// <summary>Raised after debris are added or removed in a location.</summary> - public readonly ManagedEvent<WorldDebrisListChangedEventArgs> World_DebrisListChanged; + public readonly ManagedEvent<DebrisListChangedEventArgs> DebrisListChanged; /// <summary>Raised after large terrain features (like bushes) are added or removed in a location.</summary> - public readonly ManagedEvent<WorldLargeTerrainFeatureListChangedEventArgs> World_LargeTerrainFeatureListChanged; + public readonly ManagedEvent<LargeTerrainFeatureListChangedEventArgs> LargeTerrainFeatureListChanged; /// <summary>Raised after NPCs are added or removed in a location.</summary> - public readonly ManagedEvent<WorldNpcListChangedEventArgs> World_NpcListChanged; + public readonly ManagedEvent<NpcListChangedEventArgs> NpcListChanged; /// <summary>Raised after objects are added or removed in a location.</summary> - public readonly ManagedEvent<WorldObjectListChangedEventArgs> World_ObjectListChanged; + public readonly ManagedEvent<ObjectListChangedEventArgs> ObjectListChanged; /// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary> - public readonly ManagedEvent<WorldTerrainFeatureListChangedEventArgs> World_TerrainFeatureListChanged; + public readonly ManagedEvent<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged; + + /**** + ** Specialised + ****/ + /// <summary>Raised before the game performs its overall update tick (≈60 times per second). See notes on <see cref="ISpecialisedEvents.UnvalidatedUpdateTicking"/>.</summary> + public readonly ManagedEvent<UnvalidatedUpdateTickingEventArgs> UnvalidatedUpdateTicking; + + /// <summary>Raised after the game performs its overall update tick (≈60 times per second). See notes on <see cref="ISpecialisedEvents.UnvalidatedUpdateTicked"/>.</summary> + public readonly ManagedEvent<UnvalidatedUpdateTickedEventArgs> UnvalidatedUpdateTicked; /********* @@ -70,185 +163,185 @@ namespace StardewModdingAPI.Framework.Events ** ContentEvents ****/ /// <summary>Raised after the content language changes.</summary> - public readonly ManagedEvent<EventArgsValueChanged<string>> Content_LocaleChanged; + public readonly ManagedEvent<EventArgsValueChanged<string>> Legacy_LocaleChanged; /**** ** ControlEvents ****/ /// <summary>Raised when the <see cref="KeyboardState"/> changes. That happens when the player presses or releases a key.</summary> - public readonly ManagedEvent<EventArgsKeyboardStateChanged> Legacy_Control_KeyboardChanged; + public readonly ManagedEvent<EventArgsKeyboardStateChanged> Legacy_KeyboardChanged; /// <summary>Raised after the player presses a keyboard key.</summary> - public readonly ManagedEvent<EventArgsKeyPressed> Legacy_Control_KeyPressed; + public readonly ManagedEvent<EventArgsKeyPressed> Legacy_KeyPressed; /// <summary>Raised after the player releases a keyboard key.</summary> - public readonly ManagedEvent<EventArgsKeyPressed> Legacy_Control_KeyReleased; + public readonly ManagedEvent<EventArgsKeyPressed> Legacy_KeyReleased; /// <summary>Raised when the <see cref="MouseState"/> changes. That happens when the player moves the mouse, scrolls the mouse wheel, or presses/releases a button.</summary> - public readonly ManagedEvent<EventArgsMouseStateChanged> Legacy_Control_MouseChanged; + public readonly ManagedEvent<EventArgsMouseStateChanged> Legacy_MouseChanged; /// <summary>The player pressed a controller button. This event isn't raised for trigger buttons.</summary> - public readonly ManagedEvent<EventArgsControllerButtonPressed> Legacy_Control_ControllerButtonPressed; + public readonly ManagedEvent<EventArgsControllerButtonPressed> Legacy_ControllerButtonPressed; /// <summary>The player released a controller button. This event isn't raised for trigger buttons.</summary> - public readonly ManagedEvent<EventArgsControllerButtonReleased> Legacy_Control_ControllerButtonReleased; + public readonly ManagedEvent<EventArgsControllerButtonReleased> Legacy_ControllerButtonReleased; /// <summary>The player pressed a controller trigger button.</summary> - public readonly ManagedEvent<EventArgsControllerTriggerPressed> Legacy_Control_ControllerTriggerPressed; + public readonly ManagedEvent<EventArgsControllerTriggerPressed> Legacy_ControllerTriggerPressed; /// <summary>The player released a controller trigger button.</summary> - public readonly ManagedEvent<EventArgsControllerTriggerReleased> Legacy_Control_ControllerTriggerReleased; + public readonly ManagedEvent<EventArgsControllerTriggerReleased> Legacy_ControllerTriggerReleased; /**** ** GameEvents ****/ /// <summary>Raised once after the game initialises and all <see cref="IMod.Entry"/> methods have been called.</summary> - public readonly ManagedEvent Game_FirstUpdateTick; + public readonly ManagedEvent Legacy_FirstUpdateTick; /// <summary>Raised when the game updates its state (≈60 times per second).</summary> - public readonly ManagedEvent Game_UpdateTick; + public readonly ManagedEvent Legacy_UpdateTick; /// <summary>Raised every other tick (≈30 times per second).</summary> - public readonly ManagedEvent Game_SecondUpdateTick; + public readonly ManagedEvent Legacy_SecondUpdateTick; /// <summary>Raised every fourth tick (≈15 times per second).</summary> - public readonly ManagedEvent Game_FourthUpdateTick; + public readonly ManagedEvent Legacy_FourthUpdateTick; /// <summary>Raised every eighth tick (≈8 times per second).</summary> - public readonly ManagedEvent Game_EighthUpdateTick; + public readonly ManagedEvent Legacy_EighthUpdateTick; /// <summary>Raised every 15th tick (≈4 times per second).</summary> - public readonly ManagedEvent Game_QuarterSecondTick; + public readonly ManagedEvent Legacy_QuarterSecondTick; /// <summary>Raised every 30th tick (≈twice per second).</summary> - public readonly ManagedEvent Game_HalfSecondTick; + public readonly ManagedEvent Legacy_HalfSecondTick; /// <summary>Raised every 60th tick (≈once per second).</summary> - public readonly ManagedEvent Game_OneSecondTick; + public readonly ManagedEvent Legacy_OneSecondTick; /**** ** GraphicsEvents ****/ /// <summary>Raised after the game window is resized.</summary> - public readonly ManagedEvent Graphics_Resize; + public readonly ManagedEvent Legacy_Resize; /// <summary>Raised before drawing the world to the screen.</summary> - public readonly ManagedEvent Graphics_OnPreRenderEvent; + public readonly ManagedEvent Legacy_OnPreRenderEvent; /// <summary>Raised after drawing the world to the screen.</summary> - public readonly ManagedEvent Graphics_OnPostRenderEvent; + public readonly ManagedEvent Legacy_OnPostRenderEvent; /// <summary>Raised before drawing the HUD (item toolbar, clock, etc) to the screen. The HUD is available at this point, but not necessarily visible. (For example, the event is raised even if a menu is open.)</summary> - public readonly ManagedEvent Graphics_OnPreRenderHudEvent; + public readonly ManagedEvent Legacy_OnPreRenderHudEvent; /// <summary>Raised after drawing the HUD (item toolbar, clock, etc) to the screen. The HUD is available at this point, but not necessarily visible. (For example, the event is raised even if a menu is open.)</summary> - public readonly ManagedEvent Graphics_OnPostRenderHudEvent; + public readonly ManagedEvent Legacy_OnPostRenderHudEvent; /// <summary>Raised before drawing a menu to the screen during a draw loop. This includes the game's internal menus like the title screen.</summary> - public readonly ManagedEvent Graphics_OnPreRenderGuiEvent; + public readonly ManagedEvent Legacy_OnPreRenderGuiEvent; /// <summary>Raised after drawing a menu to the screen during a draw loop. This includes the game's internal menus like the title screen.</summary> - public readonly ManagedEvent Graphics_OnPostRenderGuiEvent; + public readonly ManagedEvent Legacy_OnPostRenderGuiEvent; /**** ** InputEvents ****/ /// <summary>Raised after the player presses a button on the keyboard, controller, or mouse.</summary> - public readonly ManagedEvent<EventArgsInput> Legacy_Input_ButtonPressed; + public readonly ManagedEvent<EventArgsInput> Legacy_ButtonPressed; /// <summary>Raised after the player releases a keyboard key on the keyboard, controller, or mouse.</summary> - public readonly ManagedEvent<EventArgsInput> Legacy_Input_ButtonReleased; + public readonly ManagedEvent<EventArgsInput> Legacy_ButtonReleased; /**** ** LocationEvents ****/ /// <summary>Raised after a game location is added or removed.</summary> - public readonly ManagedEvent<EventArgsLocationsChanged> Legacy_Location_LocationsChanged; + public readonly ManagedEvent<EventArgsLocationsChanged> Legacy_LocationsChanged; /// <summary>Raised after buildings are added or removed in a location.</summary> - public readonly ManagedEvent<EventArgsLocationBuildingsChanged> Legacy_Location_BuildingsChanged; + public readonly ManagedEvent<EventArgsLocationBuildingsChanged> Legacy_BuildingsChanged; /// <summary>Raised after objects are added or removed in a location.</summary> - public readonly ManagedEvent<EventArgsLocationObjectsChanged> Legacy_Location_ObjectsChanged; + public readonly ManagedEvent<EventArgsLocationObjectsChanged> Legacy_ObjectsChanged; /**** ** MenuEvents ****/ /// <summary>Raised after a game menu is opened or replaced with another menu. This event is not invoked when a menu is closed.</summary> - public readonly ManagedEvent<EventArgsClickableMenuChanged> Menu_Changed; + public readonly ManagedEvent<EventArgsClickableMenuChanged> Legacy_MenuChanged; /// <summary>Raised after a game menu is closed.</summary> - public readonly ManagedEvent<EventArgsClickableMenuClosed> Menu_Closed; + public readonly ManagedEvent<EventArgsClickableMenuClosed> Legacy_MenuClosed; /**** ** MultiplayerEvents ****/ /// <summary>Raised before the game syncs changes from other players.</summary> - public readonly ManagedEvent Multiplayer_BeforeMainSync; + public readonly ManagedEvent Legacy_BeforeMainSync; /// <summary>Raised after the game syncs changes from other players.</summary> - public readonly ManagedEvent Multiplayer_AfterMainSync; + public readonly ManagedEvent Legacy_AfterMainSync; /// <summary>Raised before the game broadcasts changes to other players.</summary> - public readonly ManagedEvent Multiplayer_BeforeMainBroadcast; + public readonly ManagedEvent Legacy_BeforeMainBroadcast; /// <summary>Raised after the game broadcasts changes to other players.</summary> - public readonly ManagedEvent Multiplayer_AfterMainBroadcast; + public readonly ManagedEvent Legacy_AfterMainBroadcast; /**** ** MineEvents ****/ /// <summary>Raised after the player warps to a new level of the mine.</summary> - public readonly ManagedEvent<EventArgsMineLevelChanged> Mine_LevelChanged; + public readonly ManagedEvent<EventArgsMineLevelChanged> Legacy_MineLevelChanged; /**** ** PlayerEvents ****/ /// <summary>Raised after the player's inventory changes in any way (added or removed item, sorted, etc).</summary> - public readonly ManagedEvent<EventArgsInventoryChanged> Player_InventoryChanged; + public readonly ManagedEvent<EventArgsInventoryChanged> Legacy_InventoryChanged; /// <summary> Raised after the player levels up a skill. This happens as soon as they level up, not when the game notifies the player after their character goes to bed.</summary> - public readonly ManagedEvent<EventArgsLevelUp> Player_LeveledUp; + public readonly ManagedEvent<EventArgsLevelUp> Legacy_LeveledUp; /// <summary>Raised after the player warps to a new location.</summary> - public readonly ManagedEvent<EventArgsPlayerWarped> Player_Warped; + public readonly ManagedEvent<EventArgsPlayerWarped> Legacy_PlayerWarped; /**** ** SaveEvents ****/ /// <summary>Raised before the game creates the save file.</summary> - public readonly ManagedEvent Save_BeforeCreate; + public readonly ManagedEvent Legacy_BeforeCreateSave; /// <summary>Raised after the game finishes creating the save file.</summary> - public readonly ManagedEvent Save_AfterCreate; + public readonly ManagedEvent Legacy_AfterCreateSave; /// <summary>Raised before the game begins writes data to the save file.</summary> - public readonly ManagedEvent Save_BeforeSave; + public readonly ManagedEvent Legacy_BeforeSave; /// <summary>Raised after the game finishes writing data to the save file.</summary> - public readonly ManagedEvent Save_AfterSave; + public readonly ManagedEvent Legacy_AfterSave; /// <summary>Raised after the player loads a save slot.</summary> - public readonly ManagedEvent Save_AfterLoad; + public readonly ManagedEvent Legacy_AfterLoad; /// <summary>Raised after the game returns to the title screen.</summary> - public readonly ManagedEvent Save_AfterReturnToTitle; + public readonly ManagedEvent Legacy_AfterReturnToTitle; /**** ** SpecialisedEvents ****/ /// <summary>Raised when the game updates its state (≈60 times per second), regardless of normal SMAPI validation. This event is not thread-safe and may be invoked while game logic is running asynchronously. Changes to game state in this method may crash the game or corrupt an in-progress save. Do not use this event unless you're fully aware of the context in which your code will be run. Mods using this method will trigger a stability warning in the SMAPI console.</summary> - public readonly ManagedEvent Specialised_UnvalidatedUpdateTick; + public readonly ManagedEvent Legacy_UnvalidatedUpdateTick; /**** ** TimeEvents ****/ /// <summary>Raised after the game begins a new day, including when loading a save.</summary> - public readonly ManagedEvent Time_AfterDayStarted; + public readonly ManagedEvent Legacy_AfterDayStarted; /// <summary>Raised after the in-game clock changes.</summary> - public readonly ManagedEvent<EventArgsIntChanged> Time_TimeOfDayChanged; + public readonly ManagedEvent<EventArgsIntChanged> Legacy_TimeOfDayChanged; /********* @@ -264,84 +357,115 @@ namespace StardewModdingAPI.Framework.Events ManagedEvent ManageEvent(string typeName, string eventName) => new ManagedEvent($"{typeName}.{eventName}", monitor, modRegistry); // init events (new) - this.GameLoop_Launched = ManageEventOf<GameLoopLaunchedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.Launched)); - this.GameLoop_Updating = ManageEventOf<GameLoopUpdatingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.Updating)); - this.GameLoop_Updated = ManageEventOf<GameLoopUpdatedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.Updated)); - - this.Input_ButtonPressed = ManageEventOf<InputButtonPressedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonPressed)); - this.Input_ButtonReleased = ManageEventOf<InputButtonReleasedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonReleased)); - this.Input_CursorMoved = ManageEventOf<InputCursorMovedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.CursorMoved)); - this.Input_MouseWheelScrolled = ManageEventOf<InputMouseWheelScrolledEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.MouseWheelScrolled)); - - this.World_BuildingListChanged = ManageEventOf<WorldBuildingListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.LocationListChanged)); - this.World_DebrisListChanged = ManageEventOf<WorldDebrisListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.DebrisListChanged)); - this.World_LargeTerrainFeatureListChanged = ManageEventOf<WorldLargeTerrainFeatureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.LargeTerrainFeatureListChanged)); - this.World_LocationListChanged = ManageEventOf<WorldLocationListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.BuildingListChanged)); - this.World_NpcListChanged = ManageEventOf<WorldNpcListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.NpcListChanged)); - this.World_ObjectListChanged = ManageEventOf<WorldObjectListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.ObjectListChanged)); - this.World_TerrainFeatureListChanged = ManageEventOf<WorldTerrainFeatureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.TerrainFeatureListChanged)); + this.MenuChanged = ManageEventOf<MenuChangedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.MenuChanged)); + this.Rendering = ManageEventOf<RenderingEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendering)); + this.Rendered = ManageEventOf<RenderedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendered)); + this.RenderingWorld = ManageEventOf<RenderingWorldEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingWorld)); + this.RenderedWorld = ManageEventOf<RenderedWorldEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedWorld)); + this.RenderingActiveMenu = ManageEventOf<RenderingActiveMenuEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingActiveMenu)); + this.RenderedActiveMenu = ManageEventOf<RenderedActiveMenuEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedActiveMenu)); + this.RenderingHud = ManageEventOf<RenderingHudEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingHud)); + this.RenderedHud = ManageEventOf<RenderedHudEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedHud)); + this.WindowResized = ManageEventOf<WindowResizedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.WindowResized)); + + this.GameLaunched = ManageEventOf<GameLaunchedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.GameLaunched)); + this.UpdateTicking = ManageEventOf<UpdateTickingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicking)); + this.UpdateTicked = ManageEventOf<UpdateTickedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicked)); + this.SaveCreating = ManageEventOf<SaveCreatingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.SaveCreating)); + this.SaveCreated = ManageEventOf<SaveCreatedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.SaveCreated)); + this.Saving = ManageEventOf<SavingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.Saving)); + this.Saved = ManageEventOf<SavedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.Saved)); + this.SaveLoaded = ManageEventOf<SaveLoadedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.SaveLoaded)); + this.DayStarted = ManageEventOf<DayStartedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.DayStarted)); + this.DayEnding = ManageEventOf<DayEndingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.DayEnding)); + this.TimeChanged = ManageEventOf<TimeChangedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.TimeChanged)); + this.ReturnedToTitle = ManageEventOf<ReturnedToTitleEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.ReturnedToTitle)); + + 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)); + this.MouseWheelScrolled = ManageEventOf<MouseWheelScrolledEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.MouseWheelScrolled)); + + this.PeerContextReceived = ManageEventOf<PeerContextReceivedEventArgs>(nameof(IModEvents.Multiplayer), nameof(IMultiplayerEvents.PeerContextReceived)); + this.ModMessageReceived = ManageEventOf<ModMessageReceivedEventArgs>(nameof(IModEvents.Multiplayer), nameof(IMultiplayerEvents.ModMessageReceived)); + this.PeerDisconnected = ManageEventOf<PeerDisconnectedEventArgs>(nameof(IModEvents.Multiplayer), nameof(IMultiplayerEvents.PeerDisconnected)); + + this.InventoryChanged = ManageEventOf<InventoryChangedEventArgs>(nameof(IModEvents.Player), nameof(IPlayerEvents.InventoryChanged)); + this.LevelChanged = ManageEventOf<LevelChangedEventArgs>(nameof(IModEvents.Player), nameof(IPlayerEvents.LevelChanged)); + this.Warped = ManageEventOf<WarpedEventArgs>(nameof(IModEvents.Player), nameof(IPlayerEvents.Warped)); + + this.BuildingListChanged = ManageEventOf<BuildingListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.LocationListChanged)); + this.DebrisListChanged = ManageEventOf<DebrisListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.DebrisListChanged)); + this.LargeTerrainFeatureListChanged = ManageEventOf<LargeTerrainFeatureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.LargeTerrainFeatureListChanged)); + this.LocationListChanged = ManageEventOf<LocationListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.BuildingListChanged)); + this.NpcListChanged = ManageEventOf<NpcListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.NpcListChanged)); + this.ObjectListChanged = ManageEventOf<ObjectListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.ObjectListChanged)); + this.TerrainFeatureListChanged = ManageEventOf<TerrainFeatureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.TerrainFeatureListChanged)); + + this.UnvalidatedUpdateTicking = ManageEventOf<UnvalidatedUpdateTickingEventArgs>(nameof(IModEvents.Specialised), nameof(ISpecialisedEvents.UnvalidatedUpdateTicking)); + this.UnvalidatedUpdateTicked = ManageEventOf<UnvalidatedUpdateTickedEventArgs>(nameof(IModEvents.Specialised), nameof(ISpecialisedEvents.UnvalidatedUpdateTicked)); // init events (old) - this.Content_LocaleChanged = ManageEventOf<EventArgsValueChanged<string>>(nameof(ContentEvents), nameof(ContentEvents.AfterLocaleChanged)); - - this.Legacy_Control_ControllerButtonPressed = ManageEventOf<EventArgsControllerButtonPressed>(nameof(ControlEvents), nameof(ControlEvents.ControllerButtonPressed)); - this.Legacy_Control_ControllerButtonReleased = ManageEventOf<EventArgsControllerButtonReleased>(nameof(ControlEvents), nameof(ControlEvents.ControllerButtonReleased)); - this.Legacy_Control_ControllerTriggerPressed = ManageEventOf<EventArgsControllerTriggerPressed>(nameof(ControlEvents), nameof(ControlEvents.ControllerTriggerPressed)); - this.Legacy_Control_ControllerTriggerReleased = ManageEventOf<EventArgsControllerTriggerReleased>(nameof(ControlEvents), nameof(ControlEvents.ControllerTriggerReleased)); - this.Legacy_Control_KeyboardChanged = ManageEventOf<EventArgsKeyboardStateChanged>(nameof(ControlEvents), nameof(ControlEvents.KeyboardChanged)); - this.Legacy_Control_KeyPressed = ManageEventOf<EventArgsKeyPressed>(nameof(ControlEvents), nameof(ControlEvents.KeyPressed)); - this.Legacy_Control_KeyReleased = ManageEventOf<EventArgsKeyPressed>(nameof(ControlEvents), nameof(ControlEvents.KeyReleased)); - this.Legacy_Control_MouseChanged = ManageEventOf<EventArgsMouseStateChanged>(nameof(ControlEvents), nameof(ControlEvents.MouseChanged)); - - this.Game_FirstUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.FirstUpdateTick)); - this.Game_UpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.UpdateTick)); - this.Game_SecondUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.SecondUpdateTick)); - this.Game_FourthUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.FourthUpdateTick)); - this.Game_EighthUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.EighthUpdateTick)); - this.Game_QuarterSecondTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.QuarterSecondTick)); - this.Game_HalfSecondTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.HalfSecondTick)); - this.Game_OneSecondTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.OneSecondTick)); - - this.Graphics_Resize = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.Resize)); - this.Graphics_OnPreRenderEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPreRenderEvent)); - this.Graphics_OnPostRenderEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPostRenderEvent)); - this.Graphics_OnPreRenderHudEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPreRenderHudEvent)); - this.Graphics_OnPostRenderHudEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPostRenderHudEvent)); - this.Graphics_OnPreRenderGuiEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPreRenderGuiEvent)); - this.Graphics_OnPostRenderGuiEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPostRenderGuiEvent)); - - this.Legacy_Input_ButtonPressed = ManageEventOf<EventArgsInput>(nameof(InputEvents), nameof(InputEvents.ButtonPressed)); - this.Legacy_Input_ButtonReleased = ManageEventOf<EventArgsInput>(nameof(InputEvents), nameof(InputEvents.ButtonReleased)); - - this.Legacy_Location_LocationsChanged = ManageEventOf<EventArgsLocationsChanged>(nameof(LocationEvents), nameof(LocationEvents.LocationsChanged)); - this.Legacy_Location_BuildingsChanged = ManageEventOf<EventArgsLocationBuildingsChanged>(nameof(LocationEvents), nameof(LocationEvents.BuildingsChanged)); - this.Legacy_Location_ObjectsChanged = ManageEventOf<EventArgsLocationObjectsChanged>(nameof(LocationEvents), nameof(LocationEvents.ObjectsChanged)); - - this.Menu_Changed = ManageEventOf<EventArgsClickableMenuChanged>(nameof(MenuEvents), nameof(MenuEvents.MenuChanged)); - this.Menu_Closed = ManageEventOf<EventArgsClickableMenuClosed>(nameof(MenuEvents), nameof(MenuEvents.MenuClosed)); - - this.Multiplayer_BeforeMainBroadcast = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.BeforeMainBroadcast)); - this.Multiplayer_AfterMainBroadcast = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.AfterMainBroadcast)); - this.Multiplayer_BeforeMainSync = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.BeforeMainSync)); - this.Multiplayer_AfterMainSync = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.AfterMainSync)); - - this.Mine_LevelChanged = ManageEventOf<EventArgsMineLevelChanged>(nameof(MineEvents), nameof(MineEvents.MineLevelChanged)); - - this.Player_InventoryChanged = ManageEventOf<EventArgsInventoryChanged>(nameof(PlayerEvents), nameof(PlayerEvents.InventoryChanged)); - this.Player_LeveledUp = ManageEventOf<EventArgsLevelUp>(nameof(PlayerEvents), nameof(PlayerEvents.LeveledUp)); - this.Player_Warped = ManageEventOf<EventArgsPlayerWarped>(nameof(PlayerEvents), nameof(PlayerEvents.Warped)); - - this.Save_BeforeCreate = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.BeforeCreate)); - this.Save_AfterCreate = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterCreate)); - this.Save_BeforeSave = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.BeforeSave)); - this.Save_AfterSave = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterSave)); - this.Save_AfterLoad = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterLoad)); - this.Save_AfterReturnToTitle = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterReturnToTitle)); - - this.Specialised_UnvalidatedUpdateTick = ManageEvent(nameof(SpecialisedEvents), nameof(SpecialisedEvents.UnvalidatedUpdateTick)); - - this.Time_AfterDayStarted = ManageEvent(nameof(TimeEvents), nameof(TimeEvents.AfterDayStarted)); - this.Time_TimeOfDayChanged = ManageEventOf<EventArgsIntChanged>(nameof(TimeEvents), nameof(TimeEvents.TimeOfDayChanged)); + this.Legacy_LocaleChanged = ManageEventOf<EventArgsValueChanged<string>>(nameof(ContentEvents), nameof(ContentEvents.AfterLocaleChanged)); + + this.Legacy_ControllerButtonPressed = ManageEventOf<EventArgsControllerButtonPressed>(nameof(ControlEvents), nameof(ControlEvents.ControllerButtonPressed)); + this.Legacy_ControllerButtonReleased = ManageEventOf<EventArgsControllerButtonReleased>(nameof(ControlEvents), nameof(ControlEvents.ControllerButtonReleased)); + this.Legacy_ControllerTriggerPressed = ManageEventOf<EventArgsControllerTriggerPressed>(nameof(ControlEvents), nameof(ControlEvents.ControllerTriggerPressed)); + this.Legacy_ControllerTriggerReleased = ManageEventOf<EventArgsControllerTriggerReleased>(nameof(ControlEvents), nameof(ControlEvents.ControllerTriggerReleased)); + this.Legacy_KeyboardChanged = ManageEventOf<EventArgsKeyboardStateChanged>(nameof(ControlEvents), nameof(ControlEvents.KeyboardChanged)); + this.Legacy_KeyPressed = ManageEventOf<EventArgsKeyPressed>(nameof(ControlEvents), nameof(ControlEvents.KeyPressed)); + this.Legacy_KeyReleased = ManageEventOf<EventArgsKeyPressed>(nameof(ControlEvents), nameof(ControlEvents.KeyReleased)); + this.Legacy_MouseChanged = ManageEventOf<EventArgsMouseStateChanged>(nameof(ControlEvents), nameof(ControlEvents.MouseChanged)); + + this.Legacy_FirstUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.FirstUpdateTick)); + this.Legacy_UpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.UpdateTick)); + this.Legacy_SecondUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.SecondUpdateTick)); + this.Legacy_FourthUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.FourthUpdateTick)); + this.Legacy_EighthUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.EighthUpdateTick)); + this.Legacy_QuarterSecondTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.QuarterSecondTick)); + this.Legacy_HalfSecondTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.HalfSecondTick)); + this.Legacy_OneSecondTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.OneSecondTick)); + + this.Legacy_Resize = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.Resize)); + this.Legacy_OnPreRenderEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPreRenderEvent)); + this.Legacy_OnPostRenderEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPostRenderEvent)); + this.Legacy_OnPreRenderHudEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPreRenderHudEvent)); + this.Legacy_OnPostRenderHudEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPostRenderHudEvent)); + this.Legacy_OnPreRenderGuiEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPreRenderGuiEvent)); + this.Legacy_OnPostRenderGuiEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPostRenderGuiEvent)); + + this.Legacy_ButtonPressed = ManageEventOf<EventArgsInput>(nameof(InputEvents), nameof(InputEvents.ButtonPressed)); + this.Legacy_ButtonReleased = ManageEventOf<EventArgsInput>(nameof(InputEvents), nameof(InputEvents.ButtonReleased)); + + this.Legacy_LocationsChanged = ManageEventOf<EventArgsLocationsChanged>(nameof(LocationEvents), nameof(LocationEvents.LocationsChanged)); + this.Legacy_BuildingsChanged = ManageEventOf<EventArgsLocationBuildingsChanged>(nameof(LocationEvents), nameof(LocationEvents.BuildingsChanged)); + this.Legacy_ObjectsChanged = ManageEventOf<EventArgsLocationObjectsChanged>(nameof(LocationEvents), nameof(LocationEvents.ObjectsChanged)); + + this.Legacy_MenuChanged = ManageEventOf<EventArgsClickableMenuChanged>(nameof(MenuEvents), nameof(MenuEvents.MenuChanged)); + this.Legacy_MenuClosed = ManageEventOf<EventArgsClickableMenuClosed>(nameof(MenuEvents), nameof(MenuEvents.MenuClosed)); + + this.Legacy_BeforeMainBroadcast = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.BeforeMainBroadcast)); + this.Legacy_AfterMainBroadcast = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.AfterMainBroadcast)); + this.Legacy_BeforeMainSync = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.BeforeMainSync)); + this.Legacy_AfterMainSync = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.AfterMainSync)); + + this.Legacy_MineLevelChanged = ManageEventOf<EventArgsMineLevelChanged>(nameof(MineEvents), nameof(MineEvents.MineLevelChanged)); + + this.Legacy_InventoryChanged = ManageEventOf<EventArgsInventoryChanged>(nameof(PlayerEvents), nameof(PlayerEvents.InventoryChanged)); + this.Legacy_LeveledUp = ManageEventOf<EventArgsLevelUp>(nameof(PlayerEvents), nameof(PlayerEvents.LeveledUp)); + this.Legacy_PlayerWarped = ManageEventOf<EventArgsPlayerWarped>(nameof(PlayerEvents), nameof(PlayerEvents.Warped)); + + this.Legacy_BeforeCreateSave = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.BeforeCreate)); + this.Legacy_AfterCreateSave = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterCreate)); + this.Legacy_BeforeSave = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.BeforeSave)); + this.Legacy_AfterSave = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterSave)); + this.Legacy_AfterLoad = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterLoad)); + this.Legacy_AfterReturnToTitle = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterReturnToTitle)); + + this.Legacy_UnvalidatedUpdateTick = ManageEvent(nameof(SpecialisedEvents), nameof(SpecialisedEvents.UnvalidatedUpdateTick)); + + this.Legacy_AfterDayStarted = ManageEvent(nameof(TimeEvents), nameof(TimeEvents.AfterDayStarted)); + this.Legacy_TimeOfDayChanged = ManageEventOf<EventArgsIntChanged>(nameof(TimeEvents), nameof(TimeEvents.TimeOfDayChanged)); } } } diff --git a/src/SMAPI/Framework/Events/ManagedEvent.cs b/src/SMAPI/Framework/Events/ManagedEvent.cs index c1ebf6c7..65f6e38e 100644 --- a/src/SMAPI/Framework/Events/ManagedEvent.cs +++ b/src/SMAPI/Framework/Events/ManagedEvent.cs @@ -67,6 +67,30 @@ namespace StardewModdingAPI.Framework.Events } } } + + /// <summary>Raise the event and notify all handlers.</summary> + /// <param name="args">The event arguments to pass.</param> + /// <param name="match">A lambda which returns true if the event should be raised for the given mod.</param> + public void RaiseForMods(TEventArgs args, Func<IModMetadata, bool> match) + { + if (this.Event == null) + return; + + foreach (EventHandler<TEventArgs> handler in this.CachedInvocationList) + { + if (match(this.GetSourceMod(handler))) + { + try + { + handler.Invoke(null, args); + } + catch (Exception ex) + { + this.LogError(handler, ex); + } + } + } + } } /// <summary>An event wrapper which intercepts and logs errors in handler code.</summary> diff --git a/src/SMAPI/Framework/Events/ManagedEventBase.cs b/src/SMAPI/Framework/Events/ManagedEventBase.cs index f3a278dc..defd903a 100644 --- a/src/SMAPI/Framework/Events/ManagedEventBase.cs +++ b/src/SMAPI/Framework/Events/ManagedEventBase.cs @@ -69,12 +69,22 @@ namespace StardewModdingAPI.Framework.Events this.SourceMods.Remove(handler); } + /// <summary>Get the mod which registered the given event handler, if available.</summary> + /// <param name="handler">The event handler.</param> + protected IModMetadata GetSourceMod(TEventHandler handler) + { + return this.SourceMods.TryGetValue(handler, out IModMetadata mod) + ? mod + : null; + } + /// <summary>Log an exception from an event handler.</summary> /// <param name="handler">The event handler instance.</param> /// <param name="ex">The exception that was raised.</param> protected void LogError(TEventHandler handler, Exception ex) { - if (this.SourceMods.TryGetValue(handler, out IModMetadata mod)) + IModMetadata mod = this.GetSourceMod(handler); + if (mod != null) mod.LogAsMod($"This mod failed in the {this.EventName} event. Technical details: \n{ex.GetLogSummary()}", LogLevel.Error); else this.Monitor.Log($"A mod failed in the {this.EventName} event. Technical details: \n{ex.GetLogSummary()}", LogLevel.Error); diff --git a/src/SMAPI/Framework/Events/ModDisplayEvents.cs b/src/SMAPI/Framework/Events/ModDisplayEvents.cs new file mode 100644 index 00000000..e383eec6 --- /dev/null +++ b/src/SMAPI/Framework/Events/ModDisplayEvents.cs @@ -0,0 +1,93 @@ +using System; +using StardewModdingAPI.Events; +using StardewValley; + +namespace StardewModdingAPI.Framework.Events +{ + /// <summary>Events related to UI and drawing to the screen.</summary> + internal class ModDisplayEvents : ModEventsBase, IDisplayEvents + { + /********* + ** Accessors + *********/ + /// <summary>Raised after a game menu is opened, closed, or replaced.</summary> + public event EventHandler<MenuChangedEventArgs> MenuChanged + { + add => this.EventManager.MenuChanged.Add(value); + remove => this.EventManager.MenuChanged.Remove(value); + } + + /// <summary>Raised before the game draws anything to the screen in a draw tick, as soon as the sprite batch is opened. The sprite batch may be closed and reopened multiple times after this event is called, but it's only raised once per draw tick. This event isn't useful for drawing to the screen, since the game will draw over it.</summary> + public event EventHandler<RenderingEventArgs> Rendering + { + add => this.EventManager.Rendering.Add(value); + remove => this.EventManager.Rendering.Remove(value); + } + + /// <summary>Raised after the game draws to the sprite patch in a draw tick, just before the final sprite batch is rendered to the screen. Since the game may open/close the sprite batch multiple times in a draw tick, the sprite batch may not contain everything being drawn and some things may already be rendered to the screen. Content drawn to the sprite batch at this point will be drawn over all vanilla content (including menus, HUD, and cursor).</summary> + public event EventHandler<RenderedEventArgs> Rendered + { + add => this.EventManager.Rendered.Add(value); + remove => this.EventManager.Rendered.Remove(value); + } + + /// <summary>Raised before the game world is drawn to the screen. This event isn't useful for drawing to the screen, since the game will draw over it.</summary> + public event EventHandler<RenderingWorldEventArgs> RenderingWorld + { + add => this.EventManager.RenderingWorld.Add(value); + remove => this.EventManager.RenderingWorld.Remove(value); + } + + /// <summary>Raised after the game world is drawn to the sprite patch, before it's rendered to the screen. Content drawn to the sprite batch at this point will be drawn over the world, but under any active menu, HUD elements, or cursor.</summary> + public event EventHandler<RenderedWorldEventArgs> RenderedWorld + { + add => this.EventManager.RenderedWorld.Add(value); + remove => this.EventManager.RenderedWorld.Remove(value); + } + + /// <summary>When a menu is open (<see cref="Game1.activeClickableMenu"/> isn't null), raised before that menu is drawn to the screen. This includes the game's internal menus like the title screen. Content drawn to the sprite batch at this point will appear under the menu.</summary> + public event EventHandler<RenderingActiveMenuEventArgs> RenderingActiveMenu + { + add => this.EventManager.RenderingActiveMenu.Add(value); + remove => this.EventManager.RenderingActiveMenu.Remove(value); + } + + /// <summary>When a menu is open (<see cref="Game1.activeClickableMenu"/> isn't null), raised after that menu is drawn to the sprite batch but before it's rendered to the screen. Content drawn to the sprite batch at this point will appear over the menu and menu cursor.</summary> + public event EventHandler<RenderedActiveMenuEventArgs> RenderedActiveMenu + { + add => this.EventManager.RenderedActiveMenu.Add(value); + remove => this.EventManager.RenderedActiveMenu.Remove(value); + } + + /// <summary>Raised before drawing the HUD (item toolbar, clock, etc) to the screen. The vanilla HUD may be hidden at this point (e.g. because a menu is open). Content drawn to the sprite batch at this point will appear under the HUD.</summary> + public event EventHandler<RenderingHudEventArgs> RenderingHud + { + add => this.EventManager.RenderingHud.Add(value); + remove => this.EventManager.RenderingHud.Remove(value); + } + + /// <summary>Raised after drawing the HUD (item toolbar, clock, etc) to the sprite batch, but before it's rendered to the screen. The vanilla HUD may be hidden at this point (e.g. because a menu is open). Content drawn to the sprite batch at this point will appear over the HUD.</summary> + public event EventHandler<RenderedHudEventArgs> RenderedHud + { + add => this.EventManager.RenderedHud.Add(value); + remove => this.EventManager.RenderedHud.Remove(value); + } + + /// <summary>Raised after the game window is resized.</summary> + public event EventHandler<WindowResizedEventArgs> WindowResized + { + add => this.EventManager.WindowResized.Add(value); + remove => this.EventManager.WindowResized.Remove(value); + } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="mod">The mod which uses this instance.</param> + /// <param name="eventManager">The underlying event manager.</param> + internal ModDisplayEvents(IModMetadata mod, EventManager eventManager) + : base(mod, eventManager) { } + } +} diff --git a/src/SMAPI/Framework/Events/ModEvents.cs b/src/SMAPI/Framework/Events/ModEvents.cs index 9e474457..8ad3936c 100644 --- a/src/SMAPI/Framework/Events/ModEvents.cs +++ b/src/SMAPI/Framework/Events/ModEvents.cs @@ -8,15 +8,27 @@ namespace StardewModdingAPI.Framework.Events /********* ** Accessors *********/ + /// <summary>Events related to UI and drawing to the screen.</summary> + public IDisplayEvents Display { get; } + /// <summary>Events linked to the game's update loop. The update loop runs roughly ≈60 times/second to run game logic like state changes, action handling, etc. These can be useful, but you should consider more semantic events like <see cref="IModEvents.Input"/> if possible.</summary> public IGameLoopEvents GameLoop { get; } /// <summary>Events raised when the player provides input using a controller, keyboard, or mouse.</summary> public IInputEvents Input { get; } + /// <summary>Events raised for multiplayer messages and connections.</summary> + public IMultiplayerEvents Multiplayer { get; } + + /// <summary>Events raised when the player data changes.</summary> + public IPlayerEvents Player { get; } + /// <summary>Events raised when something changes in the world.</summary> public IWorldEvents World { get; } + /// <summary>Events serving specialised edge cases that shouldn't be used by most mods.</summary> + public ISpecialisedEvents Specialised { get; } + /********* ** Public methods @@ -26,9 +38,13 @@ namespace StardewModdingAPI.Framework.Events /// <param name="eventManager">The underlying event manager.</param> public ModEvents(IModMetadata mod, EventManager eventManager) { + this.Display = new ModDisplayEvents(mod, eventManager); this.GameLoop = new ModGameLoopEvents(mod, eventManager); this.Input = new ModInputEvents(mod, eventManager); + this.Multiplayer = new ModMultiplayerEvents(mod, eventManager); + this.Player = new ModPlayerEvents(mod, eventManager); this.World = new ModWorldEvents(mod, eventManager); + this.Specialised = new ModSpecialisedEvents(mod, eventManager); } } } diff --git a/src/SMAPI/Framework/Events/ModGameLoopEvents.cs b/src/SMAPI/Framework/Events/ModGameLoopEvents.cs index 379a4e96..a5beac99 100644 --- a/src/SMAPI/Framework/Events/ModGameLoopEvents.cs +++ b/src/SMAPI/Framework/Events/ModGameLoopEvents.cs @@ -10,24 +10,88 @@ namespace StardewModdingAPI.Framework.Events ** Accessors *********/ /// <summary>Raised after the game is launched, right before the first update tick.</summary> - public event EventHandler<GameLoopLaunchedEventArgs> Launched + public event EventHandler<GameLaunchedEventArgs> GameLaunched { - add => this.EventManager.GameLoop_Launched.Add(value); - remove => this.EventManager.GameLoop_Launched.Remove(value); + add => this.EventManager.GameLaunched.Add(value); + remove => this.EventManager.GameLaunched.Remove(value); } /// <summary>Raised before the game performs its overall update tick (≈60 times per second).</summary> - public event EventHandler<GameLoopUpdatingEventArgs> Updating + public event EventHandler<UpdateTickingEventArgs> UpdateTicking { - add => this.EventManager.GameLoop_Updating.Add(value); - remove => this.EventManager.GameLoop_Updating.Remove(value); + add => this.EventManager.UpdateTicking.Add(value); + remove => this.EventManager.UpdateTicking.Remove(value); } /// <summary>Raised after the game performs its overall update tick (≈60 times per second).</summary> - public event EventHandler<GameLoopUpdatedEventArgs> Updated + public event EventHandler<UpdateTickedEventArgs> UpdateTicked { - add => this.EventManager.GameLoop_Updated.Add(value); - remove => this.EventManager.GameLoop_Updated.Remove(value); + add => this.EventManager.UpdateTicked.Add(value); + remove => this.EventManager.UpdateTicked.Remove(value); + } + + /// <summary>Raised before the game creates a new save file.</summary> + public event EventHandler<SaveCreatingEventArgs> SaveCreating + { + add => this.EventManager.SaveCreating.Add(value); + remove => this.EventManager.SaveCreating.Remove(value); + } + + /// <summary>Raised after the game finishes creating the save file.</summary> + public event EventHandler<SaveCreatedEventArgs> SaveCreated + { + add => this.EventManager.SaveCreated.Add(value); + remove => this.EventManager.SaveCreated.Remove(value); + } + + /// <summary>Raised before the game begins writes data to the save file.</summary> + public event EventHandler<SavingEventArgs> Saving + { + add => this.EventManager.Saving.Add(value); + remove => this.EventManager.Saving.Remove(value); + } + + /// <summary>Raised after the game finishes writing data to the save file.</summary> + public event EventHandler<SavedEventArgs> Saved + { + add => this.EventManager.Saved.Add(value); + remove => this.EventManager.Saved.Remove(value); + } + + /// <summary>Raised after the player loads a save slot.</summary> + public event EventHandler<SaveLoadedEventArgs> SaveLoaded + { + add => this.EventManager.SaveLoaded.Add(value); + remove => this.EventManager.SaveLoaded.Remove(value); + } + + /// <summary>Raised after the game begins a new day (including when the player loads a save).</summary> + public event EventHandler<DayStartedEventArgs> DayStarted + { + add => this.EventManager.DayStarted.Add(value); + remove => this.EventManager.DayStarted.Remove(value); + } + + /// <summary>Raised before the game ends the current day. This happens before it starts setting up the next day and before <see cref="IGameLoopEvents.Saving"/>.</summary> + public event EventHandler<DayEndingEventArgs> DayEnding + { + add => this.EventManager.DayEnding.Add(value); + remove => this.EventManager.DayEnding.Remove(value); + } + + /// <summary>Raised after the in-game clock time changes.</summary> + public event EventHandler<TimeChangedEventArgs> TimeChanged + { + + add => this.EventManager.TimeChanged.Add(value); + remove => this.EventManager.TimeChanged.Remove(value); + } + + /// <summary>Raised after the game returns to the title screen.</summary> + public event EventHandler<ReturnedToTitleEventArgs> ReturnedToTitle + { + add => this.EventManager.ReturnedToTitle.Add(value); + remove => this.EventManager.ReturnedToTitle.Remove(value); } diff --git a/src/SMAPI/Framework/Events/ModInputEvents.cs b/src/SMAPI/Framework/Events/ModInputEvents.cs index feca34f3..6a4298b4 100644 --- a/src/SMAPI/Framework/Events/ModInputEvents.cs +++ b/src/SMAPI/Framework/Events/ModInputEvents.cs @@ -10,31 +10,31 @@ namespace StardewModdingAPI.Framework.Events ** Accessors *********/ /// <summary>Raised after the player presses a button on the keyboard, controller, or mouse.</summary> - public event EventHandler<InputButtonPressedEventArgs> ButtonPressed + public event EventHandler<ButtonPressedEventArgs> ButtonPressed { - add => this.EventManager.Input_ButtonPressed.Add(value); - remove => this.EventManager.Input_ButtonPressed.Remove(value); + add => this.EventManager.ButtonPressed.Add(value); + remove => this.EventManager.ButtonPressed.Remove(value); } /// <summary>Raised after the player releases a button on the keyboard, controller, or mouse.</summary> - public event EventHandler<InputButtonReleasedEventArgs> ButtonReleased + public event EventHandler<ButtonReleasedEventArgs> ButtonReleased { - add => this.EventManager.Input_ButtonReleased.Add(value); - remove => this.EventManager.Input_ButtonReleased.Remove(value); + add => this.EventManager.ButtonReleased.Add(value); + remove => this.EventManager.ButtonReleased.Remove(value); } /// <summary>Raised after the player moves the in-game cursor.</summary> - public event EventHandler<InputCursorMovedEventArgs> CursorMoved + public event EventHandler<CursorMovedEventArgs> CursorMoved { - add => this.EventManager.Input_CursorMoved.Add(value); - remove => this.EventManager.Input_CursorMoved.Remove(value); + add => this.EventManager.CursorMoved.Add(value); + remove => this.EventManager.CursorMoved.Remove(value); } /// <summary>Raised after the player scrolls the mouse wheel.</summary> - public event EventHandler<InputMouseWheelScrolledEventArgs> MouseWheelScrolled + public event EventHandler<MouseWheelScrolledEventArgs> MouseWheelScrolled { - add => this.EventManager.Input_MouseWheelScrolled.Add(value); - remove => this.EventManager.Input_MouseWheelScrolled.Remove(value); + add => this.EventManager.MouseWheelScrolled.Add(value); + remove => this.EventManager.MouseWheelScrolled.Remove(value); } diff --git a/src/SMAPI/Framework/Events/ModMultiplayerEvents.cs b/src/SMAPI/Framework/Events/ModMultiplayerEvents.cs new file mode 100644 index 00000000..152c4e0c --- /dev/null +++ b/src/SMAPI/Framework/Events/ModMultiplayerEvents.cs @@ -0,0 +1,43 @@ +using System; +using StardewModdingAPI.Events; + +namespace StardewModdingAPI.Framework.Events +{ + /// <summary>Events raised for multiplayer messages and connections.</summary> + internal class ModMultiplayerEvents : ModEventsBase, IMultiplayerEvents + { + /********* + ** Accessors + *********/ + /// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection, so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary> + public event EventHandler<PeerContextReceivedEventArgs> PeerContextReceived + { + add => this.EventManager.PeerContextReceived.Add(value); + remove => this.EventManager.PeerContextReceived.Remove(value); + } + + /// <summary>Raised after a mod message is received over the network.</summary> + public event EventHandler<ModMessageReceivedEventArgs> ModMessageReceived + { + add => this.EventManager.ModMessageReceived.Add(value); + remove => this.EventManager.ModMessageReceived.Remove(value); + } + + /// <summary>Raised after the connection with a peer is severed.</summary> + public event EventHandler<PeerDisconnectedEventArgs> PeerDisconnected + { + add => this.EventManager.PeerDisconnected.Add(value); + remove => this.EventManager.PeerDisconnected.Remove(value); + } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="mod">The mod which uses this instance.</param> + /// <param name="eventManager">The underlying event manager.</param> + internal ModMultiplayerEvents(IModMetadata mod, EventManager eventManager) + : base(mod, eventManager) { } + } +} diff --git a/src/SMAPI/Framework/Events/ModPlayerEvents.cs b/src/SMAPI/Framework/Events/ModPlayerEvents.cs new file mode 100644 index 00000000..ca7cfd96 --- /dev/null +++ b/src/SMAPI/Framework/Events/ModPlayerEvents.cs @@ -0,0 +1,43 @@ +using System; +using StardewModdingAPI.Events; + +namespace StardewModdingAPI.Framework.Events +{ + /// <summary>Events raised when the player data changes.</summary> + internal class ModPlayerEvents : ModEventsBase, IPlayerEvents + { + /********* + ** Accessors + *********/ + /// <summary>Raised after items are added or removed to a player's inventory. NOTE: this event is currently only raised for the local player.</summary> + public event EventHandler<InventoryChangedEventArgs> InventoryChanged + { + add => this.EventManager.InventoryChanged.Add(value); + remove => this.EventManager.InventoryChanged.Remove(value); + } + + /// <summary>Raised after a player skill level changes. This happens as soon as they level up, not when the game notifies the player after their character goes to bed. NOTE: this event is currently only raised for the local player.</summary> + public event EventHandler<LevelChangedEventArgs> LevelChanged + { + add => this.EventManager.LevelChanged.Add(value); + remove => this.EventManager.LevelChanged.Remove(value); + } + + /// <summary>Raised after a player warps to a new location. NOTE: this event is currently only raised for the local player.</summary> + public event EventHandler<WarpedEventArgs> Warped + { + add => this.EventManager.Warped.Add(value); + remove => this.EventManager.Warped.Remove(value); + } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="mod">The mod which uses this instance.</param> + /// <param name="eventManager">The underlying event manager.</param> + internal ModPlayerEvents(IModMetadata mod, EventManager eventManager) + : base(mod, eventManager) { } + } +} diff --git a/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs b/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs new file mode 100644 index 00000000..17c32bb8 --- /dev/null +++ b/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs @@ -0,0 +1,36 @@ +using System; +using StardewModdingAPI.Events; + +namespace StardewModdingAPI.Framework.Events +{ + /// <summary>Events serving specialised edge cases that shouldn't be used by most mods.</summary> + internal class ModSpecialisedEvents : ModEventsBase, ISpecialisedEvents + { + /********* + ** Accessors + *********/ + /// <summary>Raised before the game state is updated (≈60 times per second), regardless of normal SMAPI validation. This event is not thread-safe and may be invoked while game logic is running asynchronously. Changes to game state in this method may crash the game or corrupt an in-progress save. Do not use this event unless you're fully aware of the context in which your code will be run. Mods using this event will trigger a stability warning in the SMAPI console.</summary> + public event EventHandler<UnvalidatedUpdateTickingEventArgs> UnvalidatedUpdateTicking + { + add => this.EventManager.UnvalidatedUpdateTicking.Add(value); + remove => this.EventManager.UnvalidatedUpdateTicking.Remove(value); + } + + /// <summary>Raised after the game state is updated (≈60 times per second), regardless of normal SMAPI validation. This event is not thread-safe and may be invoked while game logic is running asynchronously. Changes to game state in this method may crash the game or corrupt an in-progress save. Do not use this event unless you're fully aware of the context in which your code will be run. Mods using this event will trigger a stability warning in the SMAPI console.</summary> + public event EventHandler<UnvalidatedUpdateTickedEventArgs> UnvalidatedUpdateTicked + { + add => this.EventManager.UnvalidatedUpdateTicked.Add(value); + remove => this.EventManager.UnvalidatedUpdateTicked.Remove(value); + } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="mod">The mod which uses this instance.</param> + /// <param name="eventManager">The underlying event manager.</param> + internal ModSpecialisedEvents(IModMetadata mod, EventManager eventManager) + : base(mod, eventManager) { } + } +} diff --git a/src/SMAPI/Framework/Events/ModWorldEvents.cs b/src/SMAPI/Framework/Events/ModWorldEvents.cs index dc9c0f4c..b85002a3 100644 --- a/src/SMAPI/Framework/Events/ModWorldEvents.cs +++ b/src/SMAPI/Framework/Events/ModWorldEvents.cs @@ -10,52 +10,52 @@ namespace StardewModdingAPI.Framework.Events ** Accessors *********/ /// <summary>Raised after a game location is added or removed.</summary> - public event EventHandler<WorldLocationListChangedEventArgs> LocationListChanged + public event EventHandler<LocationListChangedEventArgs> LocationListChanged { - add => this.EventManager.World_LocationListChanged.Add(value, this.Mod); - remove => this.EventManager.World_LocationListChanged.Remove(value); + add => this.EventManager.LocationListChanged.Add(value, this.Mod); + remove => this.EventManager.LocationListChanged.Remove(value); } /// <summary>Raised after buildings are added or removed in a location.</summary> - public event EventHandler<WorldBuildingListChangedEventArgs> BuildingListChanged + public event EventHandler<BuildingListChangedEventArgs> BuildingListChanged { - add => this.EventManager.World_BuildingListChanged.Add(value, this.Mod); - remove => this.EventManager.World_BuildingListChanged.Remove(value); + add => this.EventManager.BuildingListChanged.Add(value, this.Mod); + remove => this.EventManager.BuildingListChanged.Remove(value); } /// <summary>Raised after debris are added or removed in a location.</summary> - public event EventHandler<WorldDebrisListChangedEventArgs> DebrisListChanged + public event EventHandler<DebrisListChangedEventArgs> DebrisListChanged { - add => this.EventManager.World_DebrisListChanged.Add(value, this.Mod); - remove => this.EventManager.World_DebrisListChanged.Remove(value); + add => this.EventManager.DebrisListChanged.Add(value, this.Mod); + remove => this.EventManager.DebrisListChanged.Remove(value); } /// <summary>Raised after large terrain features (like bushes) are added or removed in a location.</summary> - public event EventHandler<WorldLargeTerrainFeatureListChangedEventArgs> LargeTerrainFeatureListChanged + public event EventHandler<LargeTerrainFeatureListChangedEventArgs> LargeTerrainFeatureListChanged { - add => this.EventManager.World_LargeTerrainFeatureListChanged.Add(value, this.Mod); - remove => this.EventManager.World_LargeTerrainFeatureListChanged.Remove(value); + add => this.EventManager.LargeTerrainFeatureListChanged.Add(value, this.Mod); + remove => this.EventManager.LargeTerrainFeatureListChanged.Remove(value); } /// <summary>Raised after NPCs are added or removed in a location.</summary> - public event EventHandler<WorldNpcListChangedEventArgs> NpcListChanged + public event EventHandler<NpcListChangedEventArgs> NpcListChanged { - add => this.EventManager.World_NpcListChanged.Add(value); - remove => this.EventManager.World_NpcListChanged.Remove(value); + add => this.EventManager.NpcListChanged.Add(value); + remove => this.EventManager.NpcListChanged.Remove(value); } /// <summary>Raised after objects are added or removed in a location.</summary> - public event EventHandler<WorldObjectListChangedEventArgs> ObjectListChanged + public event EventHandler<ObjectListChangedEventArgs> ObjectListChanged { - add => this.EventManager.World_ObjectListChanged.Add(value); - remove => this.EventManager.World_ObjectListChanged.Remove(value); + add => this.EventManager.ObjectListChanged.Add(value); + remove => this.EventManager.ObjectListChanged.Remove(value); } /// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary> - public event EventHandler<WorldTerrainFeatureListChangedEventArgs> TerrainFeatureListChanged + public event EventHandler<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged { - add => this.EventManager.World_TerrainFeatureListChanged.Add(value); - remove => this.EventManager.World_TerrainFeatureListChanged.Remove(value); + add => this.EventManager.TerrainFeatureListChanged.Add(value); + remove => this.EventManager.TerrainFeatureListChanged.Remove(value); } diff --git a/src/SMAPI/Framework/IModMetadata.cs b/src/SMAPI/Framework/IModMetadata.cs index 2145105b..7ada7dea 100644 --- a/src/SMAPI/Framework/IModMetadata.cs +++ b/src/SMAPI/Framework/IModMetadata.cs @@ -1,11 +1,13 @@ +using System.Collections.Generic; using StardewModdingAPI.Framework.ModLoading; using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; using StardewModdingAPI.Toolkit.Framework.ModData; +using StardewModdingAPI.Toolkit.Framework.UpdateData; namespace StardewModdingAPI.Framework { /// <summary>Metadata for a mod.</summary> - internal interface IModMetadata + internal interface IModMetadata : IModInfo { /********* ** Accessors @@ -16,8 +18,8 @@ namespace StardewModdingAPI.Framework /// <summary>The mod's full directory path.</summary> string DirectoryPath { get; } - /// <summary>The mod manifest.</summary> - IManifest Manifest { get; } + /// <summary>The <see cref="DirectoryPath"/> relative to the game's Mods folder.</summary> + string RelativeDirectoryPath { get; } /// <summary>Metadata about the mod from SMAPI's internal data (if any).</summary> ModDataRecordVersionedFields DataRecord { get; } @@ -31,10 +33,13 @@ namespace StardewModdingAPI.Framework /// <summary>The reason the metadata is invalid, if any.</summary> string Error { get; } - /// <summary>The mod instance (if loaded and <see cref="IsContentPack"/> is false).</summary> + /// <summary>Whether the mod folder should be ignored. This is <c>true</c> if it was found within a folder whose name starts with a dot.</summary> + bool IsIgnored { get; } + + /// <summary>The mod instance (if loaded and <see cref="IModInfo.IsContentPack"/> is false).</summary> IMod Mod { get; } - /// <summary>The content pack instance (if loaded and <see cref="IsContentPack"/> is true).</summary> + /// <summary>The content pack instance (if loaded and <see cref="IModInfo.IsContentPack"/> is true).</summary> IContentPack ContentPack { get; } /// <summary>Writes messages to the console and log file as this mod.</summary> @@ -43,9 +48,6 @@ namespace StardewModdingAPI.Framework /// <summary>The mod-provided API (if any).</summary> object Api { get; } - /// <summary>Whether the mod is a content pack.</summary> - bool IsContentPack { get; } - /// <summary>The update-check metadata for this mod (if any).</summary> ModEntryModel UpdateCheckData { get; } @@ -86,7 +88,15 @@ namespace StardewModdingAPI.Framework /// <summary>Whether the mod has an ID (regardless of whether the ID is valid or the mod itself was loaded).</summary> bool HasID(); - /// <summary>Whether the mod has at least one update key set.</summary> - bool HasUpdateKeys(); + /// <summary>Whether the mod has the given ID.</summary> + /// <param name="id">The mod ID to check.</param> + bool HasID(string id); + + /// <summary>Get the defined update keys.</summary> + /// <param name="validOnly">Only return valid update keys.</param> + IEnumerable<UpdateKey> GetUpdateKeys(bool validOnly = true); + + /// <summary>Whether the mod has at least one valid update key set.</summary> + bool HasValidUpdateKeys(); } } diff --git a/src/SMAPI/Framework/InternalExtensions.cs b/src/SMAPI/Framework/InternalExtensions.cs index ff3925fb..f52bfe2b 100644 --- a/src/SMAPI/Framework/InternalExtensions.cs +++ b/src/SMAPI/Framework/InternalExtensions.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Reflection; using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework.Events; using StardewModdingAPI.Framework.Reflection; using StardewValley; @@ -40,6 +41,17 @@ namespace StardewModdingAPI.Framework } /**** + ** ManagedEvent + ****/ + /// <summary>Raise the event using the default event args and notify all handlers.</summary> + /// <typeparam name="TEventArgs">The event args type to construct.</typeparam> + /// <param name="event">The event to raise.</param> + public static void RaiseEmpty<TEventArgs>(this ManagedEvent<TEventArgs> @event) where TEventArgs : new() + { + @event.Raise(Singleton<TEventArgs>.Instance); + } + + /**** ** Exceptions ****/ /// <summary>Get a string representation of an exception suitable for writing to the error log.</summary> diff --git a/src/SMAPI/Framework/ModHelpers/CommandHelper.cs b/src/SMAPI/Framework/ModHelpers/CommandHelper.cs index bdedb07c..5a3304f3 100644 --- a/src/SMAPI/Framework/ModHelpers/CommandHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/CommandHelper.cs @@ -8,8 +8,8 @@ namespace StardewModdingAPI.Framework.ModHelpers /********* ** Accessors *********/ - /// <summary>The friendly mod name for this instance.</summary> - private readonly string ModName; + /// <summary>The mod using this instance.</summary> + private readonly IModMetadata Mod; /// <summary>Manages console commands.</summary> private readonly CommandManager CommandManager; @@ -19,13 +19,12 @@ namespace StardewModdingAPI.Framework.ModHelpers ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="modID">The unique ID of the relevant mod.</param> - /// <param name="modName">The friendly mod name for this instance.</param> + /// <param name="mod">The mod using this instance.</param> /// <param name="commandManager">Manages console commands.</param> - public CommandHelper(string modID, string modName, CommandManager commandManager) - : base(modID) + public CommandHelper(IModMetadata mod, CommandManager commandManager) + : base(mod?.Manifest?.UniqueID ?? "SMAPI") { - this.ModName = modName; + this.Mod = mod; this.CommandManager = commandManager; } @@ -38,7 +37,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <exception cref="ArgumentException">There's already a command with that name.</exception> public ICommandHelper Add(string name, string documentation, Action<string, string[]> callback) { - this.CommandManager.Add(this.ModName, name, documentation, callback); + this.CommandManager.Add(this.Mod, name, documentation, callback); return this; } diff --git a/src/SMAPI/Framework/ModHelpers/DataHelper.cs b/src/SMAPI/Framework/ModHelpers/DataHelper.cs new file mode 100644 index 00000000..e5100aed --- /dev/null +++ b/src/SMAPI/Framework/ModHelpers/DataHelper.cs @@ -0,0 +1,166 @@ +using System; +using System.IO; +using Newtonsoft.Json; +using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Utilities; +using StardewValley; + +namespace StardewModdingAPI.Framework.ModHelpers +{ + /// <summary>Provides an API for reading and storing local mod data.</summary> + internal class DataHelper : BaseHelper, IDataHelper + { + /********* + ** Properties + *********/ + /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> + private readonly JsonHelper JsonHelper; + + /// <summary>The absolute path to the mod folder.</summary> + private readonly string ModFolderPath; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="modID">The unique ID of the relevant mod.</param> + /// <param name="modFolderPath">The absolute path to the mod folder.</param> + /// <param name="jsonHelper">The absolute path to the mod folder.</param> + public DataHelper(string modID, string modFolderPath, JsonHelper jsonHelper) + : base(modID) + { + this.ModFolderPath = modFolderPath; + this.JsonHelper = jsonHelper; + } + + /**** + ** JSON file + ****/ + /// <summary>Read data from a JSON file in the mod's folder.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="path">The file path relative to the mod folder.</param> + /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> + public TModel ReadJsonFile<TModel>(string path) where TModel : class + { + if (!PathUtilities.IsSafeRelativePath(path)) + throw new InvalidOperationException($"You must call {nameof(IModHelper.Data)}.{nameof(this.ReadJsonFile)} with a relative path."); + + path = Path.Combine(this.ModFolderPath, PathUtilities.NormalisePathSeparators(path)); + return this.JsonHelper.ReadJsonFileIfExists(path, out TModel data) + ? data + : null; + } + + /// <summary>Save data to a JSON file in the mod's folder.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="path">The file path relative to the mod folder.</param> + /// <param name="data">The arbitrary data to save.</param> + /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> + public void WriteJsonFile<TModel>(string path, TModel data) where TModel : class + { + if (!PathUtilities.IsSafeRelativePath(path)) + throw new InvalidOperationException($"You must call {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteJsonFile)} with a relative path (without directory climbing)."); + + path = Path.Combine(this.ModFolderPath, PathUtilities.NormalisePathSeparators(path)); + this.JsonHelper.WriteJsonFile(path, data); + } + + /**** + ** Save file + ****/ + /// <summary>Read arbitrary data stored in the current save slot. This is only possible if a save has been loaded.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="key">The unique key identifying the data.</param> + /// <returns>Returns the parsed data, or <c>null</c> if the entry doesn't exist or is empty.</returns> + /// <exception cref="InvalidOperationException">The player hasn't loaded a save file yet or isn't the main player.</exception> + public TModel ReadSaveData<TModel>(string key) where TModel : class + { + if (!Context.IsSaveLoaded) + throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} when a save file isn't loaded."); + if (!Context.IsMainPlayer) + throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} because this isn't the main player. (Save files are stored on the main player's computer.)"); + + return Game1.CustomData.TryGetValue(this.GetSaveFileKey(key), out string value) + ? this.JsonHelper.Deserialise<TModel>(value) + : null; + } + + /// <summary>Save arbitrary data to the current save slot. This is only possible if a save has been loaded, and the data will be lost if the player exits without saving the current day.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="key">The unique key identifying the data.</param> + /// <param name="data">The arbitrary data to save.</param> + /// <exception cref="InvalidOperationException">The player hasn't loaded a save file yet or isn't the main player.</exception> + public void WriteSaveData<TModel>(string key, TModel data) where TModel : class + { + if (!Context.IsSaveLoaded) + throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteSaveData)} when a save file isn't loaded."); + if (!Context.IsMainPlayer) + throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} because this isn't the main player. (Save files are stored on the main player's computer.)"); + + string internalKey = this.GetSaveFileKey(key); + if (data != null) + Game1.CustomData[internalKey] = this.JsonHelper.Serialise(data, Formatting.None); + else + Game1.CustomData.Remove(internalKey); + } + + /**** + ** Global app data + ****/ + /// <summary>Read arbitrary data stored on the local computer, synchronised by GOG/Steam if applicable.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="key">The unique key identifying the data.</param> + /// <returns>Returns the parsed data, or <c>null</c> if the entry doesn't exist or is empty.</returns> + public TModel ReadGlobalData<TModel>(string key) where TModel : class + { + string path = this.GetGlobalDataPath(key); + return this.JsonHelper.ReadJsonFileIfExists(path, out TModel data) + ? data + : null; + } + + /// <summary>Save arbitrary data to the local computer, synchronised by GOG/Steam if applicable.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="key">The unique key identifying the data.</param> + /// <param name="data">The arbitrary data to save.</param> + public void WriteGlobalData<TModel>(string key, TModel data) where TModel : class + { + string path = this.GetGlobalDataPath(key); + if (data != null) + this.JsonHelper.WriteJsonFile(path, data); + else + File.Delete(path); + } + + + /********* + ** Public methods + *********/ + /// <summary>Get the unique key for a save file data entry.</summary> + /// <param name="key">The unique key identifying the data.</param> + private string GetSaveFileKey(string key) + { + this.AssertSlug(key, nameof(key)); + return $"smapi/mod-data/{this.ModID}/{key}".ToLower(); + } + + /// <summary>Get the absolute path for a global data file.</summary> + /// <param name="key">The unique key identifying the data.</param> + private string GetGlobalDataPath(string key) + { + this.AssertSlug(key, nameof(key)); + return Path.Combine(Constants.SavesPath, ".smapi", "mod-data", this.ModID.ToLower(), $"{key}.json".ToLower()); + } + + /// <summary>Assert that a key contains only characters that are safe in all contexts.</summary> + /// <param name="key">The key to check.</param> + /// <param name="paramName">The argument name for any assertion error.</param> + private void AssertSlug(string key, string paramName) + { + if (!PathUtilities.IsSlug(key)) + throw new ArgumentException("The data key is invalid (keys must only contain letters, numbers, underscores, periods, or hyphens).", paramName); + } + } +} diff --git a/src/SMAPI/Framework/ModHelpers/ModHelper.cs b/src/SMAPI/Framework/ModHelpers/ModHelper.cs index 0ba258b4..5e190e55 100644 --- a/src/SMAPI/Framework/ModHelpers/ModHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModHelper.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; using StardewModdingAPI.Events; using StardewModdingAPI.Framework.Input; using StardewModdingAPI.Toolkit.Serialisation; @@ -16,11 +15,8 @@ namespace StardewModdingAPI.Framework.ModHelpers /********* ** Properties *********/ - /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> - private readonly JsonHelper JsonHelper; - /// <summary>The content packs loaded for this mod.</summary> - private readonly IContentPack[] ContentPacks; + private readonly Lazy<IContentPack[]> ContentPacks; /// <summary>Create a transitional content pack.</summary> private readonly Func<string, IManifest, IContentPack> CreateContentPack; @@ -35,12 +31,18 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <summary>The full path to the mod's folder.</summary> public string DirectoryPath { get; } + /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> + private readonly JsonHelper JsonHelper; + /// <summary>Manages access to events raised by SMAPI, which let your mod react when something happens in the game.</summary> public IModEvents Events { get; } /// <summary>An API for loading content assets.</summary> public IContentHelper Content { get; } + /// <summary>An API for reading and writing persistent mod data.</summary> + public IDataHelper Data { get; } + /// <summary>An API for checking and changing input state.</summary> public IInputHelper Input { get; } @@ -71,6 +73,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <param name="events">Manages access to events raised by SMAPI.</param> /// <param name="contentHelper">An API for loading content assets.</param> /// <param name="commandHelper">An API for managing console commands.</param> + /// <param name="dataHelper">An API for reading and writing persistent mod data.</param> /// <param name="modRegistry">an API for fetching metadata about loaded mods.</param> /// <param name="reflectionHelper">An API for accessing private game code.</param> /// <param name="multiplayer">Provides multiplayer utilities.</param> @@ -80,7 +83,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <param name="deprecationManager">Manages deprecation warnings.</param> /// <exception cref="ArgumentNullException">An argument is null or empty.</exception> /// <exception cref="InvalidOperationException">The <paramref name="modDirectory"/> path does not exist on disk.</exception> - public ModHelper(string modID, string modDirectory, JsonHelper jsonHelper, SInputState inputState, IModEvents events, IContentHelper contentHelper, ICommandHelper commandHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, IMultiplayerHelper multiplayer, ITranslationHelper translationHelper, IEnumerable<IContentPack> contentPacks, Func<string, IManifest, IContentPack> createContentPack, DeprecationManager deprecationManager) + public ModHelper(string modID, string modDirectory, JsonHelper jsonHelper, SInputState inputState, IModEvents events, IContentHelper contentHelper, ICommandHelper commandHelper, IDataHelper dataHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, IMultiplayerHelper multiplayer, ITranslationHelper translationHelper, Func<IContentPack[]> contentPacks, Func<string, IManifest, IContentPack> createContentPack, DeprecationManager deprecationManager) : base(modID) { // validate directory @@ -93,13 +96,14 @@ namespace StardewModdingAPI.Framework.ModHelpers this.DirectoryPath = modDirectory; this.JsonHelper = jsonHelper ?? throw new ArgumentNullException(nameof(jsonHelper)); this.Content = contentHelper ?? throw new ArgumentNullException(nameof(contentHelper)); + this.Data = dataHelper ?? throw new ArgumentNullException(nameof(dataHelper)); this.Input = new InputHelper(modID, inputState); this.ModRegistry = modRegistry ?? throw new ArgumentNullException(nameof(modRegistry)); this.ConsoleCommands = commandHelper ?? throw new ArgumentNullException(nameof(commandHelper)); this.Reflection = reflectionHelper ?? throw new ArgumentNullException(nameof(reflectionHelper)); this.Multiplayer = multiplayer ?? throw new ArgumentNullException(nameof(multiplayer)); this.Translation = translationHelper ?? throw new ArgumentNullException(nameof(translationHelper)); - this.ContentPacks = contentPacks.ToArray(); + this.ContentPacks = new Lazy<IContentPack[]>(contentPacks); this.CreateContentPack = createContentPack; this.DeprecationManager = deprecationManager; this.Events = events; @@ -113,7 +117,7 @@ namespace StardewModdingAPI.Framework.ModHelpers public TConfig ReadConfig<TConfig>() where TConfig : class, new() { - TConfig config = this.ReadJsonFile<TConfig>("config.json") ?? new TConfig(); + TConfig config = this.Data.ReadJsonFile<TConfig>("config.json") ?? new TConfig(); this.WriteConfig(config); // create file or fill in missing fields return config; } @@ -124,7 +128,7 @@ namespace StardewModdingAPI.Framework.ModHelpers public void WriteConfig<TConfig>(TConfig config) where TConfig : class, new() { - this.WriteJsonFile("config.json", config); + this.Data.WriteJsonFile("config.json", config); } /**** @@ -134,6 +138,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <typeparam name="TModel">The model type.</typeparam> /// <param name="path">The file path relative to the mod directory.</param> /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + [Obsolete("Use " + nameof(ModHelper.Data) + "." + nameof(IDataHelper.ReadJsonFile) + " instead")] public TModel ReadJsonFile<TModel>(string path) where TModel : class { @@ -147,6 +152,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <typeparam name="TModel">The model type.</typeparam> /// <param name="path">The file path relative to the mod directory.</param> /// <param name="model">The model to save.</param> + [Obsolete("Use " + nameof(ModHelper.Data) + "." + nameof(IDataHelper.WriteJsonFile) + " instead")] public void WriteJsonFile<TModel>(string path, TModel model) where TModel : class { @@ -197,7 +203,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <summary>Get all content packs loaded for this mod.</summary> public IEnumerable<IContentPack> GetContentPacks() { - return this.ContentPacks; + return this.ContentPacks.Value; } /**** diff --git a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs index 008a80f5..5cc2a20f 100644 --- a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs @@ -40,17 +40,17 @@ namespace StardewModdingAPI.Framework.ModHelpers } /// <summary>Get metadata for all loaded mods.</summary> - public IEnumerable<IManifest> GetAll() + public IEnumerable<IModInfo> GetAll() { - return this.Registry.GetAll().Select(p => p.Manifest); + return this.Registry.GetAll(); } /// <summary>Get metadata for a loaded mod.</summary> /// <param name="uniqueID">The mod's unique ID.</param> /// <returns>Returns the matching mod's metadata, or <c>null</c> if not found.</returns> - public IManifest Get(string uniqueID) + public IModInfo Get(string uniqueID) { - return this.Registry.Get(uniqueID)?.Manifest; + return this.Registry.Get(uniqueID); } /// <summary>Get whether a mod has been loaded.</summary> diff --git a/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs b/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs index c449a51b..eedad0bc 100644 --- a/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using StardewModdingAPI.Framework.Networking; using StardewValley; namespace StardewModdingAPI.Framework.ModHelpers @@ -25,16 +27,50 @@ namespace StardewModdingAPI.Framework.ModHelpers this.Multiplayer = multiplayer; } + /// <summary>Get a new multiplayer ID.</summary> + public long GetNewID() + { + return this.Multiplayer.getNewID(); + } + /// <summary>Get the locations which are being actively synced from the host.</summary> public IEnumerable<GameLocation> GetActiveLocations() { return this.Multiplayer.activeLocations(); } - /// <summary>Get a new multiplayer ID.</summary> - public long GetNewID() + /// <summary>Get a connected player.</summary> + /// <param name="id">The player's unique ID.</param> + /// <returns>Returns the connected player, or <c>null</c> if no such player is connected.</returns> + public IMultiplayerPeer GetConnectedPlayer(long id) { - return this.Multiplayer.getNewID(); + return this.Multiplayer.Peers.TryGetValue(id, out MultiplayerPeer peer) + ? peer + : null; + } + + /// <summary>Get all connected players.</summary> + public IEnumerable<IMultiplayerPeer> GetConnectedPlayers() + { + return this.Multiplayer.Peers.Values; + } + + /// <summary>Send a message to mods installed by connected players.</summary> + /// <typeparam name="TMessage">The data type. This can be a class with a default constructor, or a value type.</typeparam> + /// <param name="message">The data to send over the network.</param> + /// <param name="messageType">A message type which receiving mods can use to decide whether it's the one they want to handle, like <c>SetPlayerLocation</c>. This doesn't need to be globally unique, since mods should check the originating mod ID.</param> + /// <param name="modIDs">The mod IDs which should receive the message on the destination computers, or <c>null</c> for all mods. Specifying mod IDs is recommended to improve performance, unless it's a general-purpose broadcast.</param> + /// <param name="playerIDs">The <see cref="Farmer.UniqueMultiplayerID" /> values for the players who should receive the message, or <c>null</c> for all players. If you don't need to broadcast to all players, specifying player IDs is recommended to reduce latency.</param> + /// <exception cref="ArgumentNullException">The <paramref name="message"/> or <paramref name="messageType" /> is null.</exception> + public void SendMessage<TMessage>(TMessage message, string messageType, string[] modIDs = null, long[] playerIDs = null) + { + this.Multiplayer.BroadcastModMessage( + message: message, + messageType: messageType, + fromModID: this.ModID, + toModIDs: modIDs, + toPlayerIDs: playerIDs + ); } } } diff --git a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs index 37b1a378..fdbfdd8d 100644 --- a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs +++ b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs @@ -45,6 +45,7 @@ namespace StardewModdingAPI.Framework.ModLoading this.AssemblyMap = this.TrackForDisposal(Constants.GetAssemblyMap(targetPlatform)); this.AssemblyDefinitionResolver = this.TrackForDisposal(new AssemblyDefinitionResolver()); this.AssemblyDefinitionResolver.AddSearchDirectory(Constants.ExecutionPath); + this.AssemblyDefinitionResolver.AddSearchDirectory(Constants.InternalFilesPath); // generate type => assembly lookup for types which should be rewritten this.TypeAssemblies = new Dictionary<string, Assembly>(); @@ -349,6 +350,16 @@ namespace StardewModdingAPI.Framework.ModLoading mod.SetWarning(ModWarning.UsesDynamic); break; + case InstructionHandleResult.DetectedFilesystemAccess: + this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected filesystem access ({handler.NounPhrase}) in assembly {filename}."); + mod.SetWarning(ModWarning.AccessesFilesystem); + break; + + case InstructionHandleResult.DetectedShellAccess: + this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected shell or process access ({handler.NounPhrase}) in assembly {filename}."); + mod.SetWarning(ModWarning.AccessesShell); + break; + case InstructionHandleResult.None: break; diff --git a/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs b/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs index cfa23d08..f3555c2d 100644 --- a/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs +++ b/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs @@ -24,6 +24,12 @@ namespace StardewModdingAPI.Framework.ModLoading DetectedDynamic, /// <summary>The instruction is compatible, but references <see cref="SpecialisedEvents.UnvalidatedUpdateTick"/> which may impact stability.</summary> - DetectedUnvalidatedUpdateTick + DetectedUnvalidatedUpdateTick, + + /// <summary>The instruction accesses the filesystem directly.</summary> + DetectedFilesystemAccess, + + /// <summary>The instruction accesses the OS shell or processes directly.</summary> + DetectedShellAccess } } diff --git a/src/SMAPI/Framework/ModLoading/ModMetadata.cs b/src/SMAPI/Framework/ModLoading/ModMetadata.cs index 585debb4..0cb62a75 100644 --- a/src/SMAPI/Framework/ModLoading/ModMetadata.cs +++ b/src/SMAPI/Framework/ModLoading/ModMetadata.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Generic; using System.Linq; using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; using StardewModdingAPI.Toolkit.Framework.ModData; +using StardewModdingAPI.Toolkit.Framework.UpdateData; namespace StardewModdingAPI.Framework.ModLoading { @@ -17,6 +19,9 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>The mod's full directory path.</summary> public string DirectoryPath { get; } + /// <summary>The <see cref="IModMetadata.DirectoryPath"/> relative to the game's Mods folder.</summary> + public string RelativeDirectoryPath { get; } + /// <summary>The mod manifest.</summary> public IManifest Manifest { get; } @@ -32,6 +37,9 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>The reason the metadata is invalid, if any.</summary> public string Error { get; private set; } + /// <summary>Whether the mod folder should be ignored. This is <c>true</c> if it was found within a folder whose name starts with a dot.</summary> + public bool IsIgnored { get; } + /// <summary>The mod instance (if loaded and <see cref="IsContentPack"/> is false).</summary> public IMod Mod { get; private set; } @@ -57,14 +65,18 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>Construct an instance.</summary> /// <param name="displayName">The mod's display name.</param> /// <param name="directoryPath">The mod's full directory path.</param> + /// <param name="relativeDirectoryPath">The <paramref name="directoryPath"/> relative to the game's Mods folder.</param> /// <param name="manifest">The mod manifest.</param> /// <param name="dataRecord">Metadata about the mod from SMAPI's internal data (if any).</param> - public ModMetadata(string displayName, string directoryPath, IManifest manifest, ModDataRecordVersionedFields dataRecord) + /// <param name="isIgnored">Whether the mod folder should be ignored. This should be <c>true</c> if it was found within a folder whose name starts with a dot.</param> + public ModMetadata(string displayName, string directoryPath, string relativeDirectoryPath, IManifest manifest, ModDataRecordVersionedFields dataRecord, bool isIgnored) { this.DisplayName = displayName; this.DirectoryPath = directoryPath; + this.RelativeDirectoryPath = relativeDirectoryPath; this.Manifest = manifest; this.DataRecord = dataRecord; + this.IsIgnored = isIgnored; } /// <summary>Set the mod status.</summary> @@ -141,13 +153,31 @@ namespace StardewModdingAPI.Framework.ModLoading && !string.IsNullOrWhiteSpace(this.Manifest.UniqueID); } - /// <summary>Whether the mod has at least one update key set.</summary> - public bool HasUpdateKeys() + /// <summary>Whether the mod has the given ID.</summary> + /// <param name="id">The mod ID to check.</param> + public bool HasID(string id) { return - this.HasManifest() - && this.Manifest.UpdateKeys != null - && this.Manifest.UpdateKeys.Any(key => !string.IsNullOrWhiteSpace(key)); + this.HasID() + && string.Equals(this.Manifest.UniqueID.Trim(), id?.Trim(), StringComparison.InvariantCultureIgnoreCase); + } + + /// <summary>Get the defined update keys.</summary> + /// <param name="validOnly">Only return valid update keys.</param> + public IEnumerable<UpdateKey> GetUpdateKeys(bool validOnly = false) + { + foreach (string rawKey in this.Manifest?.UpdateKeys ?? new string[0]) + { + UpdateKey updateKey = UpdateKey.Parse(rawKey); + if (updateKey.LooksValid || !validOnly) + yield return updateKey; + } + } + + /// <summary>Whether the mod has at least one valid update key set.</summary> + public bool HasValidUpdateKeys() + { + return this.GetUpdateKeys(validOnly: true).Any(); } } } diff --git a/src/SMAPI/Framework/ModLoading/ModResolver.cs b/src/SMAPI/Framework/ModLoading/ModResolver.cs index 9ac95fd4..ace84054 100644 --- a/src/SMAPI/Framework/ModLoading/ModResolver.cs +++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text.RegularExpressions; using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Framework.ModScanning; @@ -31,13 +30,6 @@ namespace StardewModdingAPI.Framework.ModLoading // parse internal data record (if any) ModDataRecordVersionedFields dataRecord = modDatabase.Get(manifest?.UniqueID)?.GetVersionedFields(manifest); - // get display name - string displayName = manifest?.Name; - if (string.IsNullOrWhiteSpace(displayName)) - displayName = dataRecord?.DisplayName; - if (string.IsNullOrWhiteSpace(displayName)) - displayName = PathUtilities.GetRelativePath(rootPath, folder.ActualDirectory?.FullName ?? folder.SearchDirectory.FullName); - // apply defaults if (manifest != null && dataRecord != null) { @@ -46,10 +38,13 @@ namespace StardewModdingAPI.Framework.ModLoading } // build metadata - ModMetadataStatus status = folder.ManifestParseError == null + ModMetadataStatus status = folder.ManifestParseError == null || !folder.ShouldBeLoaded ? ModMetadataStatus.Found : ModMetadataStatus.Failed; - yield return new ModMetadata(displayName, folder.ActualDirectory?.FullName, manifest, dataRecord).SetStatus(status, folder.ManifestParseError); + string relativePath = PathUtilities.GetRelativePath(rootPath, folder.Directory.FullName); + + yield return new ModMetadata(folder.DisplayName, folder.Directory.FullName, relativePath, manifest, dataRecord, isIgnored: !folder.ShouldBeLoaded) + .SetStatus(status, !folder.ShouldBeLoaded ? "disabled by dot convention" : folder.ManifestParseError); } } @@ -92,7 +87,7 @@ namespace StardewModdingAPI.Framework.ModLoading updateUrls.Add(mod.DataRecord.AlternativeUrl); // default update URL - updateUrls.Add("https://smapi.io/compat"); + updateUrls.Add("https://mods.smapi.io"); // build error string error = $"{reasonPhrase}. Please check for a "; @@ -181,7 +176,7 @@ namespace StardewModdingAPI.Framework.ModLoading } // validate ID format - if (Regex.IsMatch(mod.Manifest.UniqueID, "[^a-z0-9_.-]", RegexOptions.IgnoreCase)) + if (!PathUtilities.IsSlug(mod.Manifest.UniqueID)) mod.SetStatus(ModMetadataStatus.Failed, "its manifest specifies an invalid ID (IDs must only contain letters, numbers, underscores, periods, or hyphens)."); } @@ -196,7 +191,7 @@ namespace StardewModdingAPI.Framework.ModLoading { if (mod.Status == ModMetadataStatus.Failed) continue; // don't replace metadata error - mod.SetStatus(ModMetadataStatus.Failed, $"its unique ID '{mod.Manifest.UniqueID}' is used by multiple mods ({string.Join(", ", group.Select(p => p.DisplayName))})."); + mod.SetStatus(ModMetadataStatus.Failed, $"you have multiple copies of this mod installed ({string.Join(", ", group.Select(p => p.RelativeDirectoryPath).OrderBy(p => p))})."); } } } @@ -386,7 +381,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="loadedMods">The loaded mods.</param> private IEnumerable<ModDependency> GetDependenciesFrom(IManifest manifest, IModMetadata[] loadedMods) { - IModMetadata FindMod(string id) => loadedMods.FirstOrDefault(m => string.Equals(m.Manifest?.UniqueID, id, StringComparison.InvariantCultureIgnoreCase)); + IModMetadata FindMod(string id) => loadedMods.FirstOrDefault(m => m.HasID(id)); // yield dependencies if (manifest.Dependencies != null) diff --git a/src/SMAPI/Framework/ModLoading/ModWarning.cs b/src/SMAPI/Framework/ModLoading/ModWarning.cs index 0e4b2570..c62199b2 100644 --- a/src/SMAPI/Framework/ModLoading/ModWarning.cs +++ b/src/SMAPI/Framework/ModLoading/ModWarning.cs @@ -26,6 +26,12 @@ namespace StardewModdingAPI.Framework.ModLoading UsesUnvalidatedUpdateTick = 16, /// <summary>The mod has no update keys set.</summary> - NoUpdateKeys = 32 + NoUpdateKeys = 32, + + /// <summary>Uses .NET APIs for filesystem access.</summary> + AccessesFilesystem = 64, + + /// <summary>Uses .NET APIs for shell or process access.</summary> + AccessesShell = 128 } } diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/VirtualEntryCallRemover.cs b/src/SMAPI/Framework/ModLoading/Rewriters/VirtualEntryCallRemover.cs deleted file mode 100644 index 322a7df1..00000000 --- a/src/SMAPI/Framework/ModLoading/Rewriters/VirtualEntryCallRemover.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System; -using Mono.Cecil; -using Mono.Cecil.Cil; - -namespace StardewModdingAPI.Framework.ModLoading.Rewriters -{ - /// <summary>Rewrites virtual calls to the <see cref="Mod.Entry"/> method.</summary> - internal class VirtualEntryCallRemover : IInstructionHandler - { - /********* - ** Properties - *********/ - /// <summary>The type containing the method.</summary> - private readonly Type ToType; - - /// <summary>The name of the method.</summary> - private readonly string MethodName; - - - /********* - ** Accessors - *********/ - /// <summary>A brief noun phrase indicating what the instruction finder matches.</summary> - public string NounPhrase { get; } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - public VirtualEntryCallRemover() - { - this.ToType = typeof(Mod); - this.MethodName = nameof(Mod.Entry); - this.NounPhrase = $"{this.ToType.Name}::{this.MethodName}"; - } - - /// <summary>Perform the predefined logic for a method if applicable.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="method">The method definition containing the instruction.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - public InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) - { - return InstructionHandleResult.None; - } - - /// <summary>Perform the predefined logic for an instruction if applicable.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The instruction to handle.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - public InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) - { - if (!this.IsMatch(instruction)) - return InstructionHandleResult.None; - - // get instructions comprising method call - int index = cil.Body.Instructions.IndexOf(instruction); - Instruction loadArg0 = cil.Body.Instructions[index - 2]; - Instruction loadArg1 = cil.Body.Instructions[index - 1]; - if (loadArg0.OpCode != OpCodes.Ldarg_0) - throw new InvalidOperationException($"Unexpected instruction sequence while removing virtual {this.ToType.Name}.{this.MethodName} call: found {loadArg0.OpCode.Name} instead of {OpCodes.Ldarg_0.Name}"); - if (loadArg1.OpCode != OpCodes.Ldarg_1) - throw new InvalidOperationException($"Unexpected instruction sequence while removing virtual {this.ToType.Name}.{this.MethodName} call: found {loadArg1.OpCode.Name} instead of {OpCodes.Ldarg_1.Name}"); - - // remove method call - cil.Remove(loadArg0); - cil.Remove(loadArg1); - cil.Remove(instruction); - return InstructionHandleResult.Rewritten; - } - - - /********* - ** Protected methods - *********/ - /// <summary>Get whether a CIL instruction matches.</summary> - /// <param name="instruction">The IL instruction.</param> - protected bool IsMatch(Instruction instruction) - { - MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); - return - methodRef != null - && methodRef.DeclaringType.FullName == this.ToType.FullName - && methodRef.Name == this.MethodName; - } - } -} diff --git a/src/SMAPI/Framework/ModRegistry.cs b/src/SMAPI/Framework/ModRegistry.cs index e7d4f89a..8ce3172c 100644 --- a/src/SMAPI/Framework/ModRegistry.cs +++ b/src/SMAPI/Framework/ModRegistry.cs @@ -18,6 +18,9 @@ namespace StardewModdingAPI.Framework /// <summary>An assembly full name => mod lookup.</summary> private readonly IDictionary<string, IModMetadata> ModNamesByAssembly = new Dictionary<string, IModMetadata>(); + /// <summary>Whether all mod assemblies have been loaded.</summary> + public bool AreAllModsLoaded { get; set; } + /// <summary>Whether all mods have been initialised and their <see cref="IMod.Entry"/> method called.</summary> public bool AreAllModsInitialised { get; set; } @@ -59,7 +62,7 @@ namespace StardewModdingAPI.Framework uniqueID = uniqueID.Trim(); // find match - return this.GetAll().FirstOrDefault(p => p.Manifest.UniqueID.Trim().Equals(uniqueID, StringComparison.InvariantCultureIgnoreCase)); + return this.GetAll().FirstOrDefault(p => p.HasID(uniqueID)); } /// <summary>Get the mod metadata from one of its assemblies.</summary> diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs index 15671af4..e2b33160 100644 --- a/src/SMAPI/Framework/Models/SConfig.cs +++ b/src/SMAPI/Framework/Models/SConfig.cs @@ -14,6 +14,14 @@ namespace StardewModdingAPI.Framework.Models /// <summary>Whether to check for newer versions of SMAPI and mods on startup.</summary> public bool CheckForUpdates { get; set; } + /// <summary>Whether to add a section to the 'mod issues' list for mods which which directly use potentially sensitive .NET APIs like file or shell access.</summary> + public bool ParanoidWarnings { get; set; } = +#if DEBUG + true; +#else + false; +#endif + /// <summary>Whether to show beta versions as valid updates.</summary> public bool UseBetaChannel { get; set; } = Constants.ApiVersion.IsPrerelease(); diff --git a/src/SMAPI/Framework/Monitor.cs b/src/SMAPI/Framework/Monitor.cs index 2812a9cc..a4d92e4b 100644 --- a/src/SMAPI/Framework/Monitor.cs +++ b/src/SMAPI/Framework/Monitor.cs @@ -37,6 +37,9 @@ namespace StardewModdingAPI.Framework /// <summary>Whether SMAPI is aborting. Mods don't need to worry about this unless they have background tasks.</summary> public bool IsExiting => this.ExitTokenSource.IsCancellationRequested; + /// <summary>Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed.</summary> + public bool IsVerbose { get; } + /// <summary>Whether to show the full log stamps (with time/level/logger) in the console. If false, shows a simplified stamp with only the logger.</summary> internal bool ShowFullStampInConsole { get; set; } @@ -56,7 +59,8 @@ namespace StardewModdingAPI.Framework /// <param name="logFile">The log file to which to write messages.</param> /// <param name="exitTokenSource">Propagates notification that SMAPI should exit.</param> /// <param name="colorScheme">The console color scheme to use.</param> - public Monitor(string source, ConsoleInterceptionManager consoleInterceptor, LogFileManager logFile, CancellationTokenSource exitTokenSource, MonitorColorScheme colorScheme) + /// <param name="isVerbose">Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed.</param> + public Monitor(string source, ConsoleInterceptionManager consoleInterceptor, LogFileManager logFile, CancellationTokenSource exitTokenSource, MonitorColorScheme colorScheme, bool isVerbose) { // validate if (string.IsNullOrWhiteSpace(source)) @@ -68,6 +72,7 @@ namespace StardewModdingAPI.Framework this.ConsoleWriter = new ColorfulConsoleWriter(Constants.Platform, colorScheme); this.ConsoleInterceptor = consoleInterceptor; this.ExitTokenSource = exitTokenSource; + this.IsVerbose = isVerbose; } /// <summary>Log a message for the player or developer.</summary> @@ -78,6 +83,14 @@ namespace StardewModdingAPI.Framework this.LogImpl(this.Source, message, (ConsoleLogLevel)level); } + /// <summary>Log a message that only appears when <see cref="IMonitor.IsVerbose"/> is enabled.</summary> + /// <param name="message">The message to log.</param> + public void VerboseLog(string message) + { + if (this.IsVerbose) + this.Log(message, LogLevel.Trace); + } + /// <summary>Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</summary> /// <param name="reason">The reason for the shutdown.</param> public void ExitGameImmediately(string reason) diff --git a/src/SMAPI/Framework/Networking/MessageType.cs b/src/SMAPI/Framework/Networking/MessageType.cs new file mode 100644 index 00000000..bd9acfa9 --- /dev/null +++ b/src/SMAPI/Framework/Networking/MessageType.cs @@ -0,0 +1,26 @@ +using StardewValley; + +namespace StardewModdingAPI.Framework.Networking +{ + /// <summary>Network message types recognised by SMAPI and Stardew Valley.</summary> + internal enum MessageType : byte + { + /********* + ** SMAPI + *********/ + /// <summary>A data message intended for mods to consume.</summary> + ModMessage = 254, + + /// <summary>Metadata context about a player synced by SMAPI.</summary> + ModContext = 255, + + /********* + ** Vanilla + *********/ + /// <summary>Metadata about the host server sent to a farmhand.</summary> + ServerIntroduction = Multiplayer.serverIntroduction, + + /// <summary>Metadata about a player sent to a farmhand or server.</summary> + PlayerIntroduction = Multiplayer.playerIntroduction + } +} diff --git a/src/SMAPI/Framework/Networking/ModMessageModel.cs b/src/SMAPI/Framework/Networking/ModMessageModel.cs new file mode 100644 index 00000000..7ee39863 --- /dev/null +++ b/src/SMAPI/Framework/Networking/ModMessageModel.cs @@ -0,0 +1,72 @@ +using System.Linq; +using Newtonsoft.Json.Linq; + +namespace StardewModdingAPI.Framework.Networking +{ + /// <summary>The metadata for a mod message.</summary> + internal class ModMessageModel + { + /********* + ** Accessors + *********/ + /**** + ** Origin + ****/ + /// <summary>The unique ID of the player who broadcast the message.</summary> + public long FromPlayerID { get; set; } + + /// <summary>The unique ID of the mod which broadcast the message.</summary> + public string FromModID { get; set; } + + /**** + ** Destination + ****/ + /// <summary>The players who should receive the message, or <c>null</c> for all players.</summary> + public long[] ToPlayerIDs { get; set; } + + /// <summary>The mods which should receive the message, or <c>null</c> for all mods.</summary> + public string[] ToModIDs { get; set; } + + /// <summary>A message type which receiving mods can use to decide whether it's the one they want to handle, like <c>SetPlayerLocation</c>. This doesn't need to be globally unique, since mods should check the originating mod ID.</summary> + public string Type { get; set; } + + /// <summary>The custom mod data being broadcast.</summary> + public JToken Data { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public ModMessageModel() { } + + /// <summary>Construct an instance.</summary> + /// <param name="fromPlayerID">The unique ID of the player who broadcast the message.</param> + /// <param name="fromModID">The unique ID of the mod which broadcast the message.</param> + /// <param name="toPlayerIDs">The players who should receive the message, or <c>null</c> for all players.</param> + /// <param name="toModIDs">The mods which should receive the message, or <c>null</c> for all mods.</param> + /// <param name="type">A message type which receiving mods can use to decide whether it's the one they want to handle, like <c>SetPlayerLocation</c>. This doesn't need to be globally unique, since mods should check the originating mod ID.</param> + /// <param name="data">The custom mod data being broadcast.</param> + public ModMessageModel(long fromPlayerID, string fromModID, long[] toPlayerIDs, string[] toModIDs, string type, JToken data) + { + this.FromPlayerID = fromPlayerID; + this.FromModID = fromModID; + this.ToPlayerIDs = toPlayerIDs; + this.ToModIDs = toModIDs; + this.Type = type; + this.Data = data; + } + + /// <summary>Construct an instance.</summary> + /// <param name="message">The message to clone.</param> + public ModMessageModel(ModMessageModel message) + { + this.FromPlayerID = message.FromPlayerID; + this.FromModID = message.FromModID; + this.ToPlayerIDs = message.ToPlayerIDs?.ToArray(); + this.ToModIDs = message.ToModIDs?.ToArray(); + this.Type = message.Type; + this.Data = message.Data; + } + } +} diff --git a/src/SMAPI/Framework/Networking/MultiplayerPeer.cs b/src/SMAPI/Framework/Networking/MultiplayerPeer.cs new file mode 100644 index 00000000..44a71978 --- /dev/null +++ b/src/SMAPI/Framework/Networking/MultiplayerPeer.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewValley.Network; + +namespace StardewModdingAPI.Framework.Networking +{ + /// <summary>Metadata about a connected player.</summary> + internal class MultiplayerPeer : IMultiplayerPeer + { + /********* + ** Properties + *********/ + /// <summary>A method which sends a message to the peer.</summary> + private readonly Action<OutgoingMessage> SendMessageImpl; + + + /********* + ** Accessors + *********/ + /// <summary>The player's unique ID.</summary> + public long PlayerID { get; } + + /// <summary>Whether this is a connection to the host player.</summary> + public bool IsHost { get; } + + /// <summary>Whether the player has SMAPI installed.</summary> + public bool HasSmapi => this.ApiVersion != null; + + /// <summary>The player's OS platform, if <see cref="HasSmapi"/> is true.</summary> + public GamePlatform? Platform { get; } + + /// <summary>The installed version of Stardew Valley, if <see cref="HasSmapi"/> is true.</summary> + public ISemanticVersion GameVersion { get; } + + /// <summary>The installed version of SMAPI, if <see cref="HasSmapi"/> is true.</summary> + public ISemanticVersion ApiVersion { get; } + + /// <summary>The installed mods, if <see cref="HasSmapi"/> is true.</summary> + public IEnumerable<IMultiplayerPeerMod> Mods { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="playerID">The player's unique ID.</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) + { + this.PlayerID = playerID; + this.IsHost = isHost; + if (model != null) + { + this.Platform = model.Platform; + this.GameVersion = model.GameVersion; + this.ApiVersion = model.ApiVersion; + this.Mods = model.Mods.Select(mod => new MultiplayerPeerMod(mod)).ToArray(); + } + this.SendMessageImpl = sendMessage; + } + + /// <summary>Get metadata for a mod installed by the player.</summary> + /// <param name="id">The unique mod ID.</param> + /// <returns>Returns the mod info, or <c>null</c> if the player doesn't have that mod.</returns> + public IMultiplayerPeerMod GetMod(string id) + { + if (string.IsNullOrWhiteSpace(id) || this.Mods == null || !this.Mods.Any()) + return null; + + id = id.Trim(); + return this.Mods.FirstOrDefault(mod => mod.ID != null && mod.ID.Equals(id, StringComparison.InvariantCultureIgnoreCase)); + } + + /// <summary>Send a message to the given peer, bypassing the game's normal validation to allow messages before the connection is approved.</summary> + /// <param name="message">The message to send.</param> + public void SendMessage(OutgoingMessage message) + { + this.SendMessageImpl(message); + } + } +} diff --git a/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs b/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs new file mode 100644 index 00000000..1b324bcd --- /dev/null +++ b/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs @@ -0,0 +1,30 @@ +namespace StardewModdingAPI.Framework.Networking +{ + internal class MultiplayerPeerMod : IMultiplayerPeerMod + { + /********* + ** Accessors + *********/ + /// <summary>The mod's display name.</summary> + public string Name { get; } + + /// <summary>The unique mod ID.</summary> + public string ID { get; } + + /// <summary>The mod version.</summary> + public ISemanticVersion Version { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="mod">The mod metadata.</param> + public MultiplayerPeerMod(RemoteContextModModel mod) + { + this.Name = mod.Name; + this.ID = mod.ID?.Trim(); + this.Version = mod.Version; + } + } +} diff --git a/src/SMAPI/Framework/Networking/RemoteContextModModel.cs b/src/SMAPI/Framework/Networking/RemoteContextModModel.cs new file mode 100644 index 00000000..9795d971 --- /dev/null +++ b/src/SMAPI/Framework/Networking/RemoteContextModModel.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Framework.Networking +{ + /// <summary>Metadata about an installed mod exchanged with connected computers.</summary> + public class RemoteContextModModel + { + /// <summary>The mod's display name.</summary> + public string Name { get; set; } + + /// <summary>The unique mod ID.</summary> + public string ID { get; set; } + + /// <summary>The mod version.</summary> + public ISemanticVersion Version { get; set; } + } +} diff --git a/src/SMAPI/Framework/Networking/RemoteContextModel.cs b/src/SMAPI/Framework/Networking/RemoteContextModel.cs new file mode 100644 index 00000000..7befb151 --- /dev/null +++ b/src/SMAPI/Framework/Networking/RemoteContextModel.cs @@ -0,0 +1,24 @@ +namespace StardewModdingAPI.Framework.Networking +{ + /// <summary>Metadata about the game, SMAPI, and installed mods exchanged with connected computers.</summary> + internal class RemoteContextModel + { + /********* + ** Accessors + *********/ + /// <summary>Whether this player is the host player.</summary> + public bool IsHost { get; set; } + + /// <summary>The game's platform version.</summary> + public GamePlatform Platform { get; set; } + + /// <summary>The installed version of Stardew Valley.</summary> + public ISemanticVersion GameVersion { get; set; } + + /// <summary>The installed version of SMAPI.</summary> + public ISemanticVersion ApiVersion { get; set; } + + /// <summary>The installed mods.</summary> + public RemoteContextModModel[] Mods { get; set; } + } +} diff --git a/src/SMAPI/Framework/Networking/SGalaxyNetClient.cs b/src/SMAPI/Framework/Networking/SGalaxyNetClient.cs new file mode 100644 index 00000000..fddd423d --- /dev/null +++ b/src/SMAPI/Framework/Networking/SGalaxyNetClient.cs @@ -0,0 +1,52 @@ +using System; +using Galaxy.Api; +using StardewValley.Network; +using StardewValley.SDKs; + +namespace StardewModdingAPI.Framework.Networking +{ + /// <summary>A multiplayer client used to connect to a hosted server. This is an implementation of <see cref="GalaxyNetClient"/> with callbacks for SMAPI functionality.</summary> + internal class SGalaxyNetClient : GalaxyNetClient + { + /********* + ** Properties + *********/ + /// <summary>A callback to raise when receiving a message. This receives the incoming message, a method to send an arbitrary message, and a callback to run the default logic.</summary> + private readonly Action<IncomingMessage, Action<OutgoingMessage>, Action> OnProcessingMessage; + + /// <summary>A callback to raise when sending a message. This receives the outgoing message, a method to send an arbitrary message, and a callback to resume the default logic.</summary> + private readonly Action<OutgoingMessage, Action<OutgoingMessage>, Action> OnSendingMessage; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="address">The remote address being connected.</param> + /// <param name="onProcessingMessage">A callback to raise when receiving a message. This receives the incoming message, a method to send an arbitrary message, and a callback to run the default logic.</param> + /// <param name="onSendingMessage">A callback to raise when sending a message. This receives the outgoing message, a method to send an arbitrary message, and a callback to resume the default logic.</param> + public SGalaxyNetClient(GalaxyID address, Action<IncomingMessage, Action<OutgoingMessage>, Action> onProcessingMessage, Action<OutgoingMessage, Action<OutgoingMessage>, Action> onSendingMessage) + : base(address) + { + this.OnProcessingMessage = onProcessingMessage; + this.OnSendingMessage = onSendingMessage; + } + + /// <summary>Send a message to the connected peer.</summary> + public override void sendMessage(OutgoingMessage message) + { + this.OnSendingMessage(message, base.sendMessage, () => base.sendMessage(message)); + } + + + /********* + ** Protected methods + *********/ + /// <summary>Process an incoming network message.</summary> + /// <param name="message">The message to process.</param> + protected override void processIncomingMessage(IncomingMessage message) + { + this.OnProcessingMessage(message, base.sendMessage, () => base.processIncomingMessage(message)); + } + } +} diff --git a/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs b/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs new file mode 100644 index 00000000..2fc92737 --- /dev/null +++ b/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs @@ -0,0 +1,63 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using Galaxy.Api; +using StardewValley.Network; +using StardewValley.SDKs; + +namespace StardewModdingAPI.Framework.Networking +{ + /// <summary>A multiplayer server used to connect to an incoming player. This is an implementation of <see cref="LidgrenServer"/> that adds support for SMAPI's metadata context exchange.</summary> + internal class SGalaxyNetServer : GalaxyNetServer + { + /********* + ** Properties + *********/ + /// <summary>A callback to raise when receiving a message. This receives the incoming message, a method to send a message, and a callback to run the default logic.</summary> + private readonly Action<IncomingMessage, Action<OutgoingMessage>, Action> OnProcessingMessage; + + /// <summary>SMAPI's implementation of the game's core multiplayer logic.</summary> + private readonly SMultiplayer Multiplayer; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="gameServer">The underlying game server.</param> + /// <param name="multiplayer">SMAPI's implementation of the game's core multiplayer logic.</param> + /// <param name="onProcessingMessage">A callback to raise when receiving a message. This receives the incoming message, a method to send a message, and a callback to run the default logic.</param> + public SGalaxyNetServer(IGameServer gameServer, SMultiplayer multiplayer, Action<IncomingMessage, Action<OutgoingMessage>, Action> onProcessingMessage) + : base(gameServer) + { + this.Multiplayer = multiplayer; + this.OnProcessingMessage = onProcessingMessage; + } + + /// <summary>Read and process a message from the client.</summary> + /// <param name="peer">The Galaxy peer ID.</param> + /// <param name="messageStream">The data to process.</param> + [SuppressMessage("ReSharper", "AccessToDisposedClosure", Justification = "The callback is invoked synchronously.")] + protected override void onReceiveMessage(GalaxyID peer, Stream messageStream) + { + using (IncomingMessage message = new IncomingMessage()) + using (BinaryReader reader = new BinaryReader(messageStream)) + { + message.Read(reader); + this.OnProcessingMessage(message, outgoing => this.sendMessage(peer, outgoing), () => + { + if (this.peers.ContainsLeft(message.FarmerID) && (long)this.peers[message.FarmerID] == (long)peer.ToUint64()) + { + this.gameServer.processIncomingMessage(message); + } + else if (message.MessageType == StardewValley.Multiplayer.playerIntroduction) + { + NetFarmerRoot farmer = this.Multiplayer.readFarmer(message.Reader); + GalaxyID capturedPeer = new GalaxyID(peer.ToUint64()); + this.gameServer.checkFarmhandRequest(Convert.ToString(peer.ToUint64()), farmer, msg => this.sendMessage(capturedPeer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = capturedPeer.ToUint64()); + } + }); + } + } + } +} diff --git a/src/SMAPI/Framework/Networking/SLidgrenClient.cs b/src/SMAPI/Framework/Networking/SLidgrenClient.cs new file mode 100644 index 00000000..02d9d68f --- /dev/null +++ b/src/SMAPI/Framework/Networking/SLidgrenClient.cs @@ -0,0 +1,50 @@ +using System; +using StardewValley.Network; + +namespace StardewModdingAPI.Framework.Networking +{ + /// <summary>A multiplayer client used to connect to a hosted server. This is an implementation of <see cref="LidgrenClient"/> with callbacks for SMAPI functionality.</summary> + internal class SLidgrenClient : LidgrenClient + { + /********* + ** Properties + *********/ + /// <summary>A callback to raise when receiving a message. This receives the incoming message, a method to send an arbitrary message, and a callback to run the default logic.</summary> + private readonly Action<IncomingMessage, Action<OutgoingMessage>, Action> OnProcessingMessage; + + /// <summary>A callback to raise when sending a message. This receives the outgoing message, a method to send an arbitrary message, and a callback to resume the default logic.</summary> + private readonly Action<OutgoingMessage, Action<OutgoingMessage>, Action> OnSendingMessage; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="address">The remote address being connected.</param> + /// <param name="onProcessingMessage">A callback to raise when receiving a message. This receives the incoming message, a method to send an arbitrary message, and a callback to run the default logic.</param> + /// <param name="onSendingMessage">A callback to raise when sending a message. This receives the outgoing message, a method to send an arbitrary message, and a callback to resume the default logic.</param> + public SLidgrenClient(string address, Action<IncomingMessage, Action<OutgoingMessage>, Action> onProcessingMessage, Action<OutgoingMessage, Action<OutgoingMessage>, Action> onSendingMessage) + : base(address) + { + this.OnProcessingMessage = onProcessingMessage; + this.OnSendingMessage = onSendingMessage; + } + + /// <summary>Send a message to the connected peer.</summary> + public override void sendMessage(OutgoingMessage message) + { + this.OnSendingMessage(message, base.sendMessage, () => base.sendMessage(message)); + } + + + /********* + ** Protected methods + *********/ + /// <summary>Process an incoming network message.</summary> + /// <param name="message">The message to process.</param> + protected override void processIncomingMessage(IncomingMessage message) + { + this.OnProcessingMessage(message, base.sendMessage, () => base.processIncomingMessage(message)); + } + } +} diff --git a/src/SMAPI/Framework/Networking/SLidgrenServer.cs b/src/SMAPI/Framework/Networking/SLidgrenServer.cs new file mode 100644 index 00000000..251e5268 --- /dev/null +++ b/src/SMAPI/Framework/Networking/SLidgrenServer.cs @@ -0,0 +1,65 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using Lidgren.Network; +using StardewValley.Network; + +namespace StardewModdingAPI.Framework.Networking +{ + /// <summary>A multiplayer server used to connect to an incoming player. This is an implementation of <see cref="LidgrenServer"/> that adds support for SMAPI's metadata context exchange.</summary> + internal class SLidgrenServer : LidgrenServer + { + /********* + ** Properties + *********/ + /// <summary>SMAPI's implementation of the game's core multiplayer logic.</summary> + private readonly SMultiplayer Multiplayer; + + /// <summary>A callback to raise when receiving a message. This receives the incoming message, a method to send a message, and a callback to run the default logic.</summary> + private readonly Action<IncomingMessage, Action<OutgoingMessage>, Action> OnProcessingMessage; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="multiplayer">SMAPI's implementation of the game's core multiplayer logic.</param> + /// <param name="gameServer">The underlying game server.</param> + /// <param name="onProcessingMessage">A callback to raise when receiving a message. This receives the incoming message, a method to send a message, and a callback to run the default logic.</param> + public SLidgrenServer(IGameServer gameServer, SMultiplayer multiplayer, Action<IncomingMessage, Action<OutgoingMessage>, Action> onProcessingMessage) + : base(gameServer) + { + this.Multiplayer = multiplayer; + this.OnProcessingMessage = onProcessingMessage; + } + + /// <summary>Parse a data message from a client.</summary> + /// <param name="rawMessage">The raw network message to parse.</param> + [SuppressMessage("ReSharper", "AccessToDisposedClosure", Justification = "The callback is invoked synchronously.")] + protected override void parseDataMessageFromClient(NetIncomingMessage rawMessage) + { + // add hook to call multiplayer core + NetConnection peer = rawMessage.SenderConnection; + using (IncomingMessage message = new IncomingMessage()) + using (Stream readStream = new NetBufferReadStream(rawMessage)) + using (BinaryReader reader = new BinaryReader(readStream)) + { + while (rawMessage.LengthBits - rawMessage.Position >= 8) + { + message.Read(reader); + NetConnection connection = rawMessage.SenderConnection; // don't pass rawMessage into context because it gets reused + this.OnProcessingMessage(message, outgoing => this.sendMessage(connection, outgoing), () => + { + if (this.peers.ContainsLeft(message.FarmerID) && this.peers[message.FarmerID] == peer) + this.gameServer.processIncomingMessage(message); + else if (message.MessageType == StardewValley.Multiplayer.playerIntroduction) + { + NetFarmerRoot farmer = this.Multiplayer.readFarmer(message.Reader); + this.gameServer.checkFarmhandRequest("", farmer, msg => this.sendMessage(peer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = peer); + } + }); + } + } + } + } +} diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs new file mode 100644 index 00000000..4b95917b --- /dev/null +++ b/src/SMAPI/Framework/SCore.cs @@ -0,0 +1,1361 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Net; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Security; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +#if SMAPI_FOR_WINDOWS +using System.Windows.Forms; +#endif +using Newtonsoft.Json; +using StardewModdingAPI.Events; +using StardewModdingAPI.Framework.Events; +using StardewModdingAPI.Framework.Exceptions; +using StardewModdingAPI.Framework.Logging; +using StardewModdingAPI.Framework.Models; +using StardewModdingAPI.Framework.ModHelpers; +using StardewModdingAPI.Framework.ModLoading; +using StardewModdingAPI.Framework.Patching; +using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Framework.Serialisation; +using StardewModdingAPI.Internal; +using StardewModdingAPI.Patches; +using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; +using StardewModdingAPI.Toolkit.Framework.ModData; +using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Utilities; +using StardewValley; +using Object = StardewValley.Object; +using ThreadState = System.Threading.ThreadState; + +namespace StardewModdingAPI.Framework +{ + /// <summary>The core class which initialises and manages SMAPI.</summary> + internal class SCore : IDisposable + { + /********* + ** Properties + *********/ + /// <summary>The log file to which to write messages.</summary> + private readonly LogFileManager LogFile; + + /// <summary>Manages console output interception.</summary> + private readonly ConsoleInterceptionManager ConsoleManager = new ConsoleInterceptionManager(); + + /// <summary>The core logger and monitor for SMAPI.</summary> + private readonly Monitor Monitor; + + /// <summary>The core logger and monitor on behalf of the game.</summary> + private readonly Monitor MonitorForGame; + + /// <summary>Tracks whether the game should exit immediately and any pending initialisation should be cancelled.</summary> + private readonly CancellationTokenSource CancellationTokenSource = new CancellationTokenSource(); + + /// <summary>Simplifies access to private game code.</summary> + private readonly Reflector Reflection = new Reflector(); + + /// <summary>The SMAPI configuration settings.</summary> + private readonly SConfig Settings; + + /// <summary>The underlying game instance.</summary> + private SGame GameInstance; + + /// <summary>The underlying content manager.</summary> + private ContentCoordinator ContentCore => this.GameInstance.ContentCore; + + /// <summary>Tracks the installed mods.</summary> + /// <remarks>This is initialised after the game starts.</remarks> + private readonly ModRegistry ModRegistry = new ModRegistry(); + + /// <summary>Manages deprecation warnings.</summary> + /// <remarks>This is initialised after the game starts.</remarks> + private readonly DeprecationManager DeprecationManager; + + /// <summary>Manages SMAPI events for mods.</summary> + private readonly EventManager EventManager; + + /// <summary>Whether the game is currently running.</summary> + private bool IsGameRunning; + + /// <summary>Whether the program has been disposed.</summary> + private bool IsDisposed; + + /// <summary>Regex patterns which match console messages to suppress from the console and log.</summary> + private readonly Regex[] SuppressConsolePatterns = + { + new Regex(@"^TextBox\.Selected is now '(?:True|False)'\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^(?:FRUIT )?TREE: IsClient:(?:True|False) randomOutput: \d+$", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^loadPreferences\(\); begin", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^savePreferences\(\); async=", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^DebugOutput:\s+(?:added CLOUD|added cricket|dismount tile|Ping|playerPos)", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^static SerializableDictionary<.+>\(\) called\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant), + }; + + /// <summary>The mod toolkit used for generic mod interactions.</summary> + private readonly ModToolkit Toolkit = new ModToolkit(); + + /// <summary>The path to search for mods.</summary> + private readonly string ModsPath; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="modsPath">The path to search for mods.</param> + /// <param name="writeToConsole">Whether to output log messages to the console.</param> + public SCore(string modsPath, bool writeToConsole) + { + // init paths + this.VerifyPath(modsPath); + this.VerifyPath(Constants.LogDir); + this.ModsPath = modsPath; + + // init log file + this.PurgeNormalLogs(); + string logPath = this.GetLogPath(); + + // init basics + this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath)); + this.LogFile = new LogFileManager(logPath); + this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.CancellationTokenSource, this.Settings.ColorScheme, this.Settings.VerboseLogging) + { + WriteToConsole = writeToConsole, + ShowTraceInConsole = this.Settings.DeveloperMode, + ShowFullStampInConsole = this.Settings.DeveloperMode + }; + this.MonitorForGame = this.GetSecondaryMonitor("game"); + this.EventManager = new EventManager(this.Monitor, this.ModRegistry); + this.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry); + + // redirect direct console output + if (this.MonitorForGame.WriteToConsole) + this.ConsoleManager.OnMessageIntercepted += message => this.HandleConsoleMessage(this.MonitorForGame, message); + + // inject deprecation managers + SemanticVersion.DeprecationManager = this.DeprecationManager; + + // init logging + this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} on {EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform)}", LogLevel.Info); + this.Monitor.Log($"Mods go here: {modsPath}"); + if (modsPath != Constants.DefaultModsPath) + this.Monitor.Log("(Using custom --mods-path argument.)", LogLevel.Trace); + this.Monitor.Log($"Log started at {DateTime.UtcNow:s} UTC", LogLevel.Trace); + + // validate platform +#if SMAPI_FOR_WINDOWS + if (Constants.Platform != Platform.Windows) + { + this.Monitor.Log("Oops! You're running Windows, but this version of SMAPI is for Linux or Mac. Please reinstall SMAPI to fix this.", LogLevel.Error); + this.PressAnyKeyToExit(); + return; + } +#else + if (Constants.Platform == Platform.Windows) + { + this.Monitor.Log("Oops! You're running {Constants.Platform}, but this version of SMAPI is for Windows. Please reinstall SMAPI to fix this.", LogLevel.Error); + this.PressAnyKeyToExit(); + return; + } +#endif + + // apply game patches + new GamePatcher(this.Monitor).Apply( + new DialogueErrorPatch(this.MonitorForGame, this.Reflection) + ); + } + + /// <summary>Launch SMAPI.</summary> + [HandleProcessCorruptedStateExceptions, SecurityCritical] // let try..catch handle corrupted state exceptions + public void RunInteractively() + { + // initialise SMAPI + try + { + // hook up events + ContentEvents.Init(this.EventManager); + ControlEvents.Init(this.EventManager); + GameEvents.Init(this.EventManager); + GraphicsEvents.Init(this.EventManager); + InputEvents.Init(this.EventManager); + LocationEvents.Init(this.EventManager); + MenuEvents.Init(this.EventManager); + MineEvents.Init(this.EventManager); + MultiplayerEvents.Init(this.EventManager); + PlayerEvents.Init(this.EventManager); + SaveEvents.Init(this.EventManager); + SpecialisedEvents.Init(this.EventManager); + TimeEvents.Init(this.EventManager); + + // init JSON parser + JsonConverter[] converters = { + new ColorConverter(), + new PointConverter(), + new RectangleConverter() + }; + foreach (JsonConverter converter in converters) + this.Toolkit.JsonHelper.JsonSettings.Converters.Add(converter); + + // add error handlers +#if SMAPI_FOR_WINDOWS + Application.ThreadException += (sender, e) => this.Monitor.Log($"Critical thread exception: {e.Exception.GetLogSummary()}", LogLevel.Error); + Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); +#endif + AppDomain.CurrentDomain.UnhandledException += (sender, e) => this.Monitor.Log($"Critical app domain exception: {e.ExceptionObject}", LogLevel.Error); + + // add more leniant assembly resolvers + AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => AssemblyLoader.ResolveAssembly(e.Name); + + // override game + SGame.ConstructorHack = new SGameConstructorHack(this.Monitor, this.Reflection, this.Toolkit.JsonHelper); + this.GameInstance = new SGame(this.Monitor, this.MonitorForGame, this.Reflection, this.EventManager, this.Toolkit.JsonHelper, this.ModRegistry, this.DeprecationManager, this.InitialiseAfterGameStart, this.Dispose); + StardewValley.Program.gamePtr = this.GameInstance; + + // add exit handler + new Thread(() => + { + this.CancellationTokenSource.Token.WaitHandle.WaitOne(); + if (this.IsGameRunning) + { + try + { + File.WriteAllText(Constants.FatalCrashMarker, string.Empty); + File.Copy(this.LogFile.Path, Constants.FatalCrashLog, overwrite: true); + } + catch (Exception ex) + { + this.Monitor.Log($"SMAPI failed trying to track the crash details: {ex.GetLogSummary()}"); + } + + this.GameInstance.Exit(); + } + }).Start(); + + // hook into game events + ContentEvents.AfterLocaleChanged += (sender, e) => this.OnLocaleChanged(); + + // set window titles + this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion}"; + Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion}"; + } + catch (Exception ex) + { + this.Monitor.Log($"SMAPI failed to initialise: {ex.GetLogSummary()}", LogLevel.Error); + this.PressAnyKeyToExit(); + return; + } + + // check update marker + if (File.Exists(Constants.UpdateMarker)) + { + string rawUpdateFound = File.ReadAllText(Constants.UpdateMarker); + if (SemanticVersion.TryParse(rawUpdateFound, out ISemanticVersion updateFound)) + { + if (Constants.ApiVersion.IsPrerelease() && updateFound.IsNewerThan(Constants.ApiVersion)) + { + this.Monitor.Log("A new version of SMAPI was detected last time you played.", LogLevel.Error); + this.Monitor.Log($"You can update to {updateFound}: https://smapi.io.", LogLevel.Error); + this.Monitor.Log("Press any key to continue playing anyway. (This only appears when using a SMAPI beta.)", LogLevel.Info); + Console.ReadKey(); + } + } + File.Delete(Constants.UpdateMarker); + } + + // show details if game crashed during last session + if (File.Exists(Constants.FatalCrashMarker)) + { + this.Monitor.Log("The game crashed last time you played. That can be due to bugs in the game, but if it happens repeatedly you can ask for help here: https://community.playstarbound.com/threads/108375/.", LogLevel.Error); + this.Monitor.Log("If you ask for help, make sure to share your SMAPI log: https://log.smapi.io.", LogLevel.Error); + this.Monitor.Log("Press any key to delete the crash data and continue playing.", LogLevel.Info); + Console.ReadKey(); + File.Delete(Constants.FatalCrashLog); + File.Delete(Constants.FatalCrashMarker); + } + + // start game + this.Monitor.Log("Starting game...", LogLevel.Debug); + try + { + this.IsGameRunning = true; + StardewValley.Program.releaseBuild = true; // game's debug logic interferes with SMAPI opening the game window + this.GameInstance.Run(); + } + catch (InvalidOperationException ex) when (ex.Source == "Microsoft.Xna.Framework.Xact" && ex.StackTrace.Contains("Microsoft.Xna.Framework.Audio.AudioEngine..ctor")) + { + this.Monitor.Log("The game couldn't load audio. Do you have speakers or headphones plugged in?", LogLevel.Error); + this.Monitor.Log($"Technical details: {ex.GetLogSummary()}", LogLevel.Trace); + this.PressAnyKeyToExit(); + } + catch (Exception ex) + { + this.Monitor.Log($"The game failed unexpectedly: {ex.GetLogSummary()}", LogLevel.Error); + this.PressAnyKeyToExit(); + } + finally + { + this.Dispose(); + } + } + + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + public void Dispose() + { + // skip if already disposed + if (this.IsDisposed) + return; + this.IsDisposed = true; + this.Monitor.Log("Disposing...", LogLevel.Trace); + + // dispose mod data + foreach (IModMetadata mod in this.ModRegistry.GetAll()) + { + try + { + (mod.Mod as IDisposable)?.Dispose(); + } + catch (Exception ex) + { + mod.LogAsMod($"Mod failed during disposal: {ex.GetLogSummary()}.", LogLevel.Warn); + } + } + + // dispose core components + this.IsGameRunning = false; + this.ConsoleManager?.Dispose(); + this.ContentCore?.Dispose(); + this.CancellationTokenSource?.Dispose(); + this.GameInstance?.Dispose(); + this.LogFile?.Dispose(); + + // end game (moved from Game1.OnExiting to let us clean up first) + Process.GetCurrentProcess().Kill(); + } + + + /********* + ** Private methods + *********/ + /// <summary>Initialise SMAPI and mods after the game starts.</summary> + private void InitialiseAfterGameStart() + { + // add headers + if (this.Settings.DeveloperMode) + this.Monitor.Log($"You configured SMAPI to run in developer mode. The console may be much more verbose. You can disable developer mode by installing the non-developer version of SMAPI, or by editing {Constants.ApiConfigPath}.", LogLevel.Info); + if (!this.Settings.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); + 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."); + + // validate XNB integrity + if (!this.ValidateContentIntegrity()) + this.Monitor.Log("SMAPI found problems in your game's content files which are likely to cause errors or crashes. Consider uninstalling XNB mods or reinstalling the game.", LogLevel.Error); + + // load mod data + ModToolkit toolkit = new ModToolkit(); + ModDatabase modDatabase = toolkit.GetModDatabase(Constants.ApiMetadataPath); + + // load mods + { + this.Monitor.Log("Loading mod metadata...", LogLevel.Trace); + ModResolver resolver = new ModResolver(); + + // load manifests + IModMetadata[] mods = resolver.ReadManifests(toolkit, this.ModsPath, modDatabase).ToArray(); + + // filter out ignored mods + foreach (IModMetadata mod in mods.Where(p => p.IsIgnored)) + this.Monitor.Log($" Skipped {mod.RelativeDirectoryPath} (folder name starts with a dot).", LogLevel.Trace); + mods = mods.Where(p => !p.IsIgnored).ToArray(); + + // load mods + resolver.ValidateManifests(mods, Constants.ApiVersion, toolkit.GetUpdateUrl); + mods = resolver.ProcessDependencies(mods, modDatabase).ToArray(); + this.LoadMods(mods, this.Toolkit.JsonHelper, this.ContentCore, modDatabase); + + // write metadata file + if (this.Settings.DumpMetadata) + { + ModFolderExport export = new ModFolderExport + { + Exported = DateTime.UtcNow.ToString("O"), + ApiVersion = Constants.ApiVersion.ToString(), + GameVersion = Constants.GameVersion.ToString(), + ModFolderPath = this.ModsPath, + Mods = mods + }; + this.Toolkit.JsonHelper.WriteJsonFile(Path.Combine(Constants.LogDir, $"{Constants.LogNamePrefix}metadata-dump.json"), export); + } + + // check for updates + this.CheckForUpdatesAsync(mods); + } + if (this.Monitor.IsExiting) + { + this.Monitor.Log("SMAPI shutting down: aborting initialisation.", LogLevel.Warn); + return; + } + + // update window titles + int modsLoaded = this.ModRegistry.GetAll().Count(); + this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion} with {modsLoaded} mods"; + Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion} with {modsLoaded} mods"; + + // start SMAPI console + new Thread(this.RunConsoleLoop).Start(); + } + + /// <summary>Handle the game changing locale.</summary> + private void OnLocaleChanged() + { + // get locale + string locale = this.ContentCore.GetLocale(); + LocalizedContentManager.LanguageCode languageCode = this.ContentCore.Language; + + // update mod translation helpers + foreach (IModMetadata mod in this.ModRegistry.GetAll(contentPacks: false)) + (mod.Mod.Helper.Translation as TranslationHelper)?.SetLocale(locale, languageCode); + } + + /// <summary>Run a loop handling console input.</summary> + [SuppressMessage("ReSharper", "FunctionNeverReturns", Justification = "The thread is aborted when the game exits.")] + private void RunConsoleLoop() + { + // prepare console + this.Monitor.Log("Type 'help' for help, or 'help <cmd>' for a command's usage", LogLevel.Info); + this.GameInstance.CommandManager.Add(null, "help", "Lists command documentation.\n\nUsage: help\nLists all available commands.\n\nUsage: help <cmd>\n- cmd: The name of a command whose documentation to display.", this.HandleCommand); + this.GameInstance.CommandManager.Add(null, "reload_i18n", "Reloads translation files for all mods.\n\nUsage: reload_i18n", this.HandleCommand); + + // start handling command line input + Thread inputThread = new Thread(() => + { + while (true) + { + // get input + string input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input)) + continue; + + // handle command + this.Monitor.LogUserInput(input); + this.GameInstance.CommandQueue.Enqueue(input); + } + }); + inputThread.Start(); + + // keep console thread alive while the game is running + while (this.IsGameRunning && !this.Monitor.IsExiting) + Thread.Sleep(1000 / 10); + if (inputThread.ThreadState == ThreadState.Running) + inputThread.Abort(); + } + + /// <summary>Look for common issues with the game's XNB content, and log warnings if anything looks broken or outdated.</summary> + /// <returns>Returns whether all integrity checks passed.</returns> + private bool ValidateContentIntegrity() + { + this.Monitor.Log("Detecting common issues...", LogLevel.Trace); + bool issuesFound = false; + + // object format (commonly broken by outdated files) + { + // detect issues + bool hasObjectIssues = false; + void LogIssue(int id, string issue) => this.Monitor.Log($@"Detected issue: item #{id} in Content\Data\ObjectInformation.xnb is invalid ({issue}).", LogLevel.Trace); + foreach (KeyValuePair<int, string> entry in Game1.objectInformation) + { + // must not be empty + if (string.IsNullOrWhiteSpace(entry.Value)) + { + LogIssue(entry.Key, "entry is empty"); + hasObjectIssues = true; + continue; + } + + // require core fields + string[] fields = entry.Value.Split('/'); + if (fields.Length < Object.objectInfoDescriptionIndex + 1) + { + LogIssue(entry.Key, "too few fields for an object"); + hasObjectIssues = true; + continue; + } + + // check min length for specific types + switch (fields[Object.objectInfoTypeIndex].Split(new[] { ' ' }, 2)[0]) + { + case "Cooking": + if (fields.Length < Object.objectInfoBuffDurationIndex + 1) + { + LogIssue(entry.Key, "too few fields for a cooking item"); + hasObjectIssues = true; + } + break; + } + } + + // log error + if (hasObjectIssues) + { + issuesFound = true; + this.Monitor.Log(@"Your Content\Data\ObjectInformation.xnb file seems to be broken or outdated.", LogLevel.Warn); + } + } + + return !issuesFound; + } + + /// <summary>Asynchronously check for a new version of SMAPI and any installed mods, and print alerts to the console if an update is available.</summary> + /// <param name="mods">The mods to include in the update check (if eligible).</param> + private void CheckForUpdatesAsync(IModMetadata[] mods) + { + if (!this.Settings.CheckForUpdates) + return; + + new Thread(() => + { + // create client + string url = this.Settings.WebApiBaseUrl; +#if !SMAPI_FOR_WINDOWS + url = url.Replace("https://", "http://"); // workaround for OpenSSL issues with the game's bundled Mono on Linux/Mac +#endif + WebApiClient client = new WebApiClient(url, Constants.ApiVersion); + this.Monitor.Log("Checking for updates...", LogLevel.Trace); + + // check SMAPI version + ISemanticVersion updateFound = null; + try + { + ModEntryModel response = client.GetModInfo(new[] { new ModSearchEntryModel("Pathoschild.SMAPI", new[] { $"GitHub:{this.Settings.GitHubProjectName}" }) }).Single().Value; + ISemanticVersion latestStable = response.Main?.Version; + ISemanticVersion latestBeta = response.Optional?.Version; + + if (latestStable == null && response.Errors.Any()) + { + this.Monitor.Log("Couldn't check for a new version of SMAPI. This won't affect your game, but you may not be notified of new versions if this keeps happening.", LogLevel.Warn); + this.Monitor.Log($"Error: {string.Join("\n", response.Errors)}"); + } + else if (this.IsValidUpdate(Constants.ApiVersion, latestBeta, this.Settings.UseBetaChannel)) + { + updateFound = latestBeta; + this.Monitor.Log($"You can update SMAPI to {latestBeta}: {Constants.HomePageUrl}", LogLevel.Alert); + } + else if (this.IsValidUpdate(Constants.ApiVersion, latestStable, this.Settings.UseBetaChannel)) + { + updateFound = latestStable; + this.Monitor.Log($"You can update SMAPI to {latestStable}: {Constants.HomePageUrl}", LogLevel.Alert); + } + else + this.Monitor.Log(" SMAPI okay.", LogLevel.Trace); + } + catch (Exception ex) + { + this.Monitor.Log("Couldn't check for a new version of SMAPI. This won't affect your game, but you won't be notified of new versions if this keeps happening.", LogLevel.Warn); + this.Monitor.Log(ex is WebException && ex.InnerException == null + ? $"Error: {ex.Message}" + : $"Error: {ex.GetLogSummary()}" + ); + } + + // show update message on next launch + if (updateFound != null) + File.WriteAllText(Constants.UpdateMarker, updateFound.ToString()); + + // check mod versions + if (mods.Any()) + { + try + { + HashSet<string> suppressUpdateChecks = new HashSet<string>(this.Settings.SuppressUpdateChecks, StringComparer.InvariantCultureIgnoreCase); + + // prepare search model + List<ModSearchEntryModel> searchMods = new List<ModSearchEntryModel>(); + foreach (IModMetadata mod in mods) + { + if (!mod.HasID() || suppressUpdateChecks.Contains(mod.Manifest.UniqueID)) + continue; + + string[] updateKeys = mod + .GetUpdateKeys(validOnly: true) + .Select(p => p.ToString()) + .ToArray(); + searchMods.Add(new ModSearchEntryModel(mod.Manifest.UniqueID, updateKeys.ToArray())); + } + + // fetch results + this.Monitor.Log($" Checking for updates to {searchMods.Count} mods...", LogLevel.Trace); + IDictionary<string, ModEntryModel> results = client.GetModInfo(searchMods.ToArray()); + + // extract update alerts & errors + var updates = new List<Tuple<IModMetadata, ISemanticVersion, string>>(); + var errors = new StringBuilder(); + foreach (IModMetadata mod in mods.OrderBy(p => p.DisplayName)) + { + // link to update-check data + if (!mod.HasID() || !results.TryGetValue(mod.Manifest.UniqueID, out ModEntryModel result)) + continue; + mod.SetUpdateData(result); + + // handle errors + if (result.Errors != null && result.Errors.Any()) + { + errors.AppendLine(result.Errors.Length == 1 + ? $" {mod.DisplayName}: {result.Errors[0]}" + : $" {mod.DisplayName}:\n - {string.Join("\n - ", result.Errors)}" + ); + } + + // parse versions + bool useBetaInfo = result.HasBetaInfo && Constants.ApiVersion.IsPrerelease(); + ISemanticVersion localVersion = mod.DataRecord?.GetLocalVersionForUpdateChecks(mod.Manifest.Version) ?? mod.Manifest.Version; + ISemanticVersion latestVersion = mod.DataRecord?.GetRemoteVersionForUpdateChecks(result.Main?.Version) ?? result.Main?.Version; + ISemanticVersion optionalVersion = mod.DataRecord?.GetRemoteVersionForUpdateChecks(result.Optional?.Version) ?? result.Optional?.Version; + ISemanticVersion unofficialVersion = useBetaInfo ? result.UnofficialForBeta?.Version : result.Unofficial?.Version; + + // show update alerts + if (this.IsValidUpdate(localVersion, latestVersion, useBetaChannel: true)) + updates.Add(Tuple.Create(mod, latestVersion, result.Main?.Url)); + else if (this.IsValidUpdate(localVersion, optionalVersion, useBetaChannel: localVersion.IsPrerelease())) + updates.Add(Tuple.Create(mod, optionalVersion, result.Optional?.Url)); + else if (this.IsValidUpdate(localVersion, unofficialVersion, useBetaChannel: mod.Status == ModMetadataStatus.Failed)) + updates.Add(Tuple.Create(mod, unofficialVersion, useBetaInfo ? result.UnofficialForBeta?.Url : result.Unofficial?.Url)); + } + + // show update errors + if (errors.Length != 0) + this.Monitor.Log("Got update-check errors for some mods:\n" + errors.ToString().TrimEnd(), LogLevel.Trace); + + // show update alerts + if (updates.Any()) + { + this.Monitor.Newline(); + this.Monitor.Log($"You can update {updates.Count} mod{(updates.Count != 1 ? "s" : "")}:", LogLevel.Alert); + foreach (var entry in updates) + { + IModMetadata mod = entry.Item1; + ISemanticVersion newVersion = entry.Item2; + string newUrl = entry.Item3; + this.Monitor.Log($" {mod.DisplayName} {newVersion}: {newUrl}", LogLevel.Alert); + } + } + else + this.Monitor.Log(" All mods up to date.", LogLevel.Trace); + } + catch (Exception ex) + { + this.Monitor.Log("Couldn't check for new mod versions. This won't affect your game, but you won't be notified of mod updates if this keeps happening.", LogLevel.Warn); + this.Monitor.Log(ex is WebException && ex.InnerException == null + ? ex.Message + : ex.ToString() + ); + } + } + }).Start(); + } + + /// <summary>Get whether a given version should be offered to the user as an update.</summary> + /// <param name="currentVersion">The current semantic version.</param> + /// <param name="newVersion">The target semantic version.</param> + /// <param name="useBetaChannel">Whether the user enabled the beta channel and should be offered pre-release updates.</param> + private bool IsValidUpdate(ISemanticVersion currentVersion, ISemanticVersion newVersion, bool useBetaChannel) + { + return + newVersion != null + && newVersion.IsNewerThan(currentVersion) + && (useBetaChannel || !newVersion.IsPrerelease()); + } + + /// <summary>Create a directory path if it doesn't exist.</summary> + /// <param name="path">The directory path.</param> + private void VerifyPath(string path) + { + try + { + if (!Directory.Exists(path)) + Directory.CreateDirectory(path); + } + catch (Exception ex) + { + // note: this happens before this.Monitor is initialised + Console.WriteLine($"Couldn't create a path: {path}\n\n{ex.GetLogSummary()}"); + } + } + + /// <summary>Load and hook up the given mods.</summary> + /// <param name="mods">The mods to load.</param> + /// <param name="jsonHelper">The JSON helper with which to read mods' JSON files.</param> + /// <param name="contentCore">The content manager to use for mod content.</param> + /// <param name="modDatabase">Handles access to SMAPI's internal mod metadata list.</param> + private void LoadMods(IModMetadata[] mods, JsonHelper jsonHelper, ContentCoordinator contentCore, ModDatabase modDatabase) + { + this.Monitor.Log("Loading mods...", LogLevel.Trace); + + // load mods + IDictionary<IModMetadata, Tuple<string, string>> skippedMods = new Dictionary<IModMetadata, Tuple<string, string>>(); + using (AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.Platform, this.Monitor)) + { + // init + HashSet<string> suppressUpdateChecks = new HashSet<string>(this.Settings.SuppressUpdateChecks, StringComparer.InvariantCultureIgnoreCase); + InterfaceProxyFactory proxyFactory = new InterfaceProxyFactory(); + void LogSkip(IModMetadata mod, string errorPhrase, string errorDetails) + { + skippedMods[mod] = Tuple.Create(errorPhrase, errorDetails); + if (mod.Status != ModMetadataStatus.Failed) + mod.SetStatus(ModMetadataStatus.Failed, errorPhrase); + } + + // load mods + foreach (IModMetadata contentPack in mods) + { + if (!this.TryLoadMod(contentPack, mods, modAssemblyLoader, proxyFactory, jsonHelper, contentCore, modDatabase, suppressUpdateChecks, out string errorPhrase, out string errorDetails)) + LogSkip(contentPack, errorPhrase, errorDetails); + } + } + IModMetadata[] loadedContentPacks = this.ModRegistry.GetAll(assemblyMods: false).ToArray(); + IModMetadata[] loadedMods = this.ModRegistry.GetAll(contentPacks: false).ToArray(); + + // unlock content packs + this.ModRegistry.AreAllModsLoaded = true; + + // log loaded mods + this.Monitor.Log($"Loaded {loadedMods.Length} mods" + (loadedMods.Length > 0 ? ":" : "."), LogLevel.Info); + foreach (IModMetadata metadata in loadedMods.OrderBy(p => p.DisplayName)) + { + IManifest manifest = metadata.Manifest; + this.Monitor.Log( + $" {metadata.DisplayName} {manifest.Version}" + + (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "") + + (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""), + LogLevel.Info + ); + } + this.Monitor.Newline(); + + // log loaded content packs + if (loadedContentPacks.Any()) + { + string GetModDisplayName(string id) => loadedMods.FirstOrDefault(p => p.HasID(id))?.DisplayName; + + this.Monitor.Log($"Loaded {loadedContentPacks.Length} content packs:", LogLevel.Info); + foreach (IModMetadata metadata in loadedContentPacks.OrderBy(p => p.DisplayName)) + { + IManifest manifest = metadata.Manifest; + this.Monitor.Log( + $" {metadata.DisplayName} {manifest.Version}" + + (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "") + + (metadata.IsContentPack ? $" | for {GetModDisplayName(metadata.Manifest.ContentPackFor.UniqueID)}" : "") + + (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""), + LogLevel.Info + ); + } + this.Monitor.Newline(); + } + + // log mod warnings + this.LogModWarnings(this.ModRegistry.GetAll().ToArray(), skippedMods); + + // initialise translations + this.ReloadTranslations(loadedMods); + + // initialise loaded non-content-pack mods + foreach (IModMetadata metadata in loadedMods) + { + // add interceptors + if (metadata.Mod.Helper.Content is ContentHelper helper) + { + // ReSharper disable SuspiciousTypeConversion.Global + if (metadata.Mod is IAssetEditor editor) + helper.ObservableAssetEditors.Add(editor); + if (metadata.Mod is IAssetLoader loader) + helper.ObservableAssetLoaders.Add(loader); + // ReSharper restore SuspiciousTypeConversion.Global + + this.ContentCore.Editors[metadata] = helper.ObservableAssetEditors; + this.ContentCore.Loaders[metadata] = helper.ObservableAssetLoaders; + } + + // call entry method + try + { + IMod mod = metadata.Mod; + mod.Entry(mod.Helper); + } + catch (Exception ex) + { + metadata.LogAsMod($"Mod crashed on entry and might not work correctly. Technical details:\n{ex.GetLogSummary()}", LogLevel.Error); + } + + // get mod API + try + { + object api = metadata.Mod.GetApi(); + if (api != null && !api.GetType().IsPublic) + { + api = null; + this.Monitor.Log($"{metadata.DisplayName} provides an API instance with a non-public type. This isn't currently supported, so the API won't be available to other mods.", LogLevel.Warn); + } + + if (api != null) + this.Monitor.Log($" Found mod-provided API ({api.GetType().FullName}).", LogLevel.Trace); + metadata.SetApi(api); + } + catch (Exception ex) + { + this.Monitor.Log($"Failed loading mod-provided API for {metadata.DisplayName}. Integrations with other mods may not work. Error: {ex.GetLogSummary()}", LogLevel.Error); + } + } + + // invalidate cache entries when needed + // (These listeners are registered after Entry to avoid repeatedly reloading assets as mods initialise.) + foreach (IModMetadata metadata in loadedMods) + { + if (metadata.Mod.Helper.Content is ContentHelper helper) + { + helper.ObservableAssetEditors.CollectionChanged += (sender, e) => + { + if (e.NewItems?.Count > 0) + { + this.Monitor.Log("Invalidating cache entries for new asset editors...", LogLevel.Trace); + this.ContentCore.InvalidateCacheFor(e.NewItems.Cast<IAssetEditor>().ToArray(), new IAssetLoader[0]); + } + }; + helper.ObservableAssetLoaders.CollectionChanged += (sender, e) => + { + if (e.NewItems?.Count > 0) + { + this.Monitor.Log("Invalidating cache entries for new asset loaders...", LogLevel.Trace); + this.ContentCore.InvalidateCacheFor(new IAssetEditor[0], e.NewItems.Cast<IAssetLoader>().ToArray()); + } + }; + } + } + + // reset cache now if any editors or loaders were added during entry + IAssetEditor[] editors = loadedMods.SelectMany(p => p.Mod.Helper.Content.AssetEditors).ToArray(); + IAssetLoader[] loaders = loadedMods.SelectMany(p => p.Mod.Helper.Content.AssetLoaders).ToArray(); + if (editors.Any() || loaders.Any()) + { + this.Monitor.Log("Invalidating cached assets for new editors & loaders...", LogLevel.Trace); + this.ContentCore.InvalidateCacheFor(editors, loaders); + } + + // unlock mod integrations + this.ModRegistry.AreAllModsInitialised = true; + } + + /// <summary>Load a given mod.</summary> + /// <param name="mod">The mod to load.</param> + /// <param name="mods">The mods being loaded.</param> + /// <param name="assemblyLoader">Preprocesses and loads mod assemblies</param> + /// <param name="proxyFactory">Generates proxy classes to access mod APIs through an arbitrary interface.</param> + /// <param name="jsonHelper">The JSON helper with which to read mods' JSON files.</param> + /// <param name="contentCore">The content manager to use for mod content.</param> + /// <param name="modDatabase">Handles access to SMAPI's internal mod metadata list.</param> + /// <param name="suppressUpdateChecks">The mod IDs to ignore when validating update keys.</param> + /// <param name="errorReasonPhrase">The user-facing reason phrase explaining why the mod couldn't be loaded (if applicable).</param> + /// <param name="errorDetails">More detailed details about the error intended for developers (if any).</param> + /// <returns>Returns whether the mod was successfully loaded.</returns> + private bool TryLoadMod(IModMetadata mod, IModMetadata[] mods, AssemblyLoader assemblyLoader, InterfaceProxyFactory proxyFactory, JsonHelper jsonHelper, ContentCoordinator contentCore, ModDatabase modDatabase, HashSet<string> suppressUpdateChecks, out string errorReasonPhrase, out string errorDetails) + { + errorDetails = null; + + // log entry + { + string relativePath = PathUtilities.GetRelativePath(this.ModsPath, mod.DirectoryPath); + if (mod.IsContentPack) + this.Monitor.Log($" {mod.DisplayName} ({relativePath}) [content pack]...", LogLevel.Trace); + else if (mod.Manifest?.EntryDll != null) + this.Monitor.Log($" {mod.DisplayName} ({relativePath}{Path.DirectorySeparatorChar}{mod.Manifest.EntryDll})...", LogLevel.Trace); // don't use Path.Combine here, since EntryDLL might not be valid + else + this.Monitor.Log($" {mod.DisplayName} ({relativePath})...", LogLevel.Trace); + } + + // add warning for missing update key + if (mod.HasID() && !suppressUpdateChecks.Contains(mod.Manifest.UniqueID) && !mod.HasValidUpdateKeys()) + mod.SetWarning(ModWarning.NoUpdateKeys); + + // validate status + if (mod.Status == ModMetadataStatus.Failed) + { + this.Monitor.Log($" Failed: {mod.Error}", LogLevel.Trace); + errorReasonPhrase = mod.Error; + return false; + } + + // add deprecation warning for old version format + { + if (mod.Manifest?.Version is Toolkit.SemanticVersion version && version.IsLegacyFormat) + this.DeprecationManager.Warn(mod.DisplayName, "non-string manifest version", "2.8", DeprecationLevel.Notice); + } + + // validate dependencies + // Although dependences are validated before mods are loaded, a dependency may have failed to load. + if (mod.Manifest.Dependencies?.Any() == true) + { + foreach (IManifestDependency dependency in mod.Manifest.Dependencies.Where(p => p.IsRequired)) + { + if (this.ModRegistry.Get(dependency.UniqueID) == null) + { + string dependencyName = mods + .FirstOrDefault(otherMod => otherMod.HasID(dependency.UniqueID)) + ?.DisplayName ?? dependency.UniqueID; + errorReasonPhrase = $"it needs the '{dependencyName}' mod, which couldn't be loaded."; + return false; + } + } + } + + // load as content pack + if (mod.IsContentPack) + { + IManifest manifest = mod.Manifest; + IMonitor monitor = this.GetSecondaryMonitor(mod.DisplayName); + IContentHelper contentHelper = new ContentHelper(this.ContentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, monitor); + IContentPack contentPack = new ContentPack(mod.DirectoryPath, manifest, contentHelper, jsonHelper); + mod.SetMod(contentPack, monitor); + this.ModRegistry.Add(mod); + + errorReasonPhrase = null; + return true; + } + + // load as mod + else + { + IManifest manifest = mod.Manifest; + + // load mod + string assemblyPath = manifest?.EntryDll != null + ? Path.Combine(mod.DirectoryPath, manifest.EntryDll) + : null; + Assembly modAssembly; + try + { + modAssembly = assemblyLoader.Load(mod, assemblyPath, assumeCompatible: mod.DataRecord?.Status == ModStatus.AssumeCompatible); + } + catch (IncompatibleInstructionException) // details already in trace logs + { + string[] updateUrls = new[] { modDatabase.GetModPageUrlFor(manifest.UniqueID), "https://mods.smapi.io" }.Where(p => p != null).ToArray(); + errorReasonPhrase = $"it's no longer compatible. Please check for a new version at {string.Join(" or ", updateUrls)}."; + return false; + } + catch (SAssemblyLoadFailedException ex) + { + errorReasonPhrase = $"it DLL couldn't be loaded: {ex.Message}"; + return false; + } + catch (Exception ex) + { + errorReasonPhrase = "its DLL couldn't be loaded."; + errorDetails = $"Error: {ex.GetLogSummary()}"; + return false; + } + + // initialise mod + try + { + // get mod instance + if (!this.TryLoadModEntry(modAssembly, out Mod modEntry, out errorReasonPhrase)) + return false; + + // get content packs + IContentPack[] GetContentPacks() + { + if (!this.ModRegistry.AreAllModsLoaded) + throw new InvalidOperationException("Can't access content packs before SMAPI finishes loading mods."); + + return this.ModRegistry + .GetAll(assemblyMods: false) + .Where(p => p.IsContentPack && mod.HasID(p.Manifest.ContentPackFor.UniqueID)) + .Select(p => p.ContentPack) + .ToArray(); + } + + // init mod helpers + IMonitor monitor = this.GetSecondaryMonitor(mod.DisplayName); + IModHelper modHelper; + { + IModEvents events = new ModEvents(mod, this.EventManager); + ICommandHelper commandHelper = new CommandHelper(mod, this.GameInstance.CommandManager); + IContentHelper contentHelper = new ContentHelper(contentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, monitor); + IDataHelper dataHelper = new DataHelper(manifest.UniqueID, mod.DirectoryPath, jsonHelper); + IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, mod.DisplayName, this.Reflection, this.DeprecationManager); + IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry, proxyFactory, monitor); + IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(manifest.UniqueID, this.GameInstance.Multiplayer); + ITranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, manifest.Name, contentCore.GetLocale(), contentCore.Language); + + IContentPack CreateTransitionalContentPack(string packDirPath, IManifest packManifest) + { + IMonitor packMonitor = this.GetSecondaryMonitor(packManifest.Name); + IContentHelper packContentHelper = new ContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor); + return new ContentPack(packDirPath, packManifest, packContentHelper, this.Toolkit.JsonHelper); + } + + modHelper = new ModHelper(manifest.UniqueID, mod.DirectoryPath, this.Toolkit.JsonHelper, this.GameInstance.Input, events, contentHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper, GetContentPacks, CreateTransitionalContentPack, this.DeprecationManager); + } + + // init mod + modEntry.ModManifest = manifest; + modEntry.Helper = modHelper; + modEntry.Monitor = monitor; + + // track mod + mod.SetMod(modEntry); + this.ModRegistry.Add(mod); + return true; + } + catch (Exception ex) + { + errorReasonPhrase = $"initialisation failed:\n{ex.GetLogSummary()}"; + return false; + } + } + } + + /// <summary>Write a summary of mod warnings to the console and log.</summary> + /// <param name="mods">The loaded mods.</param> + /// <param name="skippedMods">The mods which were skipped, along with the friendly and developer reasons.</param> + private void LogModWarnings(IModMetadata[] mods, IDictionary<IModMetadata, Tuple<string, string>> skippedMods) + { + // get mods with warnings + IModMetadata[] modsWithWarnings = mods.Where(p => p.Warnings != ModWarning.None).ToArray(); + if (!modsWithWarnings.Any() && !skippedMods.Any()) + return; + + // log intro + { + int count = modsWithWarnings.Union(skippedMods.Keys).Count(); + this.Monitor.Log($"Found {count} mod{(count == 1 ? "" : "s")} with warnings:", LogLevel.Info); + } + + // log skipped mods + if (skippedMods.Any()) + { + this.Monitor.Log(" Skipped mods", LogLevel.Error); + this.Monitor.Log(" " + "".PadRight(50, '-'), LogLevel.Error); + this.Monitor.Log(" These mods could not be added to your game.", LogLevel.Error); + this.Monitor.Newline(); + + HashSet<string> logged = new HashSet<string>(); + foreach (var pair in skippedMods.OrderBy(p => p.Key.DisplayName)) + { + IModMetadata mod = pair.Key; + string errorReason = pair.Value.Item1; + string errorDetails = pair.Value.Item2; + string message = $" - {mod.DisplayName}{(mod.Manifest?.Version != null ? " " + mod.Manifest.Version.ToString() : "")} because {errorReason}"; + + if (!logged.Add($"{message}|{errorDetails}")) + continue; // skip duplicate messages (e.g. if multiple copies of the mod are installed) + + this.Monitor.Log(message, LogLevel.Error); + if (errorDetails != null) + this.Monitor.Log($" ({errorDetails})", LogLevel.Trace); + } + this.Monitor.Newline(); + } + + // log warnings + if (modsWithWarnings.Any()) + { + // issue block format logic + void LogWarningGroup(ModWarning warning, LogLevel logLevel, string heading, params string[] blurb) + { + IModMetadata[] matches = modsWithWarnings.Where(p => p.Warnings.HasFlag(warning)).ToArray(); + if (!matches.Any()) + return; + + this.Monitor.Log(" " + heading, logLevel); + this.Monitor.Log(" " + "".PadRight(50, '-'), logLevel); + foreach (string line in blurb) + this.Monitor.Log(" " + line, logLevel); + this.Monitor.Newline(); + foreach (IModMetadata match in matches) + this.Monitor.Log($" - {match.DisplayName}", logLevel); + this.Monitor.Newline(); + } + + // supported issues + LogWarningGroup(ModWarning.BrokenCodeLoaded, LogLevel.Error, "Broken mods", + "These mods have broken code, but you configured SMAPI to load them anyway. This may cause bugs,", + "errors, or crashes in-game." + ); + LogWarningGroup(ModWarning.ChangesSaveSerialiser, LogLevel.Warn, "Changed save serialiser", + "These mods change the save serialiser. They may corrupt your save files, or make them unusable if", + "you uninstall these mods." + ); + if (this.Settings.ParanoidWarnings) + { + LogWarningGroup(ModWarning.AccessesFilesystem, LogLevel.Warn, "Accesses filesystem directly", + "These mods directly access the filesystem, and you enabled paranoid warnings. (Note that this may be", + "legitimate and innocent usage; this warning is meaningless without further investigation.)" + ); + LogWarningGroup(ModWarning.AccessesShell, LogLevel.Warn, "Accesses shell/process directly", + "These mods directly access the OS shell or processes, and you enabled paranoid warnings. (Note that", + "this may be legitimate and innocent usage; this warning is meaningless without further investigation.)" + ); + } + LogWarningGroup(ModWarning.PatchesGame, LogLevel.Info, "Patched game code", + "These mods directly change the game code. They're more likely to cause errors or bugs in-game; if", + "your game has issues, try removing these first. Otherwise you can ignore this warning." + ); + LogWarningGroup(ModWarning.UsesUnvalidatedUpdateTick, LogLevel.Info, "Bypassed safety checks", + "These mods bypass SMAPI's normal safety checks, so they're more likely to cause errors or save", + "corruption. If your game has issues, try removing these first." + ); + LogWarningGroup(ModWarning.NoUpdateKeys, LogLevel.Debug, "No update keys", + "These mods have no update keys in their manifest. SMAPI may not notify you about updates for these", + "mods. Consider notifying the mod authors about this problem." + ); + LogWarningGroup(ModWarning.UsesDynamic, LogLevel.Debug, "Not crossplatform", + "These mods use the 'dynamic' keyword, and won't work on Linux/Mac." + ); + } + } + + /// <summary>Load a mod's entry class.</summary> + /// <param name="modAssembly">The mod assembly.</param> + /// <param name="mod">The loaded instance.</param> + /// <param name="error">The error indicating why loading failed (if applicable).</param> + /// <returns>Returns whether the mod entry class was successfully loaded.</returns> + private bool TryLoadModEntry(Assembly modAssembly, out Mod mod, out string error) + { + mod = null; + + // find type + TypeInfo[] modEntries = modAssembly.DefinedTypes.Where(type => typeof(Mod).IsAssignableFrom(type) && !type.IsAbstract).Take(2).ToArray(); + if (modEntries.Length == 0) + { + error = $"its DLL has no '{nameof(Mod)}' subclass."; + return false; + } + if (modEntries.Length > 1) + { + error = $"its DLL contains multiple '{nameof(Mod)}' subclasses."; + return false; + } + + // get implementation + mod = (Mod)modAssembly.CreateInstance(modEntries[0].ToString()); + if (mod == null) + { + error = "its entry class couldn't be instantiated."; + return false; + } + + error = null; + return true; + } + + /// <summary>Reload translations for all mods.</summary> + /// <param name="mods">The mods for which to reload translations.</param> + private void ReloadTranslations(IEnumerable<IModMetadata> mods) + { + JsonHelper jsonHelper = this.Toolkit.JsonHelper; + foreach (IModMetadata metadata in mods) + { + if (metadata.IsContentPack) + throw new InvalidOperationException("Can't reload translations for a content pack."); + + // read translation files + IDictionary<string, IDictionary<string, string>> translations = new Dictionary<string, IDictionary<string, string>>(); + DirectoryInfo translationsDir = new DirectoryInfo(Path.Combine(metadata.DirectoryPath, "i18n")); + if (translationsDir.Exists) + { + foreach (FileInfo file in translationsDir.EnumerateFiles("*.json")) + { + string locale = Path.GetFileNameWithoutExtension(file.Name.ToLower().Trim()); + try + { + if (jsonHelper.ReadJsonFileIfExists(file.FullName, out IDictionary<string, string> data)) + translations[locale] = data; + else + metadata.LogAsMod($"Mod's i18n/{locale}.json file couldn't be parsed.", LogLevel.Warn); + } + catch (Exception ex) + { + metadata.LogAsMod($"Mod's i18n/{locale}.json file couldn't be parsed: {ex.GetLogSummary()}", LogLevel.Warn); + } + } + } + + // validate translations + foreach (string locale in translations.Keys.ToArray()) + { + // skip empty files + if (translations[locale] == null || !translations[locale].Keys.Any()) + { + metadata.LogAsMod($"Mod's i18n/{locale}.json is empty and will be ignored.", LogLevel.Warn); + translations.Remove(locale); + continue; + } + + // handle duplicates + HashSet<string> keys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); + HashSet<string> duplicateKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); + foreach (string key in translations[locale].Keys.ToArray()) + { + if (!keys.Add(key)) + { + duplicateKeys.Add(key); + translations[locale].Remove(key); + } + } + if (duplicateKeys.Any()) + metadata.LogAsMod($"Mod's i18n/{locale}.json has duplicate translation keys: [{string.Join(", ", duplicateKeys)}]. Keys are case-insensitive.", LogLevel.Warn); + } + + // update translation + TranslationHelper translationHelper = (TranslationHelper)metadata.Mod.Helper.Translation; + translationHelper.SetTranslations(translations); + } + } + + /// <summary>The method called when the user submits a core SMAPI command in the console.</summary> + /// <param name="name">The command name.</param> + /// <param name="arguments">The command arguments.</param> + private void HandleCommand(string name, string[] arguments) + { + switch (name) + { + case "help": + if (arguments.Any()) + { + Command result = this.GameInstance.CommandManager.Get(arguments[0]); + if (result == null) + this.Monitor.Log("There's no command with that name.", LogLevel.Error); + else + this.Monitor.Log($"{result.Name}: {result.Documentation}{(result.Mod != null ? $"\n(Added by {result.Mod.DisplayName}.)" : "")}", LogLevel.Info); + } + else + { + string message = "The following commands are registered:\n"; + IGrouping<string, string>[] groups = (from command in this.GameInstance.CommandManager.GetAll() orderby command.Mod?.DisplayName, command.Name group command.Name by command.Mod?.DisplayName).ToArray(); + foreach (var group in groups) + { + string modName = group.Key ?? "SMAPI"; + string[] commandNames = group.ToArray(); + message += $"{modName}:\n {string.Join("\n ", commandNames)}\n\n"; + } + message += "For more information about a command, type 'help command_name'."; + + this.Monitor.Log(message, LogLevel.Info); + } + break; + + case "reload_i18n": + this.ReloadTranslations(this.ModRegistry.GetAll(contentPacks: false)); + this.Monitor.Log("Reloaded translation files for all mods. This only affects new translations the mods fetch; if they cached some text, it may not be updated.", LogLevel.Info); + break; + + default: + throw new NotSupportedException($"Unrecognise core SMAPI command '{name}'."); + } + } + + /// <summary>Redirect messages logged directly to the console to the given monitor.</summary> + /// <param name="monitor">The monitor with which to log messages.</param> + /// <param name="message">The message to log.</param> + private void HandleConsoleMessage(IMonitor monitor, string message) + { + // detect exception + LogLevel level = message.Contains("Exception") ? LogLevel.Error : LogLevel.Trace; + + // ignore suppressed message + if (level != LogLevel.Error && this.SuppressConsolePatterns.Any(p => p.IsMatch(message))) + return; + + // forward to monitor + monitor.Log(message, level); + } + + /// <summary>Show a 'press any key to exit' message, and exit when they press a key.</summary> + private void PressAnyKeyToExit() + { + this.Monitor.Log("Game has ended. Press any key to exit.", LogLevel.Info); + this.PressAnyKeyToExit(showMessage: false); + } + + /// <summary>Show a 'press any key to exit' message, and exit when they press a key.</summary> + /// <param name="showMessage">Whether to print a 'press any key to exit' message to the console.</param> + private void PressAnyKeyToExit(bool showMessage) + { + if (showMessage) + Console.WriteLine("Game has ended. Press any key to exit."); + Thread.Sleep(100); + Console.ReadKey(); + Environment.Exit(0); + } + + /// <summary>Get a monitor instance derived from SMAPI's current settings.</summary> + /// <param name="name">The name of the module which will log messages with this instance.</param> + private Monitor GetSecondaryMonitor(string name) + { + return new Monitor(name, this.ConsoleManager, this.LogFile, this.CancellationTokenSource, this.Settings.ColorScheme, this.Settings.VerboseLogging) + { + WriteToConsole = this.Monitor.WriteToConsole, + ShowTraceInConsole = this.Settings.DeveloperMode, + ShowFullStampInConsole = this.Settings.DeveloperMode + }; + } + + /// <summary>Get the absolute path to the next available log file.</summary> + private string GetLogPath() + { + // default path + { + FileInfo defaultFile = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogFilename}.{Constants.LogExtension}")); + if (!defaultFile.Exists) + return defaultFile.FullName; + } + + // get first disambiguated path + for (int i = 2; i < int.MaxValue; i++) + { + FileInfo file = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogFilename}.player-{i}.{Constants.LogExtension}")); + if (!file.Exists) + return file.FullName; + } + + // should never happen + throw new InvalidOperationException("Could not find an available log path."); + } + + /// <summary>Delete normal (non-crash) log files created by SMAPI.</summary> + private void PurgeNormalLogs() + { + DirectoryInfo logsDir = new DirectoryInfo(Constants.LogDir); + if (!logsDir.Exists) + return; + + foreach (FileInfo logFile in logsDir.EnumerateFiles()) + { + // skip non-SMAPI file + if (!logFile.Name.StartsWith(Constants.LogNamePrefix, StringComparison.InvariantCultureIgnoreCase)) + continue; + + // skip crash log + if (logFile.FullName == Constants.FatalCrashLog) + continue; + + // delete file + try + { + FileUtilities.ForceDelete(logFile); + } + catch (IOException) + { + // ignore file if it's in use + } + } + } + } +} diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index 83e8c9a7..75cf4c52 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -11,9 +11,11 @@ using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; using Netcode; +using StardewModdingAPI.Enums; using StardewModdingAPI.Events; using StardewModdingAPI.Framework.Events; using StardewModdingAPI.Framework.Input; +using StardewModdingAPI.Framework.Networking; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.StateTracking; using StardewModdingAPI.Framework.Utilities; @@ -40,12 +42,21 @@ namespace StardewModdingAPI.Framework /**** ** SMAPI state ****/ - /// <summary>Encapsulates monitoring and logging.</summary> + /// <summary>Encapsulates monitoring and logging for SMAPI.</summary> private readonly IMonitor Monitor; + /// <summary>Encapsulates monitoring and logging on the game's behalf.</summary> + private readonly IMonitor MonitorForGame; + /// <summary>Manages SMAPI events for mods.</summary> private readonly EventManager Events; + /// <summary>Tracks the installed mods.</summary> + private readonly ModRegistry ModRegistry; + + /// <summary>Manages deprecation warnings.</summary> + private readonly DeprecationManager DeprecationManager; + /// <summary>The maximum number of consecutive attempts SMAPI should make to recover from a draw error.</summary> private readonly Countdown DrawCrashTimer = new Countdown(60); // 60 ticks = roughly one second @@ -74,9 +85,6 @@ namespace StardewModdingAPI.Framework /// <summary>Simplifies access to private game code.</summary> private readonly Reflector Reflection; - /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> - private readonly JsonHelper JsonHelper; - /**** ** Game state ****/ @@ -114,9 +122,6 @@ namespace StardewModdingAPI.Framework /// <summary>The game's core multiplayer utility.</summary> public SMultiplayer Multiplayer => (SMultiplayer)Game1.multiplayer; - /// <summary>Whether SMAPI should log more information about the game context.</summary> - public bool VerboseLogging { get; set; } - /// <summary>A list of queued commands to execute.</summary> /// <remarks>This property must be threadsafe, since it's accessed from a separate console input thread.</remarks> public ConcurrentQueue<string> CommandQueue { get; } = new ConcurrentQueue<string>(); @@ -126,13 +131,16 @@ namespace StardewModdingAPI.Framework ** Protected methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="monitor">Encapsulates monitoring and logging for SMAPI.</param> + /// <param name="monitorForGame">Encapsulates monitoring and logging on the game's behalf.</param> /// <param name="reflection">Simplifies access to private game code.</param> /// <param name="eventManager">Manages SMAPI events for mods.</param> /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> + /// <param name="modRegistry">Tracks the installed mods.</param> + /// <param name="deprecationManager">Manages deprecation warnings.</param> /// <param name="onGameInitialised">A callback to invoke after the game finishes initialising.</param> /// <param name="onGameExiting">A callback to invoke when the game exits.</param> - internal SGame(IMonitor monitor, Reflector reflection, EventManager eventManager, JsonHelper jsonHelper, Action onGameInitialised, Action onGameExiting) + internal SGame(IMonitor monitor, IMonitor monitorForGame, Reflector reflection, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, DeprecationManager deprecationManager, Action onGameInitialised, Action onGameExiting) { SGame.ConstructorHack = null; @@ -145,13 +153,16 @@ namespace StardewModdingAPI.Framework // init SMAPI this.Monitor = monitor; + this.MonitorForGame = monitorForGame; this.Events = eventManager; + this.ModRegistry = modRegistry; this.Reflection = reflection; - this.JsonHelper = jsonHelper; + this.DeprecationManager = deprecationManager; this.OnGameInitialised = onGameInitialised; this.OnGameExiting = onGameExiting; Game1.input = new SInputState(); - Game1.multiplayer = new SMultiplayer(monitor, eventManager); + Game1.multiplayer = new SMultiplayer(monitor, eventManager, jsonHelper, modRegistry, reflection, this.OnModMessageReceived); + Game1.hooks = new SModHooks(this.OnNewDayAfterFade); // init observables Game1.locations = new ObservableCollection<GameLocation>(); @@ -180,9 +191,21 @@ namespace StardewModdingAPI.Framework this.OnGameExiting?.Invoke(); } - /**** - ** Intercepted methods & events - ****/ + /// <summary>A callback invoked before <see cref="Game1.newDayAfterFade"/> runs.</summary> + protected void OnNewDayAfterFade() + { + this.Events.DayEnding.RaiseEmpty(); + } + + /// <summary>A callback invoked when a mod message is received.</summary> + /// <param name="message">The message to deliver to applicable mods.</param> + private void OnModMessageReceived(ModMessageModel message) + { + // raise events for applicable mods + HashSet<string> modIDs = new HashSet<string>(message.ToModIDs ?? this.ModRegistry.GetAll().Select(p => p.Manifest.UniqueID), StringComparer.InvariantCultureIgnoreCase); + this.Events.ModMessageReceived.RaiseForMods(new ModMessageReceivedEventArgs(message), mod => mod != null && modIDs.Contains(mod.Manifest.UniqueID)); + } + /// <summary>Constructor a content manager to read XNB files.</summary> /// <param name="serviceProvider">The service provider to use to locate services.</param> /// <param name="rootDirectory">The root directory to search for content.</param> @@ -214,6 +237,8 @@ namespace StardewModdingAPI.Framework { try { + this.DeprecationManager.PrintQueued(); + /********* ** Special cases *********/ @@ -259,8 +284,10 @@ namespace StardewModdingAPI.Framework // update tick are neglible and not worth the complications of bypassing Game1.Update. if (Game1._newDayTask != null || Game1.gameMode == Game1.loadingMode) { + this.Events.UnvalidatedUpdateTicking.Raise(new UnvalidatedUpdateTickingEventArgs(this.TicksElapsed)); base.Update(gameTime); - this.Events.Specialised_UnvalidatedUpdateTick.Raise(); + this.Events.UnvalidatedUpdateTicked.Raise(new UnvalidatedUpdateTickedEventArgs(this.TicksElapsed)); + this.Events.Legacy_UnvalidatedUpdateTick.Raise(); return; } @@ -269,14 +296,35 @@ namespace StardewModdingAPI.Framework *********/ while (this.CommandQueue.TryDequeue(out string rawInput)) { + // parse command + string name; + string[] args; + Command command; try { - if (!this.CommandManager.Trigger(rawInput)) + if (!this.CommandManager.TryParse(rawInput, out name, out args, out command)) + { this.Monitor.Log("Unknown command; type 'help' for a list of available commands.", LogLevel.Error); + continue; + } + } + catch (Exception ex) + { + this.Monitor.Log($"Failed parsing that command:\n{ex.GetLogSummary()}", LogLevel.Error); + continue; + } + + // execute command + try + { + command.Callback.Invoke(name, args); } catch (Exception ex) { - this.Monitor.Log($"The handler registered for that command failed:\n{ex.GetLogSummary()}", LogLevel.Error); + if (command.Mod != null) + command.Mod.LogAsMod($"Mod failed handling that command:\n{ex.GetLogSummary()}", LogLevel.Error); + else + this.Monitor.Log($"Failed handling that command:\n{ex.GetLogSummary()}", LogLevel.Error); } } @@ -306,7 +354,8 @@ namespace StardewModdingAPI.Framework { this.IsBetweenCreateEvents = true; this.Monitor.Log("Context: before save creation.", LogLevel.Trace); - this.Events.Save_BeforeCreate.Raise(); + this.Events.SaveCreating.RaiseEmpty(); + this.Events.Legacy_BeforeCreateSave.Raise(); } // raise before-save @@ -314,12 +363,15 @@ namespace StardewModdingAPI.Framework { this.IsBetweenSaveEvents = true; this.Monitor.Log("Context: before save.", LogLevel.Trace); - this.Events.Save_BeforeSave.Raise(); + this.Events.Saving.RaiseEmpty(); + this.Events.Legacy_BeforeSave.Raise(); } // suppress non-save events + this.Events.UnvalidatedUpdateTicking.Raise(new UnvalidatedUpdateTickingEventArgs(this.TicksElapsed)); base.Update(gameTime); - this.Events.Specialised_UnvalidatedUpdateTick.Raise(); + this.Events.UnvalidatedUpdateTicked.Raise(new UnvalidatedUpdateTickedEventArgs(this.TicksElapsed)); + this.Events.Legacy_UnvalidatedUpdateTick.Raise(); return; } if (this.IsBetweenCreateEvents) @@ -327,15 +379,19 @@ namespace StardewModdingAPI.Framework // raise after-create this.IsBetweenCreateEvents = false; this.Monitor.Log($"Context: after save creation, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.", LogLevel.Trace); - this.Events.Save_AfterCreate.Raise(); + this.Events.SaveCreated.RaiseEmpty(); + this.Events.Legacy_AfterCreateSave.Raise(); } if (this.IsBetweenSaveEvents) { // raise after-save this.IsBetweenSaveEvents = false; this.Monitor.Log($"Context: after save, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.", LogLevel.Trace); - this.Events.Save_AfterSave.Raise(); - this.Events.Time_AfterDayStarted.Raise(); + this.Events.Saved.RaiseEmpty(); + this.Events.DayStarted.RaiseEmpty(); + + this.Events.Legacy_AfterSave.Raise(); + this.Events.Legacy_AfterDayStarted.Raise(); } /********* @@ -365,7 +421,7 @@ namespace StardewModdingAPI.Framework var now = this.Watchers.LocaleWatcher.CurrentValue; this.Monitor.Log($"Context: locale set to {now}.", LogLevel.Trace); - this.Events.Content_LocaleChanged.Raise(new EventArgsValueChanged<string>(was.ToString(), now.ToString())); + this.Events.Legacy_LocaleChanged.Raise(new EventArgsValueChanged<string>(was.ToString(), now.ToString())); this.Watchers.LocaleWatcher.Reset(); } @@ -376,7 +432,8 @@ namespace StardewModdingAPI.Framework if (wasWorldReady && !Context.IsWorldReady) { this.Monitor.Log("Context: returned to title", LogLevel.Trace); - this.Events.Save_AfterReturnToTitle.Raise(); + this.Events.ReturnedToTitle.RaiseEmpty(); + this.Events.Legacy_AfterReturnToTitle.Raise(); } else if (!this.RaisedAfterLoadEvent && Context.IsWorldReady) { @@ -393,8 +450,11 @@ namespace StardewModdingAPI.Framework // raise events this.RaisedAfterLoadEvent = true; - this.Events.Save_AfterLoad.Raise(); - this.Events.Time_AfterDayStarted.Raise(); + this.Events.SaveLoaded.RaiseEmpty(); + this.Events.DayStarted.RaiseEmpty(); + + this.Events.Legacy_AfterLoad.Raise(); + this.Events.Legacy_AfterDayStarted.Raise(); } /********* @@ -406,9 +466,14 @@ namespace StardewModdingAPI.Framework // since the game adds & removes its own handler on the fly. if (this.Watchers.WindowSizeWatcher.IsChanged) { - if (this.VerboseLogging) + if (this.Monitor.IsVerbose) this.Monitor.Log($"Events: window size changed to {this.Watchers.WindowSizeWatcher.CurrentValue}.", LogLevel.Trace); - this.Events.Graphics_Resize.Raise(); + + Point oldSize = this.Watchers.WindowSizeWatcher.PreviousValue; + Point newSize = this.Watchers.WindowSizeWatcher.CurrentValue; + + this.Events.WindowResized.Raise(new WindowResizedEventArgs(oldSize, newSize)); + this.Events.Legacy_Resize.Raise(); this.Watchers.WindowSizeWatcher.Reset(); } @@ -430,7 +495,7 @@ namespace StardewModdingAPI.Framework ICursorPosition now = this.Watchers.CursorWatcher.CurrentValue; this.Watchers.CursorWatcher.Reset(); - this.Events.Input_CursorMoved.Raise(new InputCursorMovedEventArgs(was, now)); + this.Events.CursorMoved.Raise(new CursorMovedEventArgs(was, now)); } // raise mouse wheel scrolled @@ -440,9 +505,9 @@ namespace StardewModdingAPI.Framework int now = this.Watchers.MouseWheelScrollWatcher.CurrentValue; this.Watchers.MouseWheelScrollWatcher.Reset(); - if (this.VerboseLogging) + if (this.Monitor.IsVerbose) this.Monitor.Log($"Events: mouse wheel scrolled to {now}.", LogLevel.Trace); - this.Events.Input_MouseWheelScrolled.Raise(new InputMouseWheelScrolledEventArgs(cursor, was, now)); + this.Events.MouseWheelScrolled.Raise(new MouseWheelScrolledEventArgs(cursor, was, now)); } // raise input button events @@ -453,55 +518,55 @@ namespace StardewModdingAPI.Framework if (status == InputStatus.Pressed) { - if (this.VerboseLogging) + if (this.Monitor.IsVerbose) this.Monitor.Log($"Events: button {button} pressed.", LogLevel.Trace); - this.Events.Input_ButtonPressed.Raise(new InputButtonPressedEventArgs(button, cursor, inputState)); - this.Events.Legacy_Input_ButtonPressed.Raise(new EventArgsInput(button, cursor, inputState.SuppressButtons)); + this.Events.ButtonPressed.Raise(new ButtonPressedEventArgs(button, cursor, inputState)); + this.Events.Legacy_ButtonPressed.Raise(new EventArgsInput(button, cursor, inputState.SuppressButtons)); // legacy events if (button.TryGetKeyboard(out Keys key)) { if (key != Keys.None) - this.Events.Legacy_Control_KeyPressed.Raise(new EventArgsKeyPressed(key)); + this.Events.Legacy_KeyPressed.Raise(new EventArgsKeyPressed(key)); } else if (button.TryGetController(out Buttons controllerButton)) { if (controllerButton == Buttons.LeftTrigger || controllerButton == Buttons.RightTrigger) - this.Events.Legacy_Control_ControllerTriggerPressed.Raise(new EventArgsControllerTriggerPressed(PlayerIndex.One, controllerButton, controllerButton == Buttons.LeftTrigger ? inputState.RealController.Triggers.Left : inputState.RealController.Triggers.Right)); + this.Events.Legacy_ControllerTriggerPressed.Raise(new EventArgsControllerTriggerPressed(PlayerIndex.One, controllerButton, controllerButton == Buttons.LeftTrigger ? inputState.RealController.Triggers.Left : inputState.RealController.Triggers.Right)); else - this.Events.Legacy_Control_ControllerButtonPressed.Raise(new EventArgsControllerButtonPressed(PlayerIndex.One, controllerButton)); + this.Events.Legacy_ControllerButtonPressed.Raise(new EventArgsControllerButtonPressed(PlayerIndex.One, controllerButton)); } } else if (status == InputStatus.Released) { - if (this.VerboseLogging) + if (this.Monitor.IsVerbose) this.Monitor.Log($"Events: button {button} released.", LogLevel.Trace); - this.Events.Input_ButtonReleased.Raise(new InputButtonReleasedEventArgs(button, cursor, inputState)); - this.Events.Legacy_Input_ButtonReleased.Raise(new EventArgsInput(button, cursor, inputState.SuppressButtons)); + this.Events.ButtonReleased.Raise(new ButtonReleasedEventArgs(button, cursor, inputState)); + this.Events.Legacy_ButtonReleased.Raise(new EventArgsInput(button, cursor, inputState.SuppressButtons)); // legacy events if (button.TryGetKeyboard(out Keys key)) { if (key != Keys.None) - this.Events.Legacy_Control_KeyReleased.Raise(new EventArgsKeyPressed(key)); + this.Events.Legacy_KeyReleased.Raise(new EventArgsKeyPressed(key)); } else if (button.TryGetController(out Buttons controllerButton)) { if (controllerButton == Buttons.LeftTrigger || controllerButton == Buttons.RightTrigger) - this.Events.Legacy_Control_ControllerTriggerReleased.Raise(new EventArgsControllerTriggerReleased(PlayerIndex.One, controllerButton, controllerButton == Buttons.LeftTrigger ? inputState.RealController.Triggers.Left : inputState.RealController.Triggers.Right)); + this.Events.Legacy_ControllerTriggerReleased.Raise(new EventArgsControllerTriggerReleased(PlayerIndex.One, controllerButton, controllerButton == Buttons.LeftTrigger ? inputState.RealController.Triggers.Left : inputState.RealController.Triggers.Right)); else - this.Events.Legacy_Control_ControllerButtonReleased.Raise(new EventArgsControllerButtonReleased(PlayerIndex.One, controllerButton)); + this.Events.Legacy_ControllerButtonReleased.Raise(new EventArgsControllerButtonReleased(PlayerIndex.One, controllerButton)); } } } // raise legacy state-changed events if (inputState.RealKeyboard != previousInputState.RealKeyboard) - this.Events.Legacy_Control_KeyboardChanged.Raise(new EventArgsKeyboardStateChanged(previousInputState.RealKeyboard, inputState.RealKeyboard)); + this.Events.Legacy_KeyboardChanged.Raise(new EventArgsKeyboardStateChanged(previousInputState.RealKeyboard, inputState.RealKeyboard)); if (inputState.RealMouse != previousInputState.RealMouse) - this.Events.Legacy_Control_MouseChanged.Raise(new EventArgsMouseStateChanged(previousInputState.RealMouse, inputState.RealMouse, new Point((int)previousInputState.CursorPosition.ScreenPixels.X, (int)previousInputState.CursorPosition.ScreenPixels.Y), new Point((int)inputState.CursorPosition.ScreenPixels.X, (int)inputState.CursorPosition.ScreenPixels.Y))); + this.Events.Legacy_MouseChanged.Raise(new EventArgsMouseStateChanged(previousInputState.RealMouse, inputState.RealMouse, new Point((int)previousInputState.CursorPosition.ScreenPixels.X, (int)previousInputState.CursorPosition.ScreenPixels.Y), new Point((int)inputState.CursorPosition.ScreenPixels.X, (int)inputState.CursorPosition.ScreenPixels.Y))); } } @@ -514,14 +579,15 @@ namespace StardewModdingAPI.Framework IClickableMenu now = this.Watchers.ActiveMenuWatcher.CurrentValue; this.Watchers.ActiveMenuWatcher.Reset(); // reset here so a mod changing the menu will be raised as a new event afterwards - if (this.VerboseLogging) + if (this.Monitor.IsVerbose) this.Monitor.Log($"Context: menu changed from {was?.GetType().FullName ?? "none"} to {now?.GetType().FullName ?? "none"}.", LogLevel.Trace); // raise menu events + this.Events.MenuChanged.Raise(new MenuChangedEventArgs(was, now)); if (now != null) - this.Events.Menu_Changed.Raise(new EventArgsClickableMenuChanged(was, now)); + this.Events.Legacy_MenuChanged.Raise(new EventArgsClickableMenuChanged(was, now)); else - this.Events.Menu_Closed.Raise(new EventArgsClickableMenuClosed(was)); + this.Events.Legacy_MenuClosed.Raise(new EventArgsClickableMenuClosed(was)); } /********* @@ -541,15 +607,15 @@ namespace StardewModdingAPI.Framework GameLocation[] removed = this.Watchers.LocationsWatcher.Removed.ToArray(); this.Watchers.LocationsWatcher.ResetLocationList(); - if (this.VerboseLogging) + if (this.Monitor.IsVerbose) { string addedText = this.Watchers.LocationsWatcher.Added.Any() ? string.Join(", ", added.Select(p => p.Name)) : "none"; string removedText = this.Watchers.LocationsWatcher.Removed.Any() ? string.Join(", ", removed.Select(p => p.Name)) : "none"; this.Monitor.Log($"Context: location list changed (added {addedText}; removed {removedText}).", LogLevel.Trace); } - this.Events.World_LocationListChanged.Raise(new WorldLocationListChangedEventArgs(added, removed)); - this.Events.Legacy_Location_LocationsChanged.Raise(new EventArgsLocationsChanged(added, removed)); + this.Events.LocationListChanged.Raise(new LocationListChangedEventArgs(added, removed)); + this.Events.Legacy_LocationsChanged.Raise(new EventArgsLocationsChanged(added, removed)); } // raise location contents changed @@ -565,8 +631,8 @@ namespace StardewModdingAPI.Framework Building[] removed = watcher.BuildingsWatcher.Removed.ToArray(); watcher.BuildingsWatcher.Reset(); - this.Events.World_BuildingListChanged.Raise(new WorldBuildingListChangedEventArgs(location, added, removed)); - this.Events.Legacy_Location_BuildingsChanged.Raise(new EventArgsLocationBuildingsChanged(location, added, removed)); + this.Events.BuildingListChanged.Raise(new BuildingListChangedEventArgs(location, added, removed)); + this.Events.Legacy_BuildingsChanged.Raise(new EventArgsLocationBuildingsChanged(location, added, removed)); } // debris changed @@ -577,7 +643,7 @@ namespace StardewModdingAPI.Framework Debris[] removed = watcher.DebrisWatcher.Removed.ToArray(); watcher.DebrisWatcher.Reset(); - this.Events.World_DebrisListChanged.Raise(new WorldDebrisListChangedEventArgs(location, added, removed)); + this.Events.DebrisListChanged.Raise(new DebrisListChangedEventArgs(location, added, removed)); } // large terrain features changed @@ -588,7 +654,7 @@ namespace StardewModdingAPI.Framework LargeTerrainFeature[] removed = watcher.LargeTerrainFeaturesWatcher.Removed.ToArray(); watcher.LargeTerrainFeaturesWatcher.Reset(); - this.Events.World_LargeTerrainFeatureListChanged.Raise(new WorldLargeTerrainFeatureListChangedEventArgs(location, added, removed)); + this.Events.LargeTerrainFeatureListChanged.Raise(new LargeTerrainFeatureListChangedEventArgs(location, added, removed)); } // NPCs changed @@ -599,7 +665,7 @@ namespace StardewModdingAPI.Framework NPC[] removed = watcher.NpcsWatcher.Removed.ToArray(); watcher.NpcsWatcher.Reset(); - this.Events.World_NpcListChanged.Raise(new WorldNpcListChangedEventArgs(location, added, removed)); + this.Events.NpcListChanged.Raise(new NpcListChangedEventArgs(location, added, removed)); } // objects changed @@ -610,8 +676,8 @@ namespace StardewModdingAPI.Framework KeyValuePair<Vector2, Object>[] removed = watcher.ObjectsWatcher.Removed.ToArray(); watcher.ObjectsWatcher.Reset(); - this.Events.World_ObjectListChanged.Raise(new WorldObjectListChangedEventArgs(location, added, removed)); - this.Events.Legacy_Location_ObjectsChanged.Raise(new EventArgsLocationObjectsChanged(location, added, removed)); + this.Events.ObjectListChanged.Raise(new ObjectListChangedEventArgs(location, added, removed)); + this.Events.Legacy_ObjectsChanged.Raise(new EventArgsLocationObjectsChanged(location, added, removed)); } // terrain features changed @@ -622,7 +688,7 @@ namespace StardewModdingAPI.Framework KeyValuePair<Vector2, TerrainFeature>[] removed = watcher.TerrainFeaturesWatcher.Removed.ToArray(); watcher.TerrainFeaturesWatcher.Reset(); - this.Events.World_TerrainFeatureListChanged.Raise(new WorldTerrainFeatureListChangedEventArgs(location, added, removed)); + this.Events.TerrainFeatureListChanged.Raise(new TerrainFeatureListChangedEventArgs(location, added, removed)); } } } @@ -637,10 +703,11 @@ namespace StardewModdingAPI.Framework int now = this.Watchers.TimeWatcher.CurrentValue; this.Watchers.TimeWatcher.Reset(); - if (this.VerboseLogging) + if (this.Monitor.IsVerbose) this.Monitor.Log($"Events: time changed from {was} to {now}.", LogLevel.Trace); - this.Events.Time_TimeOfDayChanged.Raise(new EventArgsIntChanged(was, now)); + this.Events.TimeChanged.Raise(new TimeChangedEventArgs(was, now)); + this.Events.Legacy_TimeOfDayChanged.Raise(new EventArgsIntChanged(was, now)); } else this.Watchers.TimeWatcher.Reset(); @@ -648,39 +715,45 @@ namespace StardewModdingAPI.Framework // raise player events if (raiseWorldEvents) { - PlayerTracker curPlayer = this.Watchers.CurrentPlayerTracker; + PlayerTracker playerTracker = this.Watchers.CurrentPlayerTracker; // raise current location changed - if (curPlayer.TryGetNewLocation(out GameLocation newLocation)) + if (playerTracker.TryGetNewLocation(out GameLocation newLocation)) { - if (this.VerboseLogging) + if (this.Monitor.IsVerbose) this.Monitor.Log($"Context: set location to {newLocation.Name}.", LogLevel.Trace); - this.Events.Player_Warped.Raise(new EventArgsPlayerWarped(curPlayer.LocationWatcher.PreviousValue, newLocation)); + + GameLocation oldLocation = playerTracker.LocationWatcher.PreviousValue; + this.Events.Warped.Raise(new WarpedEventArgs(playerTracker.Player, oldLocation, newLocation)); + this.Events.Legacy_PlayerWarped.Raise(new EventArgsPlayerWarped(oldLocation, newLocation)); } // raise player leveled up a skill - foreach (KeyValuePair<EventArgsLevelUp.LevelType, IValueWatcher<int>> pair in curPlayer.GetChangedSkills()) + foreach (KeyValuePair<SkillType, IValueWatcher<int>> pair in playerTracker.GetChangedSkills()) { - if (this.VerboseLogging) + if (this.Monitor.IsVerbose) this.Monitor.Log($"Events: player skill '{pair.Key}' changed from {pair.Value.PreviousValue} to {pair.Value.CurrentValue}.", LogLevel.Trace); - this.Events.Player_LeveledUp.Raise(new EventArgsLevelUp(pair.Key, pair.Value.CurrentValue)); + + this.Events.LevelChanged.Raise(new LevelChangedEventArgs(playerTracker.Player, pair.Key, pair.Value.PreviousValue, pair.Value.CurrentValue)); + this.Events.Legacy_LeveledUp.Raise(new EventArgsLevelUp((EventArgsLevelUp.LevelType)pair.Key, pair.Value.CurrentValue)); } // raise player inventory changed - ItemStackChange[] changedItems = curPlayer.GetInventoryChanges().ToArray(); + ItemStackChange[] changedItems = playerTracker.GetInventoryChanges().ToArray(); if (changedItems.Any()) { - if (this.VerboseLogging) + if (this.Monitor.IsVerbose) this.Monitor.Log("Events: player inventory changed.", LogLevel.Trace); - this.Events.Player_InventoryChanged.Raise(new EventArgsInventoryChanged(Game1.player.Items, changedItems.ToList())); + this.Events.InventoryChanged.Raise(new InventoryChangedEventArgs(playerTracker.Player, changedItems)); + this.Events.Legacy_InventoryChanged.Raise(new EventArgsInventoryChanged(Game1.player.Items, changedItems)); } // raise mine level changed - if (curPlayer.TryGetNewMineLevel(out int mineLevel)) + if (playerTracker.TryGetNewMineLevel(out int mineLevel)) { - if (this.VerboseLogging) + if (this.Monitor.IsVerbose) this.Monitor.Log($"Context: mine level changed to {mineLevel}.", LogLevel.Trace); - this.Events.Mine_LevelChanged.Raise(new EventArgsMineLevelChanged(curPlayer.MineLevelWatcher.PreviousValue, mineLevel)); + this.Events.Legacy_MineLevelChanged.Raise(new EventArgsMineLevelChanged(playerTracker.MineLevelWatcher.PreviousValue, mineLevel)); } } this.Watchers.CurrentPlayerTracker?.Reset(); @@ -694,8 +767,9 @@ namespace StardewModdingAPI.Framework *********/ this.TicksElapsed++; if (this.TicksElapsed == 1) - this.Events.GameLoop_Launched.Raise(new GameLoopLaunchedEventArgs()); - this.Events.GameLoop_Updating.Raise(new GameLoopUpdatingEventArgs(this.TicksElapsed)); + this.Events.GameLaunched.Raise(new GameLaunchedEventArgs()); + this.Events.UnvalidatedUpdateTicking.Raise(new UnvalidatedUpdateTickingEventArgs(this.TicksElapsed)); + this.Events.UpdateTicking.Raise(new UpdateTickingEventArgs(this.TicksElapsed)); try { this.Input.UpdateSuppression(); @@ -703,29 +777,30 @@ namespace StardewModdingAPI.Framework } catch (Exception ex) { - this.Monitor.Log($"An error occured in the base update loop: {ex.GetLogSummary()}", LogLevel.Error); + this.MonitorForGame.Log($"An error occured in the base update loop: {ex.GetLogSummary()}", LogLevel.Error); } - this.Events.GameLoop_Updated.Raise(new GameLoopUpdatedEventArgs(this.TicksElapsed)); + this.Events.UnvalidatedUpdateTicked.Raise(new UnvalidatedUpdateTickedEventArgs(this.TicksElapsed)); + this.Events.UpdateTicked.Raise(new UpdateTickedEventArgs(this.TicksElapsed)); /********* ** Update events *********/ - this.Events.Specialised_UnvalidatedUpdateTick.Raise(); + this.Events.Legacy_UnvalidatedUpdateTick.Raise(); if (this.TicksElapsed == 1) - this.Events.Game_FirstUpdateTick.Raise(); - this.Events.Game_UpdateTick.Raise(); + this.Events.Legacy_FirstUpdateTick.Raise(); + this.Events.Legacy_UpdateTick.Raise(); if (this.CurrentUpdateTick % 2 == 0) - this.Events.Game_SecondUpdateTick.Raise(); + this.Events.Legacy_SecondUpdateTick.Raise(); if (this.CurrentUpdateTick % 4 == 0) - this.Events.Game_FourthUpdateTick.Raise(); + this.Events.Legacy_FourthUpdateTick.Raise(); if (this.CurrentUpdateTick % 8 == 0) - this.Events.Game_EighthUpdateTick.Raise(); + this.Events.Legacy_EighthUpdateTick.Raise(); if (this.CurrentUpdateTick % 15 == 0) - this.Events.Game_QuarterSecondTick.Raise(); + this.Events.Legacy_QuarterSecondTick.Raise(); if (this.CurrentUpdateTick % 30 == 0) - this.Events.Game_HalfSecondTick.Raise(); + this.Events.Legacy_HalfSecondTick.Raise(); if (this.CurrentUpdateTick % 60 == 0) - this.Events.Game_OneSecondTick.Raise(); + this.Events.Legacy_OneSecondTick.Raise(); this.CurrentUpdateTick += 1; if (this.CurrentUpdateTick >= 60) this.CurrentUpdateTick = 0; @@ -796,32 +871,9 @@ namespace StardewModdingAPI.Framework [SuppressMessage("SMAPI.CommonErrors", "AvoidImplicitNetFieldCast", Justification = "copied from game code as-is")] private void DrawImpl(GameTime gameTime) { - if (Game1.debugMode) - { - if (Game1._fpsStopwatch.IsRunning) - { - float totalSeconds = (float)Game1._fpsStopwatch.Elapsed.TotalSeconds; - Game1._fpsList.Add(totalSeconds); - while (Game1._fpsList.Count >= 120) - Game1._fpsList.RemoveAt(0); - float num = 0.0f; - foreach (float fps in Game1._fpsList) - num += fps; - Game1._fps = (float)(1.0 / ((double)num / (double)Game1._fpsList.Count)); - } - Game1._fpsStopwatch.Restart(); - } - else - { - if (Game1._fpsStopwatch.IsRunning) - Game1._fpsStopwatch.Reset(); - Game1._fps = 0.0f; - Game1._fpsList.Clear(); - } if (Game1._newDayTask != null) { this.GraphicsDevice.Clear(this.bgColor); - //base.Draw(gameTime); } else { @@ -834,18 +886,23 @@ namespace StardewModdingAPI.Framework if (activeClickableMenu != null) { Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + this.Events.Rendering.RaiseEmpty(); try { - this.Events.Graphics_OnPreRenderGuiEvent.Raise(); + this.Events.RenderingActiveMenu.RaiseEmpty(); + this.Events.Legacy_OnPreRenderGuiEvent.Raise(); activeClickableMenu.draw(Game1.spriteBatch); - this.Events.Graphics_OnPostRenderGuiEvent.Raise(); + this.Events.RenderedActiveMenu.RaiseEmpty(); + this.Events.Legacy_OnPostRenderGuiEvent.Raise(); } catch (Exception ex) { this.Monitor.Log($"The {activeClickableMenu.GetType().FullName} menu crashed while drawing itself during save. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); activeClickableMenu.exitThisMenu(); } - this.RaisePostRender(); + this.Events.Rendered.RaiseEmpty(); + this.Events.Legacy_OnPostRenderEvent.Raise(); + Game1.spriteBatch.End(); } if (Game1.overlayMenu != null) @@ -854,7 +911,6 @@ namespace StardewModdingAPI.Framework Game1.overlayMenu.draw(Game1.spriteBatch); Game1.spriteBatch.End(); } - //base.Draw(gameTime); this.renderScreenBuffer(); } else @@ -863,37 +919,49 @@ namespace StardewModdingAPI.Framework if (Game1.activeClickableMenu != null && Game1.options.showMenuBackground && Game1.activeClickableMenu.showWithoutTransparencyIfOptionIsSet()) { Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + + this.Events.Rendering.RaiseEmpty(); try { Game1.activeClickableMenu.drawBackground(Game1.spriteBatch); - this.Events.Graphics_OnPreRenderGuiEvent.Raise(); + this.Events.RenderingActiveMenu.RaiseEmpty(); + this.Events.Legacy_OnPreRenderGuiEvent.Raise(); Game1.activeClickableMenu.draw(Game1.spriteBatch); - this.Events.Graphics_OnPostRenderGuiEvent.Raise(); + this.Events.RenderedActiveMenu.RaiseEmpty(); + this.Events.Legacy_OnPostRenderGuiEvent.Raise(); } catch (Exception ex) { this.Monitor.Log($"The {Game1.activeClickableMenu.GetType().FullName} menu crashed while drawing itself. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); Game1.activeClickableMenu.exitThisMenu(); } - this.RaisePostRender(); + this.Events.Rendered.RaiseEmpty(); + this.Events.Legacy_OnPostRenderEvent.Raise(); Game1.spriteBatch.End(); this.drawOverlays(Game1.spriteBatch); if ((double)Game1.options.zoomLevel != 1.0) { this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); this.GraphicsDevice.Clear(this.bgColor); - Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); Game1.spriteBatch.End(); } + if (Game1.overlayMenu == null) + return; + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.overlayMenu.draw(Game1.spriteBatch); + Game1.spriteBatch.End(); } else if (Game1.gameMode == (byte)11) { - Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + this.Events.Rendering.RaiseEmpty(); Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3685"), new Vector2(16f, 16f), Color.HotPink); Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3686"), new Vector2(16f, 32f), new Color(0, (int)byte.MaxValue, 0)); Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.parseText(Game1.errorMessage, Game1.dialogueFont, Game1.graphics.GraphicsDevice.Viewport.Width), new Vector2(16f, 48f), Color.White); - this.RaisePostRender(); + this.Events.Rendered.RaiseEmpty(); + this.Events.Legacy_OnPostRenderEvent.Raise(); Game1.spriteBatch.End(); } else if (Game1.currentMinigame != null) @@ -907,25 +975,27 @@ namespace StardewModdingAPI.Framework } this.drawOverlays(Game1.spriteBatch); this.RaisePostRender(needsNewBatch: true); - if ((double)Game1.options.zoomLevel != 1.0) - { - this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); - this.GraphicsDevice.Clear(this.bgColor); - Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); - Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); - Game1.spriteBatch.End(); - } + if ((double)Game1.options.zoomLevel == 1.0) + return; + this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); + this.GraphicsDevice.Clear(this.bgColor); + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); + Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.End(); } else if (Game1.showingEndOfNightStuff) { Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + this.Events.Rendering.RaiseEmpty(); if (Game1.activeClickableMenu != null) { try { - this.Events.Graphics_OnPreRenderGuiEvent.Raise(); + this.Events.RenderingActiveMenu.RaiseEmpty(); + this.Events.Legacy_OnPreRenderGuiEvent.Raise(); Game1.activeClickableMenu.draw(Game1.spriteBatch); - this.Events.Graphics_OnPostRenderGuiEvent.Raise(); + this.Events.RenderedActiveMenu.RaiseEmpty(); + this.Events.Legacy_OnPostRenderGuiEvent.Raise(); } catch (Exception ex) { @@ -933,21 +1003,21 @@ namespace StardewModdingAPI.Framework Game1.activeClickableMenu.exitThisMenu(); } } - this.RaisePostRender(); + this.Events.Rendered.RaiseEmpty(); Game1.spriteBatch.End(); this.drawOverlays(Game1.spriteBatch); - if ((double)Game1.options.zoomLevel != 1.0) - { - this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); - this.GraphicsDevice.Clear(this.bgColor); - Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); - Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); - Game1.spriteBatch.End(); - } + if ((double)Game1.options.zoomLevel == 1.0) + return; + this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); + this.GraphicsDevice.Clear(this.bgColor); + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); + Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.End(); } else if (Game1.gameMode == (byte)6 || Game1.gameMode == (byte)3 && Game1.currentLocation == null) { Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + this.Events.Rendering.RaiseEmpty(); string str1 = ""; for (int index = 0; (double)index < gameTime.TotalGameTime.TotalMilliseconds % 999.0 / 333.0; ++index) str1 += "."; @@ -959,24 +1029,36 @@ namespace StardewModdingAPI.Framework int x = 64; int y = Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Bottom - height; SpriteText.drawString(Game1.spriteBatch, s, x, y, 999999, widthOfString, height, 1f, 0.88f, false, 0, str3, -1); + this.Events.Rendered.RaiseEmpty(); Game1.spriteBatch.End(); this.drawOverlays(Game1.spriteBatch); if ((double)Game1.options.zoomLevel != 1.0) { this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); this.GraphicsDevice.Clear(this.bgColor); - Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); Game1.spriteBatch.End(); } + if (Game1.overlayMenu != null) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.overlayMenu.draw(Game1.spriteBatch); + Game1.spriteBatch.End(); + } //base.Draw(gameTime); } else { + byte batchOpens = 0; // used for rendering event + Microsoft.Xna.Framework.Rectangle rectangle; + Viewport viewport; if (Game1.gameMode == (byte)0) { Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + if (++batchOpens == 1) + this.Events.Rendering.RaiseEmpty(); } else { @@ -985,6 +1067,8 @@ namespace StardewModdingAPI.Framework this.GraphicsDevice.SetRenderTarget(Game1.lightmap); this.GraphicsDevice.Clear(Color.White * 0.0f); Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + if (++batchOpens == 1) + this.Events.Rendering.RaiseEmpty(); Game1.spriteBatch.Draw(Game1.staminaRect, Game1.lightmap.Bounds, Game1.currentLocation.Name.StartsWith("UndergroundMine") ? Game1.mine.getLightingColor(gameTime) : (Game1.ambientLight.Equals(Color.White) || Game1.isRaining && (bool)((NetFieldBase<bool, NetBool>)Game1.currentLocation.isOutdoors) ? Game1.outdoorLight : Game1.ambientLight)); for (int index = 0; index < Game1.currentLightSources.Count; ++index) { @@ -998,21 +1082,32 @@ namespace StardewModdingAPI.Framework Game1.bloom.BeginDraw(); this.GraphicsDevice.Clear(this.bgColor); Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - this.Events.Graphics_OnPreRenderEvent.Raise(); + if (++batchOpens == 1) + this.Events.Rendering.RaiseEmpty(); + this.Events.RenderingWorld.RaiseEmpty(); + this.Events.Legacy_OnPreRenderEvent.Raise(); if (Game1.background != null) Game1.background.draw(Game1.spriteBatch); Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch); Game1.currentLocation.Map.GetLayer("Back").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, 4); Game1.currentLocation.drawWater(Game1.spriteBatch); - IEnumerable<Farmer> source = Game1.currentLocation.farmers; + this._farmerShadows.Clear(); if (Game1.currentLocation.currentEvent != null && !Game1.currentLocation.currentEvent.isFestival && Game1.currentLocation.currentEvent.farmerActors.Count > 0) - source = (IEnumerable<Farmer>)Game1.currentLocation.currentEvent.farmerActors; - IEnumerable<Farmer> farmers = source.Where<Farmer>((Func<Farmer, bool>)(farmer => { - if (!farmer.IsLocalPlayer) - return !(bool)((NetFieldBase<bool, NetBool>)farmer.hidden); - return true; - })); + foreach (Farmer farmerActor in Game1.currentLocation.currentEvent.farmerActors) + { + if (farmerActor.IsLocalPlayer && Game1.displayFarmer || !(bool)((NetFieldBase<bool, NetBool>)farmerActor.hidden)) + this._farmerShadows.Add(farmerActor); + } + } + else + { + foreach (Farmer farmer in Game1.currentLocation.farmers) + { + if (farmer.IsLocalPlayer && Game1.displayFarmer || !(bool)((NetFieldBase<bool, NetBool>)farmer.hidden)) + this._farmerShadows.Add(farmer); + } + } if (!Game1.currentLocation.shouldHideCharacters()) { if (Game1.CurrentEvent == null) @@ -1031,13 +1126,13 @@ namespace StardewModdingAPI.Framework Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, actor.Position + new Vector2((float)(actor.Sprite.SpriteWidth * 4) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : (actor.Sprite.SpriteHeight <= 16 ? -4 : 12))))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)actor.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)actor.scale), SpriteEffects.None, Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 1E-06f); } } - foreach (Farmer farmer in farmers) + foreach (Farmer farmerShadow in this._farmerShadows) { - if (!(bool)((NetFieldBase<bool, NetBool>)farmer.swimming) && !farmer.isRidingHorse() && (Game1.currentLocation == null || !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(farmer.getTileLocation()))) + if (!(bool)((NetFieldBase<bool, NetBool>)farmerShadow.swimming) && !farmerShadow.isRidingHorse() && (Game1.currentLocation == null || !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(farmerShadow.getTileLocation()))) { SpriteBatch spriteBatch = Game1.spriteBatch; Texture2D shadowTexture = Game1.shadowTexture; - Vector2 local = Game1.GlobalToLocal(farmer.Position + new Vector2(32f, 24f)); + Vector2 local = Game1.GlobalToLocal(farmerShadow.Position + new Vector2(32f, 24f)); Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds); Color white = Color.White; double num1 = 0.0; @@ -1046,7 +1141,7 @@ namespace StardewModdingAPI.Framework bounds = Game1.shadowTexture.Bounds; double y = (double)bounds.Center.Y; Vector2 origin = new Vector2((float)x, (float)y); - double num2 = 4.0 - (!farmer.running && !farmer.UsingTool || farmer.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmer.FarmerSprite.CurrentFrame]) * 0.5); + double num2 = 4.0 - (!farmerShadow.running && !farmerShadow.UsingTool || farmerShadow.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmerShadow.FarmerSprite.CurrentFrame]) * 0.5); int num3 = 0; double num4 = 0.0; spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, (float)num1, origin, (float)num2, (SpriteEffects)num3, (float)num4); @@ -1075,13 +1170,13 @@ namespace StardewModdingAPI.Framework Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, actor.Position + new Vector2((float)(actor.Sprite.SpriteWidth * 4) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : 12)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)actor.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)actor.scale), SpriteEffects.None, Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 1E-06f); } } - foreach (Farmer farmer in farmers) + foreach (Farmer farmerShadow in this._farmerShadows) { - if (!(bool)((NetFieldBase<bool, NetBool>)farmer.swimming) && !farmer.isRidingHorse() && (Game1.currentLocation != null && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(farmer.getTileLocation()))) + if (!(bool)((NetFieldBase<bool, NetBool>)farmerShadow.swimming) && !farmerShadow.isRidingHorse() && (Game1.currentLocation != null && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(farmerShadow.getTileLocation()))) { SpriteBatch spriteBatch = Game1.spriteBatch; Texture2D shadowTexture = Game1.shadowTexture; - Vector2 local = Game1.GlobalToLocal(farmer.Position + new Vector2(32f, 24f)); + Vector2 local = Game1.GlobalToLocal(farmerShadow.Position + new Vector2(32f, 24f)); Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds); Color white = Color.White; double num1 = 0.0; @@ -1090,7 +1185,7 @@ namespace StardewModdingAPI.Framework bounds = Game1.shadowTexture.Bounds; double y = (double)bounds.Center.Y; Vector2 origin = new Vector2((float)x, (float)y); - double num2 = 4.0 - (!farmer.running && !farmer.UsingTool || farmer.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmer.FarmerSprite.CurrentFrame]) * 0.5); + double num2 = 4.0 - (!farmerShadow.running && !farmerShadow.UsingTool || farmerShadow.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmerShadow.FarmerSprite.CurrentFrame]) * 0.5); int num3 = 0; double num4 = 0.0; spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, (float)num1, origin, (float)num2, (SpriteEffects)num3, (float)num4); @@ -1141,14 +1236,14 @@ namespace StardewModdingAPI.Framework Location mapDisplayLocation2 = new Location(rectangle.Right, (int)Game1.player.Position.Y - 38); Size size2 = Game1.viewport.Size; if (layer2.PickTile(mapDisplayLocation2, size2).TileIndexProperties.ContainsKey("FrontAlways")) - goto label_139; + goto label_129; } else - goto label_139; + goto label_129; } Game1.drawPlayerHeldObject(Game1.player); } - label_139: + label_129: if ((Game1.player.UsingTool || Game1.pickingTool) && Game1.player.CurrentTool != null && ((!Game1.player.CurrentTool.Name.Equals("Seeds") || Game1.pickingTool) && (Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), (int)Game1.player.Position.Y - 38), Game1.viewport.Size) != null && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), Game1.player.getStandingY()), Game1.viewport.Size) == null))) Game1.drawTool(Game1.player); if (Game1.currentLocation.Map.GetLayer("AlwaysFront") != null) @@ -1186,9 +1281,23 @@ namespace StardewModdingAPI.Framework if (Game1.farmEvent != null) Game1.farmEvent.draw(Game1.spriteBatch); if ((double)Game1.currentLocation.LightLevel > 0.0 && Game1.timeOfDay < 2000) - Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * Game1.currentLocation.LightLevel); + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D fadeToBlackRect = Game1.fadeToBlackRect; + viewport = Game1.graphics.GraphicsDevice.Viewport; + Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; + Color color = Color.Black * Game1.currentLocation.LightLevel; + spriteBatch.Draw(fadeToBlackRect, bounds, color); + } if (Game1.screenGlow) - Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Game1.screenGlowColor * Game1.screenGlowAlpha); + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D fadeToBlackRect = Game1.fadeToBlackRect; + viewport = Game1.graphics.GraphicsDevice.Viewport; + Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; + Color color = Game1.screenGlowColor * Game1.screenGlowAlpha; + spriteBatch.Draw(fadeToBlackRect, bounds, color); + } Game1.currentLocation.drawAboveAlwaysFrontLayer(Game1.spriteBatch); if (Game1.player.CurrentTool != null && Game1.player.CurrentTool is FishingRod && ((Game1.player.CurrentTool as FishingRod).isTimingCast || (double)(Game1.player.CurrentTool as FishingRod).castingChosenCountdown > 0.0 || ((Game1.player.CurrentTool as FishingRod).fishCaught || (Game1.player.CurrentTool as FishingRod).showingTreasure))) Game1.player.CurrentTool.draw(Game1.spriteBatch); @@ -1221,34 +1330,77 @@ namespace StardewModdingAPI.Framework Game1.spriteBatch.Begin(SpriteSortMode.Deferred, this.lightingBlend, SamplerState.LinearClamp, (DepthStencilState)null, (RasterizerState)null); Game1.spriteBatch.Draw((Texture2D)Game1.lightmap, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(Game1.lightmap.Bounds), Color.White, 0.0f, Vector2.Zero, (float)(Game1.options.lightingQuality / 2), SpriteEffects.None, 1f); if (Game1.isRaining && (bool)((NetFieldBase<bool, NetBool>)Game1.currentLocation.isOutdoors) && !(Game1.currentLocation is Desert)) - Game1.spriteBatch.Draw(Game1.staminaRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.OrangeRed * 0.45f); + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D staminaRect = Game1.staminaRect; + viewport = Game1.graphics.GraphicsDevice.Viewport; + Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; + Color color = Color.OrangeRed * 0.45f; + spriteBatch.Draw(staminaRect, bounds, color); + } Game1.spriteBatch.End(); } Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); if (Game1.drawGrid) { - int x1 = -Game1.viewport.X % 64; - float num1 = (float)(-Game1.viewport.Y % 64); - int x2 = x1; - while (x2 < Game1.graphics.GraphicsDevice.Viewport.Width) + int num1 = -Game1.viewport.X % 64; + float num2 = (float)(-Game1.viewport.Y % 64); + int num3 = num1; + while (true) { - Game1.spriteBatch.Draw(Game1.staminaRect, new Microsoft.Xna.Framework.Rectangle(x2, (int)num1, 1, Game1.graphics.GraphicsDevice.Viewport.Height), Color.Red * 0.5f); - x2 += 64; + int num4 = num3; + viewport = Game1.graphics.GraphicsDevice.Viewport; + int width1 = viewport.Width; + if (num4 < width1) + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D staminaRect = Game1.staminaRect; + int x = num3; + int y = (int)num2; + int width2 = 1; + viewport = Game1.graphics.GraphicsDevice.Viewport; + int height = viewport.Height; + Microsoft.Xna.Framework.Rectangle destinationRectangle = new Microsoft.Xna.Framework.Rectangle(x, y, width2, height); + Color color = Color.Red * 0.5f; + spriteBatch.Draw(staminaRect, destinationRectangle, color); + num3 += 64; + } + else + break; } - float num2 = num1; - while ((double)num2 < (double)Game1.graphics.GraphicsDevice.Viewport.Height) + float num5 = num2; + while (true) { - Game1.spriteBatch.Draw(Game1.staminaRect, new Microsoft.Xna.Framework.Rectangle(x1, (int)num2, Game1.graphics.GraphicsDevice.Viewport.Width, 1), Color.Red * 0.5f); - num2 += 64f; + double num4 = (double)num5; + viewport = Game1.graphics.GraphicsDevice.Viewport; + double height1 = (double)viewport.Height; + if (num4 < height1) + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D staminaRect = Game1.staminaRect; + int x = num1; + int y = (int)num5; + viewport = Game1.graphics.GraphicsDevice.Viewport; + int width = viewport.Width; + int height2 = 1; + Microsoft.Xna.Framework.Rectangle destinationRectangle = new Microsoft.Xna.Framework.Rectangle(x, y, width, height2); + Color color = Color.Red * 0.5f; + spriteBatch.Draw(staminaRect, destinationRectangle, color); + num5 += 64f; + } + else + break; } } if (Game1.currentBillboard != 0) this.drawBillboard(); if ((Game1.displayHUD || Game1.eventUp) && (Game1.currentBillboard == 0 && Game1.gameMode == (byte)3) && (!Game1.freezeControls && !Game1.panMode && !Game1.HostPaused)) { - this.Events.Graphics_OnPreRenderHudEvent.Raise(); + this.Events.RenderingHud.RaiseEmpty(); + this.Events.Legacy_OnPreRenderHudEvent.Raise(); this.drawHUD(); - this.Events.Graphics_OnPostRenderHudEvent.Raise(); + this.Events.RenderedHud.RaiseEmpty(); + this.Events.Legacy_OnPostRenderHudEvent.Raise(); } else if (Game1.activeClickableMenu == null && Game1.farmEvent == null) Game1.spriteBatch.Draw(Game1.mouseCursors, new Vector2((float)Game1.getOldMouseX(), (float)Game1.getOldMouseY()), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.mouseCursors, 0, 16, 16)), Color.White, 0.0f, Vector2.Zero, (float)(4.0 + (double)Game1.dialogueButtonScale / 150.0), SpriteEffects.None, 1f); @@ -1288,13 +1440,34 @@ namespace StardewModdingAPI.Framework if (Game1.eventUp && Game1.currentLocation != null && Game1.currentLocation.currentEvent != null) Game1.currentLocation.currentEvent.drawAfterMap(Game1.spriteBatch); if (Game1.isRaining && Game1.currentLocation != null && ((bool)((NetFieldBase<bool, NetBool>)Game1.currentLocation.isOutdoors) && !(Game1.currentLocation is Desert))) - Game1.spriteBatch.Draw(Game1.staminaRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Blue * 0.2f); + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D staminaRect = Game1.staminaRect; + viewport = Game1.graphics.GraphicsDevice.Viewport; + Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; + Color color = Color.Blue * 0.2f; + spriteBatch.Draw(staminaRect, bounds, color); + } if ((Game1.fadeToBlack || Game1.globalFade) && !Game1.menuUp && (!Game1.nameSelectUp || Game1.messagePause)) - Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * (Game1.gameMode == (byte)0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha)); + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D fadeToBlackRect = Game1.fadeToBlackRect; + viewport = Game1.graphics.GraphicsDevice.Viewport; + Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; + Color color = Color.Black * (Game1.gameMode == (byte)0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha); + spriteBatch.Draw(fadeToBlackRect, bounds, color); + } else if ((double)Game1.flashAlpha > 0.0) { if (Game1.options.screenFlash) - Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.White * Math.Min(1f, Game1.flashAlpha)); + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D fadeToBlackRect = Game1.fadeToBlackRect; + viewport = Game1.graphics.GraphicsDevice.Viewport; + Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; + Color color = Color.White * Math.Min(1f, Game1.flashAlpha); + spriteBatch.Draw(fadeToBlackRect, bounds, color); + } Game1.flashAlpha -= 0.1f; } if ((Game1.messagePause || Game1.globalFade) && Game1.dialogueUp) @@ -1335,9 +1508,11 @@ namespace StardewModdingAPI.Framework { try { - this.Events.Graphics_OnPreRenderGuiEvent.Raise(); + this.Events.RenderingActiveMenu.RaiseEmpty(); + this.Events.Legacy_OnPreRenderGuiEvent.Raise(); Game1.activeClickableMenu.draw(Game1.spriteBatch); - this.Events.Graphics_OnPostRenderGuiEvent.Raise(); + this.Events.RenderedActiveMenu.RaiseEmpty(); + this.Events.Legacy_OnPostRenderGuiEvent.Raise(); } catch (Exception ex) { @@ -1352,11 +1527,12 @@ namespace StardewModdingAPI.Framework string s = Game1.content.LoadString("Strings\\StringsFromCSFiles:DayTimeMoneyBox.cs.10378"); SpriteText.drawStringWithScrollBackground(Game1.spriteBatch, s, 96, 32, "", 1f, -1); } - this.RaisePostRender(); + this.Events.RenderedWorld.RaiseEmpty(); + this.Events.Rendered.RaiseEmpty(); + this.Events.Legacy_OnPostRenderEvent.Raise(); Game1.spriteBatch.End(); this.drawOverlays(Game1.spriteBatch); this.renderScreenBuffer(); - //base.Draw(gameTime); } } } @@ -1377,11 +1553,11 @@ namespace StardewModdingAPI.Framework /// <param name="needsNewBatch">Whether to create a new sprite batch.</param> private void RaisePostRender(bool needsNewBatch = false) { - if (this.Events.Graphics_OnPostRenderEvent.HasListeners()) + if (this.Events.Legacy_OnPostRenderEvent.HasListeners()) { if (needsNewBatch) Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); - this.Events.Graphics_OnPostRenderEvent.Raise(); + this.Events.Legacy_OnPostRenderEvent.Raise(); if (needsNewBatch) Game1.spriteBatch.End(); } diff --git a/src/SMAPI/Framework/SModHooks.cs b/src/SMAPI/Framework/SModHooks.cs new file mode 100644 index 00000000..9f0201c8 --- /dev/null +++ b/src/SMAPI/Framework/SModHooks.cs @@ -0,0 +1,34 @@ +using System; +using StardewValley; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Invokes callbacks for mod hooks provided by the game.</summary> + internal class SModHooks : ModHooks + { + /********* + ** Properties + *********/ + /// <summary>A callback to invoke before <see cref="Game1.newDayAfterFade"/> runs.</summary> + private readonly Action BeforeNewDayAfterFade; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="beforeNewDayAfterFade">A callback to invoke before <see cref="Game1.newDayAfterFade"/> runs.</param> + public SModHooks(Action beforeNewDayAfterFade) + { + this.BeforeNewDayAfterFade = beforeNewDayAfterFade; + } + + /// <summary>A hook invoked when <see cref="Game1.newDayAfterFade"/> is called.</summary> + /// <param name="action">The vanilla <see cref="Game1.newDayAfterFade"/> logic.</param> + public override void OnGame1_NewDayAfterFade(Action action) + { + this.BeforeNewDayAfterFade?.Invoke(); + action(); + } + } +} diff --git a/src/SMAPI/Framework/SMultiplayer.cs b/src/SMAPI/Framework/SMultiplayer.cs index 687b1922..629fce1d 100644 --- a/src/SMAPI/Framework/SMultiplayer.cs +++ b/src/SMAPI/Framework/SMultiplayer.cs @@ -1,9 +1,33 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Galaxy.Api; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using StardewModdingAPI.Events; using StardewModdingAPI.Framework.Events; +using StardewModdingAPI.Framework.Networking; +using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Toolkit.Serialisation; using StardewValley; +using StardewValley.Network; +using StardewValley.SDKs; namespace StardewModdingAPI.Framework { /// <summary>SMAPI's implementation of the game's core multiplayer logic.</summary> + /// <remarks> + /// SMAPI syncs mod context to all players through the host as such: + /// 1. Farmhand sends ModContext + PlayerIntro. + /// 2. If host receives ModContext: it stores the context, replies with known contexts, and forwards it to other farmhands. + /// 3. If host receives PlayerIntro before ModContext: it stores a 'vanilla player' context, and forwards it to other farmhands. + /// 4. If farmhand receives ModContext: it stores it. + /// 5. If farmhand receives ServerIntro without a preceding ModContext: it stores a 'vanilla host' context. + /// 6. If farmhand receives PlayerIntro without a preceding ModContext AND it's not the host peer: it stores a 'vanilla player' context. + /// + /// Once a farmhand/server stored a context, messages can be sent to that player through the SMAPI APIs. + /// </remarks> internal class SMultiplayer : Multiplayer { /********* @@ -12,9 +36,31 @@ namespace StardewModdingAPI.Framework /// <summary>Encapsulates monitoring and logging.</summary> private readonly IMonitor Monitor; + /// <summary>Tracks the installed mods.</summary> + private readonly ModRegistry ModRegistry; + + /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> + private readonly JsonHelper JsonHelper; + + /// <summary>Simplifies access to private code.</summary> + private readonly Reflector Reflection; + /// <summary>Manages SMAPI events.</summary> private readonly EventManager EventManager; + /// <summary>A callback to invoke when a mod message is received.</summary> + private readonly Action<ModMessageModel> OnModMessageReceived; + + + /********* + ** Accessors + *********/ + /// <summary>The metadata for each connected peer.</summary> + public IDictionary<long, MultiplayerPeer> Peers { get; } = new Dictionary<long, MultiplayerPeer>(); + + /// <summary>The metadata for the host player, if the current player is a farmhand.</summary> + public MultiplayerPeer HostPeer; + /********* ** Public methods @@ -22,26 +68,454 @@ namespace StardewModdingAPI.Framework /// <summary>Construct an instance.</summary> /// <param name="monitor">Encapsulates monitoring and logging.</param> /// <param name="eventManager">Manages SMAPI events.</param> - public SMultiplayer(IMonitor monitor, EventManager eventManager) + /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> + /// <param name="modRegistry">Tracks the installed mods.</param> + /// <param name="reflection">Simplifies access to private code.</param> + /// <param name="onModMessageReceived">A callback to invoke when a mod message is received.</param> + public SMultiplayer(IMonitor monitor, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, Reflector reflection, Action<ModMessageModel> onModMessageReceived) { this.Monitor = monitor; this.EventManager = eventManager; + this.JsonHelper = jsonHelper; + this.ModRegistry = modRegistry; + this.Reflection = reflection; + this.OnModMessageReceived = onModMessageReceived; } /// <summary>Handle sync messages from other players and perform other initial sync logic.</summary> public override void UpdateEarly() { - this.EventManager.Multiplayer_BeforeMainSync.Raise(); + this.EventManager.Legacy_BeforeMainSync.Raise(); base.UpdateEarly(); - this.EventManager.Multiplayer_AfterMainSync.Raise(); + this.EventManager.Legacy_AfterMainSync.Raise(); } /// <summary>Broadcast sync messages to other players and perform other final sync logic.</summary> public override void UpdateLate(bool forceSync = false) { - this.EventManager.Multiplayer_BeforeMainBroadcast.Raise(); + this.EventManager.Legacy_BeforeMainBroadcast.Raise(); base.UpdateLate(forceSync); - this.EventManager.Multiplayer_AfterMainBroadcast.Raise(); + this.EventManager.Legacy_AfterMainBroadcast.Raise(); + } + + /// <summary>Initialise a client before the game connects to a remote server.</summary> + /// <param name="client">The client to initialise.</param> + public override Client InitClient(Client client) + { + switch (client) + { + case LidgrenClient _: + { + string address = this.Reflection.GetField<string>(client, "address").GetValue(); + return new SLidgrenClient(address, this.OnClientProcessingMessage, this.OnClientSendingMessage); + } + + case GalaxyNetClient _: + { + GalaxyID address = this.Reflection.GetField<GalaxyID>(client, "lobbyId").GetValue(); + return new SGalaxyNetClient(address, this.OnClientProcessingMessage, this.OnClientSendingMessage); + } + + default: + return client; + } + } + + /// <summary>Initialise a server before the game connects to an incoming player.</summary> + /// <param name="server">The server to initialise.</param> + public override Server InitServer(Server server) + { + switch (server) + { + case LidgrenServer _: + { + IGameServer gameServer = this.Reflection.GetField<IGameServer>(server, "gameServer").GetValue(); + return new SLidgrenServer(gameServer, this, this.OnServerProcessingMessage); + } + + case GalaxyNetServer _: + { + IGameServer gameServer = this.Reflection.GetField<IGameServer>(server, "gameServer").GetValue(); + return new SGalaxyNetServer(gameServer, this, this.OnServerProcessingMessage); + } + + default: + return server; + } + } + + /// <summary>A callback raised when sending a message as a farmhand.</summary> + /// <param name="message">The message being sent.</param> + /// <param name="sendMessage">Send an arbitrary message through the client.</param> + /// <param name="resume">Resume sending the underlying message.</param> + protected void OnClientSendingMessage(OutgoingMessage message, Action<OutgoingMessage> sendMessage, Action resume) + { + if (this.Monitor.IsVerbose) + this.Monitor.Log($"CLIENT SEND {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace); + + switch (message.MessageType) + { + // sync mod context (step 1) + case (byte)MessageType.PlayerIntroduction: + sendMessage(new OutgoingMessage((byte)MessageType.ModContext, Game1.player.UniqueMultiplayerID, this.GetContextSyncMessageFields())); + resume(); + break; + + // run default logic + default: + resume(); + break; + } + } + + /// <summary>Process an incoming network message as the host player.</summary> + /// <param name="message">The message to process.</param> + /// <param name="sendMessage">A method which sends the given message to the client.</param> + /// <param name="resume">Process the message using the game's default logic.</param> + public void OnServerProcessingMessage(IncomingMessage message, Action<OutgoingMessage> sendMessage, Action resume) + { + if (this.Monitor.IsVerbose) + this.Monitor.Log($"SERVER RECV {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace); + + switch (message.MessageType) + { + // sync mod context (step 2) + case (byte)MessageType.ModContext: + { + // parse message + RemoteContextModel model = this.ReadContext(message.Reader); + this.Monitor.Log($"Received context for farmhand {message.FarmerID} running {(model != null ? $"SMAPI {model.ApiVersion} with {model.Mods.Length} mods" : "vanilla")}.", LogLevel.Trace); + + // store peer + MultiplayerPeer newPeer = new MultiplayerPeer(message.FarmerID, model, sendMessage, isHost: false); + if (this.Peers.ContainsKey(message.FarmerID)) + { + this.Monitor.Log($"Rejected mod context from farmhand {message.FarmerID}: already received context for that player.", LogLevel.Error); + return; + } + this.AddPeer(newPeer, canBeHost: false, raiseEvent: false); + + // reply with own context + this.Monitor.VerboseLog(" Replying with host context..."); + newPeer.SendMessage(new OutgoingMessage((byte)MessageType.ModContext, Game1.player.UniqueMultiplayerID, this.GetContextSyncMessageFields())); + + // reply with other players' context + foreach (MultiplayerPeer otherPeer in this.Peers.Values.Where(p => p.PlayerID != newPeer.PlayerID)) + { + this.Monitor.VerboseLog($" Replying with context for player {otherPeer.PlayerID}..."); + newPeer.SendMessage(new OutgoingMessage((byte)MessageType.ModContext, otherPeer.PlayerID, this.GetContextSyncMessageFields(otherPeer))); + } + + // forward to other peers + if (this.Peers.Count > 1) + { + object[] fields = this.GetContextSyncMessageFields(newPeer); + foreach (MultiplayerPeer otherPeer in this.Peers.Values.Where(p => p.PlayerID != newPeer.PlayerID)) + { + this.Monitor.VerboseLog($" Forwarding context to player {otherPeer.PlayerID}..."); + otherPeer.SendMessage(new OutgoingMessage((byte)MessageType.ModContext, newPeer.PlayerID, fields)); + } + } + + // raise event + this.EventManager.PeerContextReceived.Raise(new PeerContextReceivedEventArgs(newPeer)); + } + break; + + // handle player intro + case (byte)MessageType.PlayerIntroduction: + // store peer if new + if (!this.Peers.ContainsKey(message.FarmerID)) + { + this.Monitor.Log($"Received connection for vanilla player {message.FarmerID}.", LogLevel.Trace); + MultiplayerPeer peer = new MultiplayerPeer(message.FarmerID, null, sendMessage, isHost: false); + this.AddPeer(peer, canBeHost: false); + } + + resume(); + break; + + // handle mod message + case (byte)MessageType.ModMessage: + this.ReceiveModMessage(message); + break; + + default: + resume(); + break; + } + } + + /// <summary>Process an incoming network message as a farmhand.</summary> + /// <param name="message">The message to process.</param> + /// <param name="sendMessage">Send an arbitrary message through the client.</param> + /// <param name="resume">Resume processing the message using the game's default logic.</param> + /// <returns>Returns whether the message was handled.</returns> + public void OnClientProcessingMessage(IncomingMessage message, Action<OutgoingMessage> sendMessage, Action resume) + { + if (this.Monitor.IsVerbose) + this.Monitor.Log($"CLIENT RECV {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace); + + switch (message.MessageType) + { + // mod context sync (step 4) + case (byte)MessageType.ModContext: + { + // parse message + RemoteContextModel model = this.ReadContext(message.Reader); + this.Monitor.Log($"Received context for {(model?.IsHost == true ? "host" : "farmhand")} {message.FarmerID} running {(model != null ? $"SMAPI {model.ApiVersion} with {model.Mods.Length} mods" : "vanilla")}.", LogLevel.Trace); + + // store peer + MultiplayerPeer peer = new MultiplayerPeer(message.FarmerID, model, 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); + return; + } + this.AddPeer(peer, canBeHost: true); + } + break; + + // handle server intro + case (byte)MessageType.ServerIntroduction: + { + // store peer + 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); + } + resume(); + break; + } + + // handle player intro + case (byte)MessageType.PlayerIntroduction: + { + // store peer + if (!this.Peers.TryGetValue(message.FarmerID, out MultiplayerPeer peer)) + { + peer = new MultiplayerPeer(message.FarmerID, null, 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); + } + + resume(); + break; + } + + // handle mod message + case (byte)MessageType.ModMessage: + this.ReceiveModMessage(message); + break; + + default: + resume(); + break; + } + } + + /// <summary>Remove players who are disconnecting.</summary> + protected override void removeDisconnectedFarmers() + { + foreach (long playerID in this.disconnectingFarmers) + { + if (this.Peers.TryGetValue(playerID, out MultiplayerPeer peer)) + { + this.Monitor.Log($"Player quit: {playerID}", LogLevel.Trace); + this.Peers.Remove(playerID); + this.EventManager.PeerDisconnected.Raise(new PeerDisconnectedEventArgs(peer)); + } + } + + base.removeDisconnectedFarmers(); + } + + /// <summary>Broadcast a mod message to matching players.</summary> + /// <param name="message">The data to send over the network.</param> + /// <param name="messageType">A message type which receiving mods can use to decide whether it's the one they want to handle, like <c>SetPlayerLocation</c>. This doesn't need to be globally unique, since mods should check the originating mod ID.</param> + /// <param name="fromModID">The unique ID of the mod sending the message.</param> + /// <param name="toModIDs">The mod IDs which should receive the message on the destination computers, or <c>null</c> for all mods. Specifying mod IDs is recommended to improve performance, unless it's a general-purpose broadcast.</param> + /// <param name="toPlayerIDs">The <see cref="Farmer.UniqueMultiplayerID" /> values for the players who should receive the message, or <c>null</c> for all players. If you don't need to broadcast to all players, specifying player IDs is recommended to reduce latency.</param> + public void BroadcastModMessage<TMessage>(TMessage message, string messageType, string fromModID, string[] toModIDs, long[] toPlayerIDs) + { + // validate + if (message == null) + throw new ArgumentNullException(nameof(message)); + if (string.IsNullOrWhiteSpace(messageType)) + throw new ArgumentNullException(nameof(messageType)); + if (string.IsNullOrWhiteSpace(fromModID)) + throw new ArgumentNullException(nameof(fromModID)); + if (!this.Peers.Any()) + { + this.Monitor.VerboseLog($"Ignored '{messageType}' broadcast from mod {fromModID}: not connected to any players."); + return; + } + + // filter player IDs + HashSet<long> playerIDs = null; + if (toPlayerIDs != null && toPlayerIDs.Any()) + { + playerIDs = new HashSet<long>(toPlayerIDs); + playerIDs.RemoveWhere(id => !this.Peers.ContainsKey(id)); + if (!playerIDs.Any()) + { + this.Monitor.VerboseLog($"Ignored '{messageType}' broadcast from mod {fromModID}: none of the specified player IDs are connected."); + return; + } + } + + // get data to send + ModMessageModel model = new ModMessageModel( + fromPlayerID: Game1.player.UniqueMultiplayerID, + fromModID: fromModID, + toModIDs: toModIDs, + toPlayerIDs: playerIDs?.ToArray(), + type: messageType, + data: JToken.FromObject(message) + ); + string data = JsonConvert.SerializeObject(model, Formatting.None); + + // log message + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Broadcasting '{messageType}' message: {data}.", LogLevel.Trace); + + // send message + if (Context.IsMainPlayer) + { + foreach (MultiplayerPeer peer in this.Peers.Values) + { + if (playerIDs == null || playerIDs.Contains(peer.PlayerID)) + { + model.ToPlayerIDs = new[] { peer.PlayerID }; + peer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, peer.PlayerID, data)); + } + } + } + else if (this.HostPeer != null && this.HostPeer.HasSmapi) + this.HostPeer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, this.HostPeer.PlayerID, data)); + else + this.Monitor.VerboseLog(" Can't send message because no valid connections were found."); + + } + + + /********* + ** Private methods + *********/ + /// <summary>Save a received peer.</summary> + /// <param name="peer">The peer to add.</param> + /// <param name="canBeHost">Whether to track the peer as the host if applicable.</param> + /// <param name="raiseEvent">Whether to raise the <see cref="Events.EventManager.PeerContextReceived"/> event.</param> + private void AddPeer(MultiplayerPeer peer, bool canBeHost, bool raiseEvent = true) + { + // store + this.Peers[peer.PlayerID] = peer; + if (canBeHost && peer.IsHost) + this.HostPeer = peer; + + // raise event + if (raiseEvent) + this.EventManager.PeerContextReceived.Raise(new PeerContextReceivedEventArgs(peer)); + } + + /// <summary>Read the metadata context for a player.</summary> + /// <param name="reader">The stream reader.</param> + private RemoteContextModel ReadContext(BinaryReader reader) + { + string data = reader.ReadString(); + RemoteContextModel model = this.JsonHelper.Deserialise<RemoteContextModel>(data); + return model.ApiVersion != null + ? model + : null; // no data available for unmodded players + } + + /// <summary>Receive a mod message sent from another player's mods.</summary> + /// <param name="message">The raw message to parse.</param> + private void ReceiveModMessage(IncomingMessage message) + { + // parse message + string json = message.Reader.ReadString(); + ModMessageModel model = this.JsonHelper.Deserialise<ModMessageModel>(json); + HashSet<long> playerIDs = new HashSet<long>(model.ToPlayerIDs ?? this.GetKnownPlayerIDs()); + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Received message: {json}."); + + // notify local mods + if (playerIDs.Contains(Game1.player.UniqueMultiplayerID)) + this.OnModMessageReceived(model); + + // forward to other players + if (Context.IsMainPlayer && playerIDs.Any(p => p != Game1.player.UniqueMultiplayerID)) + { + ModMessageModel newModel = new ModMessageModel(model); + foreach (long playerID in playerIDs) + { + if (playerID != Game1.player.UniqueMultiplayerID && playerID != model.FromPlayerID && this.Peers.TryGetValue(playerID, out MultiplayerPeer peer)) + { + newModel.ToPlayerIDs = new[] { peer.PlayerID }; + this.Monitor.VerboseLog($" Forwarding message to player {peer.PlayerID}."); + peer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, peer.PlayerID, this.JsonHelper.Serialise(newModel, Formatting.None))); + } + } + } + } + + /// <summary>Get all connected player IDs, including the current player.</summary> + private IEnumerable<long> GetKnownPlayerIDs() + { + yield return Game1.player.UniqueMultiplayerID; + foreach (long peerID in this.Peers.Keys) + yield return peerID; + } + + /// <summary>Get the fields to include in a context sync message sent to other players.</summary> + private object[] GetContextSyncMessageFields() + { + RemoteContextModel model = new RemoteContextModel + { + IsHost = Context.IsWorldReady && Context.IsMainPlayer, + Platform = Constants.TargetPlatform, + ApiVersion = Constants.ApiVersion, + GameVersion = Constants.GameVersion, + Mods = this.ModRegistry + .GetAll() + .Select(mod => new RemoteContextModModel + { + ID = mod.Manifest.UniqueID, + Name = mod.Manifest.Name, + Version = mod.Manifest.Version + }) + .ToArray() + }; + + return new object[] { this.JsonHelper.Serialise(model, Formatting.None) }; + } + + /// <summary>Get the fields to include in a context sync message sent to other players.</summary> + /// <param name="peer">The peer whose data to represent.</param> + private object[] GetContextSyncMessageFields(IMultiplayerPeer peer) + { + if (!peer.HasSmapi) + return new object[] { "{}" }; + + RemoteContextModel model = new RemoteContextModel + { + IsHost = peer.IsHost, + Platform = peer.Platform.Value, + ApiVersion = peer.ApiVersion, + GameVersion = peer.GameVersion, + Mods = peer.Mods + .Select(mod => new RemoteContextModModel + { + ID = mod.ID, + Name = mod.Name, + Version = mod.Version + }) + .ToArray() + }; + + return new object[] { this.JsonHelper.Serialise(model, Formatting.None) }; } } } diff --git a/src/SMAPI/Framework/Singleton.cs b/src/SMAPI/Framework/Singleton.cs new file mode 100644 index 00000000..399a8bf0 --- /dev/null +++ b/src/SMAPI/Framework/Singleton.cs @@ -0,0 +1,10 @@ +namespace StardewModdingAPI.Framework +{ + /// <summary>Provides singleton instances of a given type.</summary> + /// <typeparam name="T">The instance type.</typeparam> + internal static class Singleton<T> where T : new() + { + /// <summary>The singleton instance.</summary> + public static T Instance { get; } = new T(); + } +} diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/NetCollectionWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/NetCollectionWatcher.cs index f92edb90..8a841a79 100644 --- a/src/SMAPI/Framework/StateTracking/FieldWatchers/NetCollectionWatcher.cs +++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/NetCollectionWatcher.cs @@ -5,7 +5,7 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers { /// <summary>A watcher which detects changes to a Netcode collection.</summary> internal class NetCollectionWatcher<TValue> : BaseDisposableWatcher, ICollectionWatcher<TValue> - where TValue : INetObject<INetSerializable> + where TValue : class, INetObject<INetSerializable> { /********* ** Properties diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs index d7a02668..ab4ab0d5 100644 --- a/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs +++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs @@ -56,7 +56,7 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers /// <summary>Get a watcher for a net collection.</summary> /// <typeparam name="T">The value type.</typeparam> /// <param name="collection">The net collection.</param> - public static NetCollectionWatcher<T> ForNetCollection<T>(NetCollection<T> collection) where T : INetObject<INetSerializable> + public static NetCollectionWatcher<T> ForNetCollection<T>(NetCollection<T> collection) where T : class, INetObject<INetSerializable> { return new NetCollectionWatcher<T>(collection); } diff --git a/src/SMAPI/Framework/StateTracking/PlayerTracker.cs b/src/SMAPI/Framework/StateTracking/PlayerTracker.cs index 3814e534..6a705e50 100644 --- a/src/SMAPI/Framework/StateTracking/PlayerTracker.cs +++ b/src/SMAPI/Framework/StateTracking/PlayerTracker.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; using System.Linq; +using StardewModdingAPI.Enums; using StardewModdingAPI.Events; using StardewModdingAPI.Framework.StateTracking.FieldWatchers; using StardewValley; using StardewValley.Locations; +using ChangeType = StardewModdingAPI.Events.ChangeType; namespace StardewModdingAPI.Framework.StateTracking { @@ -40,7 +42,7 @@ namespace StardewModdingAPI.Framework.StateTracking public IValueWatcher<int> MineLevelWatcher { get; } /// <summary>Tracks changes to the player's skill levels.</summary> - public IDictionary<EventArgsLevelUp.LevelType, IValueWatcher<int>> SkillWatchers { get; } + public IDictionary<SkillType, IValueWatcher<int>> SkillWatchers { get; } /********* @@ -57,14 +59,14 @@ namespace StardewModdingAPI.Framework.StateTracking // init trackers this.LocationWatcher = WatcherFactory.ForReference(this.GetCurrentLocation); this.MineLevelWatcher = WatcherFactory.ForEquatable(() => this.LastValidLocation is MineShaft mine ? mine.mineLevel : 0); - this.SkillWatchers = new Dictionary<EventArgsLevelUp.LevelType, IValueWatcher<int>> + this.SkillWatchers = new Dictionary<SkillType, IValueWatcher<int>> { - [EventArgsLevelUp.LevelType.Combat] = WatcherFactory.ForNetValue(player.combatLevel), - [EventArgsLevelUp.LevelType.Farming] = WatcherFactory.ForNetValue(player.farmingLevel), - [EventArgsLevelUp.LevelType.Fishing] = WatcherFactory.ForNetValue(player.fishingLevel), - [EventArgsLevelUp.LevelType.Foraging] = WatcherFactory.ForNetValue(player.foragingLevel), - [EventArgsLevelUp.LevelType.Luck] = WatcherFactory.ForNetValue(player.luckLevel), - [EventArgsLevelUp.LevelType.Mining] = WatcherFactory.ForNetValue(player.miningLevel) + [SkillType.Combat] = WatcherFactory.ForNetValue(player.combatLevel), + [SkillType.Farming] = WatcherFactory.ForNetValue(player.farmingLevel), + [SkillType.Fishing] = WatcherFactory.ForNetValue(player.fishingLevel), + [SkillType.Foraging] = WatcherFactory.ForNetValue(player.foragingLevel), + [SkillType.Luck] = WatcherFactory.ForNetValue(player.luckLevel), + [SkillType.Mining] = WatcherFactory.ForNetValue(player.miningLevel) }; // track watchers for convenience @@ -123,7 +125,7 @@ namespace StardewModdingAPI.Framework.StateTracking } /// <summary>Get the player skill levels which changed.</summary> - public IEnumerable<KeyValuePair<EventArgsLevelUp.LevelType, IValueWatcher<int>>> GetChangedSkills() + public IEnumerable<KeyValuePair<SkillType, IValueWatcher<int>>> GetChangedSkills() { return this.SkillWatchers.Where(p => p.Value.IsChanged); } diff --git a/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs b/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs index d9090c08..5a259663 100644 --- a/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs +++ b/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs @@ -1,6 +1,8 @@ +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using StardewModdingAPI.Framework.StateTracking.Comparers; using StardewModdingAPI.Framework.StateTracking.FieldWatchers; using StardewValley; using StardewValley.Buildings; @@ -18,10 +20,10 @@ namespace StardewModdingAPI.Framework.StateTracking private readonly ICollectionWatcher<GameLocation> LocationListWatcher; /// <summary>A lookup of the tracked locations.</summary> - private IDictionary<GameLocation, LocationTracker> LocationDict { get; } = new Dictionary<GameLocation, LocationTracker>(); + private IDictionary<GameLocation, LocationTracker> LocationDict { get; } = new Dictionary<GameLocation, LocationTracker>(new ObjectReferenceComparer<GameLocation>()); /// <summary>A lookup of registered buildings and their indoor location.</summary> - private readonly IDictionary<Building, GameLocation> BuildingIndoors = new Dictionary<Building, GameLocation>(); + private readonly IDictionary<Building, GameLocation> BuildingIndoors = new Dictionary<Building, GameLocation>(new ObjectReferenceComparer<Building>()); /********* @@ -37,10 +39,10 @@ namespace StardewModdingAPI.Framework.StateTracking public IEnumerable<LocationTracker> Locations => this.LocationDict.Values; /// <summary>The locations removed since the last update.</summary> - public ICollection<GameLocation> Added { get; } = new HashSet<GameLocation>(); + public ICollection<GameLocation> Added { get; } = new HashSet<GameLocation>(new ObjectReferenceComparer<GameLocation>()); /// <summary>The locations added since the last update.</summary> - public ICollection<GameLocation> Removed { get; } = new HashSet<GameLocation>(); + public ICollection<GameLocation> Removed { get; } = new HashSet<GameLocation>(new ObjectReferenceComparer<GameLocation>()); /********* diff --git a/src/SMAPI/IContentPack.cs b/src/SMAPI/IContentPack.cs index 15a2b7dd..9ba32394 100644 --- a/src/SMAPI/IContentPack.cs +++ b/src/SMAPI/IContentPack.cs @@ -22,11 +22,19 @@ namespace StardewModdingAPI ** Public methods *********/ /// <summary>Read a JSON file from the content pack folder.</summary> - /// <typeparam name="TModel">The model type.</typeparam> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> /// <param name="path">The file path relative to the content pack directory.</param> /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> TModel ReadJsonFile<TModel>(string path) where TModel : class; + /// <summary>Save data to a JSON file in the content pack's folder.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="path">The file path relative to the mod folder.</param> + /// <param name="data">The arbitrary data to save.</param> + /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> + void WriteJsonFile<TModel>(string path, TModel data) where TModel : class; + /// <summary>Load content from the content pack folder (if not already cached), and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary> /// <typeparam name="T">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, and dictionaries; other types may be supported by the game's content pipeline.</typeparam> /// <param name="key">The local path to a content file relative to the content pack folder.</param> diff --git a/src/SMAPI/IDataHelper.cs b/src/SMAPI/IDataHelper.cs new file mode 100644 index 00000000..6afdc529 --- /dev/null +++ b/src/SMAPI/IDataHelper.cs @@ -0,0 +1,61 @@ +using System; + +namespace StardewModdingAPI +{ + /// <summary>Provides an API for reading and storing local mod data.</summary> + public interface IDataHelper + { + /********* + ** Public methods + *********/ + /**** + ** JSON file + ****/ + /// <summary>Read data from a JSON file in the mod's folder.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="path">The file path relative to the mod folder.</param> + /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> + TModel ReadJsonFile<TModel>(string path) where TModel : class; + + /// <summary>Save data to a JSON file in the mod's folder.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="path">The file path relative to the mod folder.</param> + /// <param name="data">The arbitrary data to save.</param> + /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> + void WriteJsonFile<TModel>(string path, TModel data) where TModel : class; + + /**** + ** Save file + ****/ + /// <summary>Read arbitrary data stored in the current save slot. This is only possible if a save has been loaded.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="key">The unique key identifying the data.</param> + /// <returns>Returns the parsed data, or <c>null</c> if the entry doesn't exist or is empty.</returns> + /// <exception cref="InvalidOperationException">The player hasn't loaded a save file yet or isn't the main player.</exception> + TModel ReadSaveData<TModel>(string key) where TModel : class; + + /// <summary>Save arbitrary data to the current save slot. This is only possible if a save has been loaded, and the data will be lost if the player exits without saving the current day.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="key">The unique key identifying the data.</param> + /// <param name="data">The arbitrary data to save.</param> + /// <exception cref="InvalidOperationException">The player hasn't loaded a save file yet or isn't the main player.</exception> + void WriteSaveData<TModel>(string key, TModel data) where TModel : class; + + + /**** + ** Global app data + ****/ + /// <summary>Read arbitrary data stored on the local computer, synchronised by GOG/Steam if applicable.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="key">The unique key identifying the data.</param> + /// <returns>Returns the parsed data, or <c>null</c> if the entry doesn't exist or is empty.</returns> + TModel ReadGlobalData<TModel>(string key) where TModel : class; + + /// <summary>Save arbitrary data to the local computer, synchronised by GOG/Steam if applicable.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="key">The unique key identifying the data.</param> + /// <param name="data">The arbitrary data to save.</param> + void WriteGlobalData<TModel>(string key, TModel data) where TModel : class; + } +} diff --git a/src/SMAPI/IModHelper.cs b/src/SMAPI/IModHelper.cs index d7b8c986..e4b5d390 100644 --- a/src/SMAPI/IModHelper.cs +++ b/src/SMAPI/IModHelper.cs @@ -17,9 +17,15 @@ namespace StardewModdingAPI [Obsolete("This is an experimental interface which may change at any time. Don't depend on this for released mods.")] IModEvents Events { get; } + /// <summary>An API for managing console commands.</summary> + ICommandHelper ConsoleCommands { get; } + /// <summary>An API for loading content assets.</summary> IContentHelper Content { get; } + /// <summary>An API for reading and writing persistent mod data.</summary> + IDataHelper Data { get; } + /// <summary>An API for checking and changing input state.</summary> IInputHelper Input { get; } @@ -32,9 +38,6 @@ namespace StardewModdingAPI /// <summary>Provides multiplayer utilities.</summary> IMultiplayerHelper Multiplayer { get; } - /// <summary>An API for managing console commands.</summary> - ICommandHelper ConsoleCommands { get; } - /// <summary>Provides translations stored in the mod's <c>i18n</c> folder, with one file per locale (like <c>en.json</c>) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like <c>pt-BR.json</c> < <c>pt.json</c> < <c>default.json</c>).</summary> ITranslationHelper Translation { get; } @@ -61,12 +64,14 @@ namespace StardewModdingAPI /// <typeparam name="TModel">The model type.</typeparam> /// <param name="path">The file path relative to the mod directory.</param> /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + [Obsolete("Use " + nameof(IModHelper.Data) + "." + nameof(IDataHelper.ReadJsonFile) + " instead")] TModel ReadJsonFile<TModel>(string path) where TModel : class; /// <summary>Save to a JSON file.</summary> /// <typeparam name="TModel">The model type.</typeparam> /// <param name="path">The file path relative to the mod directory.</param> /// <param name="model">The model to save.</param> + [Obsolete("Use " + nameof(IModHelper.Data) + "." + nameof(IDataHelper.WriteJsonFile) + " instead")] void WriteJsonFile<TModel>(string path, TModel model) where TModel : class; /**** diff --git a/src/SMAPI/IModInfo.cs b/src/SMAPI/IModInfo.cs new file mode 100644 index 00000000..3c85d454 --- /dev/null +++ b/src/SMAPI/IModInfo.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI +{ + /// <summary>Metadata for a loaded mod.</summary> + public interface IModInfo + { + /// <summary>The mod manifest.</summary> + IManifest Manifest { get; } + + /// <summary>Whether the mod is a content pack.</summary> + bool IsContentPack { get; } + } +} diff --git a/src/SMAPI/IModRegistry.cs b/src/SMAPI/IModRegistry.cs index a06e099e..10b3121e 100644 --- a/src/SMAPI/IModRegistry.cs +++ b/src/SMAPI/IModRegistry.cs @@ -6,12 +6,12 @@ namespace StardewModdingAPI public interface IModRegistry : IModLinked { /// <summary>Get metadata for all loaded mods.</summary> - IEnumerable<IManifest> GetAll(); + IEnumerable<IModInfo> GetAll(); /// <summary>Get metadata for a loaded mod.</summary> /// <param name="uniqueID">The mod's unique ID.</param> /// <returns>Returns the matching mod's metadata, or <c>null</c> if not found.</returns> - IManifest Get(string uniqueID); + IModInfo Get(string uniqueID); /// <summary>Get whether a mod has been loaded.</summary> /// <param name="uniqueID">The mod's unique ID.</param> diff --git a/src/SMAPI/IMonitor.cs b/src/SMAPI/IMonitor.cs index 62c479bc..0f153e10 100644 --- a/src/SMAPI/IMonitor.cs +++ b/src/SMAPI/IMonitor.cs @@ -1,4 +1,4 @@ -namespace StardewModdingAPI +namespace StardewModdingAPI { /// <summary>Encapsulates monitoring and logging for a given module.</summary> public interface IMonitor @@ -9,6 +9,9 @@ /// <summary>Whether SMAPI is aborting. Mods don't need to worry about this unless they have background tasks.</summary> bool IsExiting { get; } + /// <summary>Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed.</summary> + bool IsVerbose { get; } + /********* ** Methods @@ -18,6 +21,10 @@ /// <param name="level">The log severity level.</param> void Log(string message, LogLevel level = LogLevel.Debug); + /// <summary>Log a message that only appears when <see cref="IsVerbose"/> is enabled.</summary> + /// <param name="message">The message to log.</param> + void VerboseLog(string message); + /// <summary>Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</summary> /// <param name="reason">The reason for the shutdown.</param> void ExitGameImmediately(string reason); diff --git a/src/SMAPI/IMultiplayerHelper.cs b/src/SMAPI/IMultiplayerHelper.cs index 43a0ac95..4067a676 100644 --- a/src/SMAPI/IMultiplayerHelper.cs +++ b/src/SMAPI/IMultiplayerHelper.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using StardewValley; @@ -11,5 +12,22 @@ namespace StardewModdingAPI /// <summary>Get the locations which are being actively synced from the host.</summary> IEnumerable<GameLocation> GetActiveLocations(); + + /// <summary>Get a connected player.</summary> + /// <param name="id">The player's unique ID.</param> + /// <returns>Returns the connected player, or <c>null</c> if no such player is connected.</returns> + IMultiplayerPeer GetConnectedPlayer(long id); + + /// <summary>Get all connected players.</summary> + IEnumerable<IMultiplayerPeer> GetConnectedPlayers(); + + /// <summary>Send a message to mods installed by connected players.</summary> + /// <typeparam name="TMessage">The data type. This can be a class with a default constructor, or a value type.</typeparam> + /// <param name="message">The data to send over the network.</param> + /// <param name="messageType">A message type which receiving mods can use to decide whether it's the one they want to handle, like <c>SetPlayerLocation</c>. This doesn't need to be globally unique, since mods should check the originating mod ID.</param> + /// <param name="modIDs">The mod IDs which should receive the message on the destination computers, or <c>null</c> for all mods. Specifying mod IDs is recommended to improve performance, unless it's a general-purpose broadcast.</param> + /// <param name="playerIDs">The <see cref="Farmer.UniqueMultiplayerID" /> values for the players who should receive the message, or <c>null</c> for all players. If you don't need to broadcast to all players, specifying player IDs is recommended to reduce latency.</param> + /// <exception cref="ArgumentNullException">The <paramref name="message"/> or <paramref name="messageType" /> is null.</exception> + void SendMessage<TMessage>(TMessage message, string messageType, string[] modIDs = null, long[] playerIDs = null); } } diff --git a/src/SMAPI/IMultiplayerPeer.cs b/src/SMAPI/IMultiplayerPeer.cs new file mode 100644 index 00000000..0d4d3261 --- /dev/null +++ b/src/SMAPI/IMultiplayerPeer.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; + +namespace StardewModdingAPI +{ + /// <summary>Metadata about a connected player.</summary> + public interface IMultiplayerPeer + { + /********* + ** Accessors + *********/ + /// <summary>The player's unique ID.</summary> + long PlayerID { get; } + + /// <summary>Whether this is a connection to the host player.</summary> + bool IsHost { get; } + + /// <summary>Whether the player has SMAPI installed.</summary> + bool HasSmapi { get; } + + /// <summary>The player's OS platform, if <see cref="HasSmapi"/> is true.</summary> + GamePlatform? Platform { get; } + + /// <summary>The installed version of Stardew Valley, if <see cref="HasSmapi"/> is true.</summary> + ISemanticVersion GameVersion { get; } + + /// <summary>The installed version of SMAPI, if <see cref="HasSmapi"/> is true.</summary> + ISemanticVersion ApiVersion { get; } + + /// <summary>The installed mods, if <see cref="HasSmapi"/> is true.</summary> + IEnumerable<IMultiplayerPeerMod> Mods { get; } + + + /********* + ** Methods + *********/ + /// <summary>Get metadata for a mod installed by the player.</summary> + /// <param name="id">The unique mod ID.</param> + /// <returns>Returns the mod info, or <c>null</c> if the player doesn't have that mod.</returns> + IMultiplayerPeerMod GetMod(string id); + } +} diff --git a/src/SMAPI/IMultiplayerPeerMod.cs b/src/SMAPI/IMultiplayerPeerMod.cs new file mode 100644 index 00000000..005408b1 --- /dev/null +++ b/src/SMAPI/IMultiplayerPeerMod.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI +{ + /// <summary>Metadata about a mod installed by a connected player.</summary> + public interface IMultiplayerPeerMod + { + /// <summary>The mod's display name.</summary> + string Name { get; } + + /// <summary>The unique mod ID.</summary> + string ID { get; } + + /// <summary>The mod version.</summary> + ISemanticVersion Version { get; } + } +} diff --git a/src/SMAPI/Metadata/InstructionMetadata.cs b/src/SMAPI/Metadata/InstructionMetadata.cs index 2f0c1b15..ff8d54e3 100644 --- a/src/SMAPI/Metadata/InstructionMetadata.cs +++ b/src/SMAPI/Metadata/InstructionMetadata.cs @@ -34,20 +34,11 @@ namespace StardewModdingAPI.Metadata // rewrite for crossplatform compatibility new MethodParentRewriter(typeof(SpriteBatch), typeof(SpriteBatchMethods), onlyIfPlatformChanged: true), - // rewrite for SMAPI 2.0 - new VirtualEntryCallRemover(), - - // rewrite for SMAPI 2.6 (types moved into SMAPI.Toolkit.CoreInterfaces) - new TypeReferenceRewriter("StardewModdingAPI.IManifest", typeof(IManifest), shouldIgnore: type => type.Scope.Name != "StardewModdingAPI"), - new TypeReferenceRewriter("StardewModdingAPI.IManifestContentPackFor", typeof(IManifestContentPackFor), shouldIgnore: type => type.Scope.Name != "StardewModdingAPI"), - new TypeReferenceRewriter("StardewModdingAPI.IManifestDependency", typeof(IManifestDependency), shouldIgnore: type => type.Scope.Name != "StardewModdingAPI"), - new TypeReferenceRewriter("StardewModdingAPI.ISemanticVersion", typeof(ISemanticVersion), shouldIgnore: type => type.Scope.Name != "StardewModdingAPI"), - // rewrite for Stardew Valley 1.3 new StaticFieldToConstantRewriter<int>(typeof(Game1), "tileSize", Game1.tileSize), /**** - ** detect incompatible code + ** detect mod issues ****/ // detect broken code new ReferenceToMissingMemberFinder(this.ValidateReferencesToAssemblies), @@ -61,7 +52,22 @@ namespace StardewModdingAPI.Metadata new FieldFinder(typeof(SaveGame).FullName, nameof(SaveGame.serializer), InstructionHandleResult.DetectedSaveSerialiser), new FieldFinder(typeof(SaveGame).FullName, nameof(SaveGame.farmerSerializer), InstructionHandleResult.DetectedSaveSerialiser), new FieldFinder(typeof(SaveGame).FullName, nameof(SaveGame.locationSerializer), InstructionHandleResult.DetectedSaveSerialiser), - new EventFinder(typeof(SpecialisedEvents).FullName, nameof(SpecialisedEvents.UnvalidatedUpdateTick), InstructionHandleResult.DetectedUnvalidatedUpdateTick) + new EventFinder(typeof(SpecialisedEvents).FullName, nameof(SpecialisedEvents.UnvalidatedUpdateTick), InstructionHandleResult.DetectedUnvalidatedUpdateTick), + + /**** + ** detect paranoid issues + ****/ + // filesystem access + new TypeFinder(typeof(System.IO.File).FullName, InstructionHandleResult.DetectedFilesystemAccess), + new TypeFinder(typeof(System.IO.FileStream).FullName, InstructionHandleResult.DetectedFilesystemAccess), + new TypeFinder(typeof(System.IO.FileInfo).FullName, InstructionHandleResult.DetectedFilesystemAccess), + new TypeFinder(typeof(System.IO.Directory).FullName, InstructionHandleResult.DetectedFilesystemAccess), + new TypeFinder(typeof(System.IO.DirectoryInfo).FullName, InstructionHandleResult.DetectedFilesystemAccess), + new TypeFinder(typeof(System.IO.DriveInfo).FullName, InstructionHandleResult.DetectedFilesystemAccess), + new TypeFinder(typeof(System.IO.FileSystemWatcher).FullName, InstructionHandleResult.DetectedFilesystemAccess), + + // shell access + new TypeFinder(typeof(System.Diagnostics.Process).FullName, InstructionHandleResult.DetectedShellAccess) }; } } diff --git a/src/SMAPI/Patches/DialogueErrorPatch.cs b/src/SMAPI/Patches/DialogueErrorPatch.cs new file mode 100644 index 00000000..d8905fd1 --- /dev/null +++ b/src/SMAPI/Patches/DialogueErrorPatch.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Harmony; +using StardewModdingAPI.Framework.Patching; +using StardewModdingAPI.Framework.Reflection; +using StardewValley; + +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> + internal class DialogueErrorPatch : IHarmonyPatch + { + /********* + ** Private methods + *********/ + /// <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 + *********/ + /// <summary>A unique name for this patch.</summary> + 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; + } + + + /// <summary>Apply the Harmony patch.</summary> + /// <param name="harmony">The Harmony instance.</param> + public void Apply(HarmonyInstance harmony) + { + ConstructorInfo constructor = AccessTools.Constructor(typeof(Dialogue), new[] { typeof(string), typeof(NPC) }); + MethodInfo prefix = AccessTools.Method(this.GetType(), nameof(DialogueErrorPatch.Prefix)); + + harmony.Patch(constructor, new HarmonyMethod(prefix), null); + } + + + /********* + ** Private methods + *********/ + /// <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> + /// <remarks>This method must be static for Harmony to work correctly. See the Harmony documentation before renaming arguments.</remarks> + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony.")] + private static bool Prefix(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"); + IReflectedField<List<string>> dialogues = DialogueErrorPatch.Reflection.GetField<List<string>>(__instance, "dialogues"); + + // replicate base constructor + if (dialogues.GetValue() == null) + dialogues.SetValue(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; + } + } +} diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs index 634c5066..2efcfecb 100644 --- a/src/SMAPI/Program.cs +++ b/src/SMAPI/Program.cs @@ -1,107 +1,26 @@ using System; -using System.Collections.Generic; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; -using System.Net; using System.Reflection; -using System.Runtime.ExceptionServices; -using System.Security; -using System.Text; -using System.Text.RegularExpressions; using System.Threading; #if SMAPI_FOR_WINDOWS -using System.Windows.Forms; #endif -using Newtonsoft.Json; -using StardewModdingAPI.Events; using StardewModdingAPI.Framework; -using StardewModdingAPI.Framework.Events; -using StardewModdingAPI.Framework.Exceptions; -using StardewModdingAPI.Framework.Logging; -using StardewModdingAPI.Framework.Models; -using StardewModdingAPI.Framework.ModHelpers; -using StardewModdingAPI.Framework.ModLoading; -using StardewModdingAPI.Framework.Patching; -using StardewModdingAPI.Framework.Reflection; -using StardewModdingAPI.Framework.Serialisation; using StardewModdingAPI.Internal; -using StardewModdingAPI.Toolkit; -using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; -using StardewModdingAPI.Toolkit.Framework.ModData; -using StardewModdingAPI.Toolkit.Serialisation; -using StardewModdingAPI.Toolkit.Utilities; -using StardewValley; -using Monitor = StardewModdingAPI.Framework.Monitor; -using SObject = StardewValley.Object; -using ThreadState = System.Threading.ThreadState; namespace StardewModdingAPI { /// <summary>The main entry point for SMAPI, responsible for hooking into and launching the game.</summary> - internal class Program : IDisposable + internal class Program { /********* ** Properties *********/ - /// <summary>The log file to which to write messages.</summary> - private readonly LogFileManager LogFile; - - /// <summary>Manages console output interception.</summary> - private readonly ConsoleInterceptionManager ConsoleManager = new ConsoleInterceptionManager(); - - /// <summary>The core logger and monitor for SMAPI.</summary> - private readonly Monitor Monitor; - - /// <summary>Tracks whether the game should exit immediately and any pending initialisation should be cancelled.</summary> - private readonly CancellationTokenSource CancellationTokenSource = new CancellationTokenSource(); - - /// <summary>Simplifies access to private game code.</summary> - private readonly Reflector Reflection = new Reflector(); - - /// <summary>The SMAPI configuration settings.</summary> - private readonly SConfig Settings; - - /// <summary>The underlying game instance.</summary> - private SGame GameInstance; - - /// <summary>The underlying content manager.</summary> - private ContentCoordinator ContentCore => this.GameInstance.ContentCore; - - /// <summary>Tracks the installed mods.</summary> - /// <remarks>This is initialised after the game starts.</remarks> - private readonly ModRegistry ModRegistry = new ModRegistry(); - - /// <summary>Manages deprecation warnings.</summary> - /// <remarks>This is initialised after the game starts.</remarks> - private DeprecationManager DeprecationManager; - - /// <summary>Manages SMAPI events for mods.</summary> - private readonly EventManager EventManager; - - /// <summary>Whether the game is currently running.</summary> - private bool IsGameRunning; - - /// <summary>Whether the program has been disposed.</summary> - private bool IsDisposed; - - /// <summary>Regex patterns which match console messages to suppress from the console and log.</summary> - private readonly Regex[] SuppressConsolePatterns = - { - new Regex(@"^TextBox\.Selected is now '(?:True|False)'\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^(?:FRUIT )?TREE: IsClient:(?:True|False) randomOutput: \d+$", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^loadPreferences\(\); begin", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^savePreferences\(\); async=", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^Multiplayer auth success$", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^DebugOutput: (?:added CLOUD|dismount tile|Ping|playerPos)", RegexOptions.Compiled | RegexOptions.CultureInvariant) - }; - - /// <summary>The mod toolkit used for generic mod interactions.</summary> - private readonly ModToolkit Toolkit = new ModToolkit(); - - /// <summary>The path to search for mods.</summary> - private readonly string ModsPath; + /// <summary>The absolute path to search for SMAPI's internal DLLs.</summary> + /// <remarks>We can't use <see cref="Constants.ExecutionPath"/> directly, since <see cref="Constants"/> depends on DLLs loaded from this folder.</remarks> + [SuppressMessage("ReSharper", "AssignNullToNotNullAttribute", Justification = "The assembly location is never null in this context.")] + internal static readonly string DllSearchPath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "smapi-internal"); /********* @@ -111,269 +30,47 @@ namespace StardewModdingAPI /// <param name="args">The command-line arguments.</param> public static void Main(string[] args) { - Program.AssertMinimumCompatibility(); - - // get flags from arguments - bool writeToConsole = !args.Contains("--no-terminal"); - - // get mods path from arguments - string modsPath = null; - { - int pathIndex = Array.LastIndexOf(args, "--mods-path") + 1; - if (pathIndex >= 1 && args.Length >= pathIndex) - { - modsPath = args[pathIndex]; - if (!string.IsNullOrWhiteSpace(modsPath) && !Path.IsPathRooted(modsPath)) - modsPath = Path.Combine(Constants.ExecutionPath, modsPath); - } - if (string.IsNullOrWhiteSpace(modsPath)) - modsPath = Constants.DefaultModsPath; - } - - // load SMAPI - using (Program program = new Program(modsPath, writeToConsole)) - program.RunInteractively(); + AppDomain.CurrentDomain.AssemblyResolve += Program.CurrentDomain_AssemblyResolve; + Program.AssertGamePresent(); + Program.AssertGameVersion(); + Program.Start(args); } - /// <summary>Construct an instance.</summary> - /// <param name="modsPath">The path to search for mods.</param> - /// <param name="writeToConsole">Whether to output log messages to the console.</param> - public Program(string modsPath, bool writeToConsole) - { - // init paths - this.VerifyPath(modsPath); - this.VerifyPath(Constants.LogDir); - this.ModsPath = modsPath; - - // init log file - this.PurgeLogFiles(); - string logPath = this.GetLogPath(); - - // init basics - this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath)); - this.LogFile = new LogFileManager(logPath); - this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.CancellationTokenSource, this.Settings.ColorScheme) - { - WriteToConsole = writeToConsole, - ShowTraceInConsole = this.Settings.DeveloperMode, - ShowFullStampInConsole = this.Settings.DeveloperMode - }; - this.EventManager = new EventManager(this.Monitor, this.ModRegistry); - - // init logging - this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} on {EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform)}", LogLevel.Info); - this.Monitor.Log($"Mods go here: {modsPath}"); - if (modsPath != Constants.DefaultModsPath) - this.Monitor.Log("(Using custom --mods-path argument.)", LogLevel.Trace); - this.Monitor.Log($"Log started at {DateTime.UtcNow:s} UTC", LogLevel.Trace); - - // validate game version - if (Constants.GameVersion.IsOlderThan(Constants.MinimumGameVersion)) - { - this.Monitor.Log($"Oops! You're running Stardew Valley {Constants.GameVersion}, but the oldest supported version is {Constants.MinimumGameVersion}. Please update your game before using SMAPI.", LogLevel.Error); - this.PressAnyKeyToExit(); - return; - } - if (Constants.MaximumGameVersion != null && Constants.GameVersion.IsNewerThan(Constants.MaximumGameVersion)) - { - this.Monitor.Log($"Oops! You're running Stardew Valley {Constants.GameVersion}, but this version of SMAPI is only compatible up to Stardew Valley {Constants.MaximumGameVersion}. Please check for a newer version of SMAPI: https://smapi.io.", LogLevel.Error); - this.PressAnyKeyToExit(); - return; - } - - // apply game patches - new GamePatcher(this.Monitor).Apply( - // new GameLocationPatch() - ); - } - /// <summary>Launch SMAPI.</summary> - [HandleProcessCorruptedStateExceptions, SecurityCritical] // let try..catch handle corrupted state exceptions - public void RunInteractively() + /********* + ** Private methods + *********/ + /// <summary>Method called when assembly resolution fails, which may return a manually resolved assembly.</summary> + /// <param name="sender">The event sender.</param> + /// <param name="e">The event arguments.</param> + private static Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs e) { - // initialise SMAPI try { - // hook up events - ContentEvents.Init(this.EventManager); - ControlEvents.Init(this.EventManager); - GameEvents.Init(this.EventManager); - GraphicsEvents.Init(this.EventManager); - InputEvents.Init(this.EventManager); - LocationEvents.Init(this.EventManager); - MenuEvents.Init(this.EventManager); - MineEvents.Init(this.EventManager); - MultiplayerEvents.Init(this.EventManager); - PlayerEvents.Init(this.EventManager); - SaveEvents.Init(this.EventManager); - SpecialisedEvents.Init(this.EventManager); - TimeEvents.Init(this.EventManager); - - // init JSON parser - JsonConverter[] converters = { - new ColorConverter(), - new PointConverter(), - new RectangleConverter() - }; - foreach (JsonConverter converter in converters) - this.Toolkit.JsonHelper.JsonSettings.Converters.Add(converter); - - // add error handlers -#if SMAPI_FOR_WINDOWS - Application.ThreadException += (sender, e) => this.Monitor.Log($"Critical thread exception: {e.Exception.GetLogSummary()}", LogLevel.Error); - Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); -#endif - AppDomain.CurrentDomain.UnhandledException += (sender, e) => this.Monitor.Log($"Critical app domain exception: {e.ExceptionObject}", LogLevel.Error); - - // add more leniant assembly resolvers - AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => AssemblyLoader.ResolveAssembly(e.Name); - - // override game - SGame.ConstructorHack = new SGameConstructorHack(this.Monitor, this.Reflection, this.Toolkit.JsonHelper); - this.GameInstance = new SGame(this.Monitor, this.Reflection, this.EventManager, this.Toolkit.JsonHelper, this.InitialiseAfterGameStart, this.Dispose); - StardewValley.Program.gamePtr = this.GameInstance; - - // add exit handler - new Thread(() => + AssemblyName name = new AssemblyName(e.Name); + foreach (FileInfo dll in new DirectoryInfo(Program.DllSearchPath).EnumerateFiles("*.dll")) { - this.CancellationTokenSource.Token.WaitHandle.WaitOne(); - if (this.IsGameRunning) - { - try - { - File.WriteAllText(Constants.FatalCrashMarker, string.Empty); - File.Copy(this.LogFile.Path, Constants.FatalCrashLog, overwrite: true); - } - catch (Exception ex) - { - this.Monitor.Log($"SMAPI failed trying to track the crash details: {ex.GetLogSummary()}"); - } - - this.GameInstance.Exit(); - } - }).Start(); - - // hook into game events - ContentEvents.AfterLocaleChanged += (sender, e) => this.OnLocaleChanged(); - - // set window titles - this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion}"; - Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion}"; - } - catch (Exception ex) - { - this.Monitor.Log($"SMAPI failed to initialise: {ex.GetLogSummary()}", LogLevel.Error); - this.PressAnyKeyToExit(); - return; - } - - // check update marker - if (File.Exists(Constants.UpdateMarker)) - { - string rawUpdateFound = File.ReadAllText(Constants.UpdateMarker); - if (SemanticVersion.TryParse(rawUpdateFound, out ISemanticVersion updateFound)) - { - if (Constants.ApiVersion.IsPrerelease() && updateFound.IsNewerThan(Constants.ApiVersion)) - { - this.Monitor.Log("A new version of SMAPI was detected last time you played.", LogLevel.Error); - this.Monitor.Log($"You can update to {updateFound}: https://smapi.io.", LogLevel.Error); - this.Monitor.Log("Press any key to continue playing anyway. (This only appears when using a SMAPI beta.)", LogLevel.Info); - Console.ReadKey(); - } + if (name.Name.Equals(AssemblyName.GetAssemblyName(dll.FullName).Name, StringComparison.InvariantCultureIgnoreCase)) + return Assembly.LoadFrom(dll.FullName); } - File.Delete(Constants.UpdateMarker); - } - - // show details if game crashed during last session - if (File.Exists(Constants.FatalCrashMarker)) - { - this.Monitor.Log("The game crashed last time you played. That can be due to bugs in the game, but if it happens repeatedly you can ask for help here: http://community.playstarbound.com/threads/108375/.", LogLevel.Error); - this.Monitor.Log("If you ask for help, make sure to share your SMAPI log: https://log.smapi.io.", LogLevel.Error); - this.Monitor.Log("Press any key to delete the crash data and continue playing.", LogLevel.Info); - Console.ReadKey(); - File.Delete(Constants.FatalCrashLog); - File.Delete(Constants.FatalCrashMarker); - } - - // start game - this.Monitor.Log("Starting game...", LogLevel.Debug); - try - { - this.IsGameRunning = true; - StardewValley.Program.releaseBuild = true; // game's debug logic interferes with SMAPI opening the game window - this.GameInstance.Run(); - } - catch (InvalidOperationException ex) when (ex.Source == "Microsoft.Xna.Framework.Xact" && ex.StackTrace.Contains("Microsoft.Xna.Framework.Audio.AudioEngine..ctor")) - { - this.Monitor.Log("The game couldn't load audio. Do you have speakers or headphones plugged in?", LogLevel.Error); - this.Monitor.Log($"Technical details: {ex.GetLogSummary()}", LogLevel.Trace); - this.PressAnyKeyToExit(); + return null; } catch (Exception ex) { - this.Monitor.Log($"The game failed unexpectedly: {ex.GetLogSummary()}", LogLevel.Error); - this.PressAnyKeyToExit(); - } - finally - { - this.Dispose(); - } - } - - /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> - public void Dispose() - { - // skip if already disposed - if (this.IsDisposed) - return; - this.IsDisposed = true; - this.Monitor.Log("Disposing...", LogLevel.Trace); - - // dispose mod data - foreach (IModMetadata mod in this.ModRegistry.GetAll()) - { - try - { - (mod.Mod as IDisposable)?.Dispose(); - } - catch (Exception ex) - { - mod.LogAsMod($"Mod failed during disposal: {ex.GetLogSummary()}.", LogLevel.Warn); - } + Console.WriteLine($"Error resolving assembly: {ex}"); + return null; } - - // dispose core components - this.IsGameRunning = false; - this.ConsoleManager?.Dispose(); - this.ContentCore?.Dispose(); - this.CancellationTokenSource?.Dispose(); - this.GameInstance?.Dispose(); - this.LogFile?.Dispose(); - - // end game (moved from Game1.OnExiting to let us clean up first) - Process.GetCurrentProcess().Kill(); } - - /********* - ** Private methods - *********/ - /// <summary>Assert that the minimum conditions are present to initialise SMAPI without type load exceptions.</summary> - private static void AssertMinimumCompatibility() + /// <summary>Assert that the game is available.</summary> + /// <remarks>This must be checked *before* any references to <see cref="Constants"/>, and this method should not reference <see cref="Constants"/> itself to avoid errors in Mono.</remarks> + private static void AssertGamePresent() { - void PrintErrorAndExit(string message) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine(message); - Console.ResetColor(); - Program.PressAnyKeyToExit(showMessage: true); - } - string gameAssemblyName = Constants.GameAssemblyName; - - // game not present + Platform platform = EnvironmentUtility.DetectPlatform(); + string gameAssemblyName = platform == Platform.Windows ? "Stardew Valley" : "StardewValley"; if (Type.GetType($"StardewValley.Game1, {gameAssemblyName}", throwOnError: false) == null) { - PrintErrorAndExit( + Program.PrintErrorAndExit( "Oops! SMAPI can't find the game. " + (Assembly.GetCallingAssembly().Location.Contains(Path.Combine("internal", "Windows")) || Assembly.GetCallingAssembly().Location.Contains(Path.Combine("internal", "Mono")) ? "It looks like you're running SMAPI from the download package, but you need to run the installed version instead. " @@ -382,890 +79,62 @@ namespace StardewModdingAPI + "See the readme.txt file for details." ); } - - // Stardew Valley 1.2 types not present - if (Type.GetType($"StardewValley.LocalizedContentManager+LanguageCode, {gameAssemblyName}", throwOnError: false) == null) - { - PrintErrorAndExit(Constants.GameVersion.IsOlderThan(Constants.MinimumGameVersion) - ? $"Oops! You're running Stardew Valley {Constants.GameVersion}, but the oldest supported version is {Constants.MinimumGameVersion}. Please update your game before using SMAPI." - : "Oops! SMAPI doesn't seem to be compatible with your game. Make sure you're running the latest version of Stardew Valley and SMAPI." - ); - } - } - - /// <summary>Initialise SMAPI and mods after the game starts.</summary> - private void InitialiseAfterGameStart() - { - // load settings - this.GameInstance.VerboseLogging = this.Settings.VerboseLogging; - - // load core components - this.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry); - - // redirect direct console output - { - Monitor monitor = this.GetSecondaryMonitor("game"); - if (monitor.WriteToConsole) - this.ConsoleManager.OnMessageIntercepted += message => this.HandleConsoleMessage(monitor, message); - } - - // add headers - if (this.Settings.DeveloperMode) - this.Monitor.Log($"You configured SMAPI to run in developer mode. The console may be much more verbose. You can disable developer mode by installing the non-developer version of SMAPI, or by editing {Constants.ApiConfigPath}.", LogLevel.Info); - if (!this.Settings.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); - 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.VerboseLog("Verbose logging enabled."); - - // validate XNB integrity - if (!this.ValidateContentIntegrity()) - this.Monitor.Log("SMAPI found problems in your game's content files which are likely to cause errors or crashes. Consider uninstalling XNB mods or reinstalling the game.", LogLevel.Error); - - // load mod data - ModToolkit toolkit = new ModToolkit(); - ModDatabase modDatabase = toolkit.GetModDatabase(Constants.ApiMetadataPath); - - // load mods - { - this.Monitor.Log("Loading mod metadata...", LogLevel.Trace); - ModResolver resolver = new ModResolver(); - - // load manifests - IModMetadata[] mods = resolver.ReadManifests(toolkit, this.ModsPath, modDatabase).ToArray(); - resolver.ValidateManifests(mods, Constants.ApiVersion, toolkit.GetUpdateUrl); - - // process dependencies - mods = resolver.ProcessDependencies(mods, modDatabase).ToArray(); - - // load mods - this.LoadMods(mods, this.Toolkit.JsonHelper, this.ContentCore, modDatabase); - - // write metadata file - if (this.Settings.DumpMetadata) - { - ModFolderExport export = new ModFolderExport - { - Exported = DateTime.UtcNow.ToString("O"), - ApiVersion = Constants.ApiVersion.ToString(), - GameVersion = Constants.GameVersion.ToString(), - ModFolderPath = this.ModsPath, - Mods = mods - }; - this.Toolkit.JsonHelper.WriteJsonFile(Path.Combine(Constants.LogDir, $"{Constants.LogNamePrefix}metadata-dump.json"), export); - } - - // check for updates - this.CheckForUpdatesAsync(mods); - } - if (this.Monitor.IsExiting) - { - this.Monitor.Log("SMAPI shutting down: aborting initialisation.", LogLevel.Warn); - return; - } - - // update window titles - int modsLoaded = this.ModRegistry.GetAll().Count(); - this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion} with {modsLoaded} mods"; - Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion} with {modsLoaded} mods"; - - // start SMAPI console - new Thread(this.RunConsoleLoop).Start(); - } - - /// <summary>Handle the game changing locale.</summary> - private void OnLocaleChanged() - { - // get locale - string locale = this.ContentCore.GetLocale(); - LocalizedContentManager.LanguageCode languageCode = this.ContentCore.Language; - - // update mod translation helpers - foreach (IModMetadata mod in this.ModRegistry.GetAll(contentPacks: false)) - (mod.Mod.Helper.Translation as TranslationHelper)?.SetLocale(locale, languageCode); - } - - /// <summary>Run a loop handling console input.</summary> - [SuppressMessage("ReSharper", "FunctionNeverReturns", Justification = "The thread is aborted when the game exits.")] - private void RunConsoleLoop() - { - // prepare console - this.Monitor.Log("Type 'help' for help, or 'help <cmd>' for a command's usage", LogLevel.Info); - this.GameInstance.CommandManager.Add("SMAPI", "help", "Lists command documentation.\n\nUsage: help\nLists all available commands.\n\nUsage: help <cmd>\n- cmd: The name of a command whose documentation to display.", this.HandleCommand); - this.GameInstance.CommandManager.Add("SMAPI", "reload_i18n", "Reloads translation files for all mods.\n\nUsage: reload_i18n", this.HandleCommand); - - // start handling command line input - Thread inputThread = new Thread(() => - { - while (true) - { - // get input - string input = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(input)) - continue; - - // handle command - this.Monitor.LogUserInput(input); - this.GameInstance.CommandQueue.Enqueue(input); - } - }); - inputThread.Start(); - - // keep console thread alive while the game is running - while (this.IsGameRunning && !this.Monitor.IsExiting) - Thread.Sleep(1000 / 10); - if (inputThread.ThreadState == ThreadState.Running) - inputThread.Abort(); - } - - /// <summary>Look for common issues with the game's XNB content, and log warnings if anything looks broken or outdated.</summary> - /// <returns>Returns whether all integrity checks passed.</returns> - private bool ValidateContentIntegrity() - { - this.Monitor.Log("Detecting common issues...", LogLevel.Trace); - bool issuesFound = false; - - // object format (commonly broken by outdated files) - { - // detect issues - bool hasObjectIssues = false; - void LogIssue(int id, string issue) => this.Monitor.Log($@"Detected issue: item #{id} in Content\Data\ObjectInformation.xnb is invalid ({issue}).", LogLevel.Trace); - foreach (KeyValuePair<int, string> entry in Game1.objectInformation) - { - // must not be empty - if (string.IsNullOrWhiteSpace(entry.Value)) - { - LogIssue(entry.Key, "entry is empty"); - hasObjectIssues = true; - continue; - } - - // require core fields - string[] fields = entry.Value.Split('/'); - if (fields.Length < SObject.objectInfoDescriptionIndex + 1) - { - LogIssue(entry.Key, "too few fields for an object"); - hasObjectIssues = true; - continue; - } - - // check min length for specific types - switch (fields[SObject.objectInfoTypeIndex].Split(new[] { ' ' }, 2)[0]) - { - case "Cooking": - if (fields.Length < SObject.objectInfoBuffDurationIndex + 1) - { - LogIssue(entry.Key, "too few fields for a cooking item"); - hasObjectIssues = true; - } - break; - } - } - - // log error - if (hasObjectIssues) - { - issuesFound = true; - this.Monitor.Log(@"Your Content\Data\ObjectInformation.xnb file seems to be broken or outdated.", LogLevel.Warn); - } - } - - return !issuesFound; } - /// <summary>Asynchronously check for a new version of SMAPI and any installed mods, and print alerts to the console if an update is available.</summary> - /// <param name="mods">The mods to include in the update check (if eligible).</param> - private void CheckForUpdatesAsync(IModMetadata[] mods) + /// <summary>Assert that the game version is within <see cref="Constants.MinimumGameVersion"/> and <see cref="Constants.MaximumGameVersion"/>.</summary> + private static void AssertGameVersion() { - if (!this.Settings.CheckForUpdates) - return; - - new Thread(() => - { - // create client - string url = this.Settings.WebApiBaseUrl; -#if !SMAPI_FOR_WINDOWS - url = url.Replace("https://", "http://"); // workaround for OpenSSL issues with the game's bundled Mono on Linux/Mac -#endif - WebApiClient client = new WebApiClient(url, Constants.ApiVersion); - this.Monitor.Log("Checking for updates...", LogLevel.Trace); - - // check SMAPI version - ISemanticVersion updateFound = null; - try - { - ModEntryModel response = client.GetModInfo(new[] { new ModSearchEntryModel("Pathoschild.SMAPI", new[] { $"GitHub:{this.Settings.GitHubProjectName}" }) }).Single().Value; - ISemanticVersion latestStable = response.Main?.Version; - ISemanticVersion latestBeta = response.Optional?.Version; - - if (latestStable == null && response.Errors.Any()) - { - this.Monitor.Log("Couldn't check for a new version of SMAPI. This won't affect your game, but you may not be notified of new versions if this keeps happening.", LogLevel.Warn); - this.Monitor.Log($"Error: {string.Join("\n", response.Errors)}"); - } - else if (this.IsValidUpdate(Constants.ApiVersion, latestBeta, this.Settings.UseBetaChannel)) - { - updateFound = latestBeta; - this.Monitor.Log($"You can update SMAPI to {latestBeta}: {Constants.HomePageUrl}", LogLevel.Alert); - } - else if (this.IsValidUpdate(Constants.ApiVersion, latestStable, this.Settings.UseBetaChannel)) - { - updateFound = latestStable; - this.Monitor.Log($"You can update SMAPI to {latestStable}: {Constants.HomePageUrl}", LogLevel.Alert); - } - else - this.Monitor.Log(" SMAPI okay.", LogLevel.Trace); - } - catch (Exception ex) - { - this.Monitor.Log("Couldn't check for a new version of SMAPI. This won't affect your game, but you won't be notified of new versions if this keeps happening.", LogLevel.Warn); - this.Monitor.Log(ex is WebException && ex.InnerException == null - ? $"Error: {ex.Message}" - : $"Error: {ex.GetLogSummary()}" - ); - } - - // show update message on next launch - if (updateFound != null) - File.WriteAllText(Constants.UpdateMarker, updateFound.ToString()); - - // check mod versions - if (mods.Any()) - { - try - { - HashSet<string> suppressUpdateChecks = new HashSet<string>(this.Settings.SuppressUpdateChecks, StringComparer.InvariantCultureIgnoreCase); - - // prepare search model - List<ModSearchEntryModel> searchMods = new List<ModSearchEntryModel>(); - foreach (IModMetadata mod in mods) - { - if (!mod.HasID()) - continue; - - string[] updateKeys = mod.Manifest.UpdateKeys ?? new string[0]; - searchMods.Add(new ModSearchEntryModel(mod.Manifest.UniqueID, updateKeys.Except(suppressUpdateChecks).ToArray())); - } - - // fetch results - this.Monitor.Log($" Checking for updates to {searchMods.Count} mods...", LogLevel.Trace); - IDictionary<string, ModEntryModel> results = client.GetModInfo(searchMods.ToArray()); - - // extract update alerts & errors - var updates = new List<Tuple<IModMetadata, ISemanticVersion, string>>(); - var errors = new StringBuilder(); - foreach (IModMetadata mod in mods.OrderBy(p => p.DisplayName)) - { - // link to update-check data - if (!mod.HasID() || !results.TryGetValue(mod.Manifest.UniqueID, out ModEntryModel result)) - continue; - mod.SetUpdateData(result); - - // handle errors - if (result.Errors != null && result.Errors.Any()) - { - errors.AppendLine(result.Errors.Length == 1 - ? $" {mod.DisplayName}: {result.Errors[0]}" - : $" {mod.DisplayName}:\n - {string.Join("\n - ", result.Errors)}" - ); - } - - // parse versions - ISemanticVersion localVersion = mod.DataRecord?.GetLocalVersionForUpdateChecks(mod.Manifest.Version) ?? mod.Manifest.Version; - ISemanticVersion latestVersion = mod.DataRecord?.GetRemoteVersionForUpdateChecks(result.Main?.Version) ?? result.Main?.Version; - ISemanticVersion optionalVersion = mod.DataRecord?.GetRemoteVersionForUpdateChecks(result.Optional?.Version) ?? result.Optional?.Version; - ISemanticVersion unofficialVersion = result.Unofficial?.Version; - - // show update alerts - if (this.IsValidUpdate(localVersion, latestVersion, useBetaChannel: true)) - updates.Add(Tuple.Create(mod, latestVersion, result.Main?.Url)); - else if (this.IsValidUpdate(localVersion, optionalVersion, useBetaChannel: localVersion.IsPrerelease())) - updates.Add(Tuple.Create(mod, optionalVersion, result.Optional?.Url)); - else if (this.IsValidUpdate(localVersion, unofficialVersion, useBetaChannel: mod.Status == ModMetadataStatus.Failed)) - updates.Add(Tuple.Create(mod, unofficialVersion, result.Unofficial?.Url)); - } - - // show update errors - if (errors.Length != 0) - this.Monitor.Log("Got update-check errors for some mods:\n" + errors.ToString().TrimEnd(), LogLevel.Trace); - - // show update alerts - if (updates.Any()) - { - this.Monitor.Newline(); - this.Monitor.Log($"You can update {updates.Count} mod{(updates.Count != 1 ? "s" : "")}:", LogLevel.Alert); - foreach (var entry in updates) - { - IModMetadata mod = entry.Item1; - ISemanticVersion newVersion = entry.Item2; - string newUrl = entry.Item3; - this.Monitor.Log($" {mod.DisplayName} {newVersion}: {newUrl}", LogLevel.Alert); - } - } - else - this.Monitor.Log(" All mods up to date.", LogLevel.Trace); - } - catch (Exception ex) - { - this.Monitor.Log("Couldn't check for new mod versions. This won't affect your game, but you won't be notified of mod updates if this keeps happening.", LogLevel.Warn); - this.Monitor.Log(ex is WebException && ex.InnerException == null - ? ex.Message - : ex.ToString() - ); - } - } - }).Start(); - } - - /// <summary>Get whether a given version should be offered to the user as an update.</summary> - /// <param name="currentVersion">The current semantic version.</param> - /// <param name="newVersion">The target semantic version.</param> - /// <param name="useBetaChannel">Whether the user enabled the beta channel and should be offered pre-release updates.</param> - private bool IsValidUpdate(ISemanticVersion currentVersion, ISemanticVersion newVersion, bool useBetaChannel) - { - return - newVersion != null - && newVersion.IsNewerThan(currentVersion) - && (useBetaChannel || !newVersion.IsPrerelease()); - } - - /// <summary>Create a directory path if it doesn't exist.</summary> - /// <param name="path">The directory path.</param> - private void VerifyPath(string path) - { - try - { - if (!Directory.Exists(path)) - Directory.CreateDirectory(path); - } - catch (Exception ex) - { - this.Monitor.Log($"Couldn't create a path: {path}\n\n{ex.GetLogSummary()}", LogLevel.Error); - } - } - - /// <summary>Load and hook up the given mods.</summary> - /// <param name="mods">The mods to load.</param> - /// <param name="jsonHelper">The JSON helper with which to read mods' JSON files.</param> - /// <param name="contentCore">The content manager to use for mod content.</param> - /// <param name="modDatabase">Handles access to SMAPI's internal mod metadata list.</param> - private void LoadMods(IModMetadata[] mods, JsonHelper jsonHelper, ContentCoordinator contentCore, ModDatabase modDatabase) - { - this.Monitor.Log("Loading mods...", LogLevel.Trace); - - HashSet<string> suppressUpdateChecks = new HashSet<string>(this.Settings.SuppressUpdateChecks, StringComparer.InvariantCultureIgnoreCase); - IDictionary<IModMetadata, string[]> skippedMods = new Dictionary<IModMetadata, string[]>(); - void TrackSkip(IModMetadata mod, string userReasonPhrase, string devReasonPhrase = null) => skippedMods[mod] = new[] { userReasonPhrase, devReasonPhrase }; - - // load content packs - foreach (IModMetadata metadata in mods.Where(p => p.IsContentPack)) - { - this.Monitor.Log($" {metadata.DisplayName} (content pack, {PathUtilities.GetRelativePath(this.ModsPath, metadata.DirectoryPath)})...", LogLevel.Trace); - - // show warning for missing update key - if (metadata.HasManifest() && !metadata.HasUpdateKeys()) - metadata.SetWarning(ModWarning.NoUpdateKeys); - - // validate status - if (metadata.Status == ModMetadataStatus.Failed) - { - this.Monitor.Log($" Failed: {metadata.Error}", LogLevel.Trace); - TrackSkip(metadata, metadata.Error); - continue; - } - - // load mod as content pack - IManifest manifest = metadata.Manifest; - IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName); - IContentHelper contentHelper = new ContentHelper(this.ContentCore, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor); - IContentPack contentPack = new ContentPack(metadata.DirectoryPath, manifest, contentHelper, jsonHelper); - metadata.SetMod(contentPack, monitor); - this.ModRegistry.Add(metadata); - } - IModMetadata[] loadedContentPacks = this.ModRegistry.GetAll(assemblyMods: false).ToArray(); - - // load mods - { - // get content packs by mod ID - IDictionary<string, IContentPack[]> contentPacksByModID = - loadedContentPacks - .GroupBy(p => p.Manifest.ContentPackFor.UniqueID, StringComparer.InvariantCultureIgnoreCase) - .ToDictionary( - group => group.Key, - group => group.Select(metadata => metadata.ContentPack).ToArray(), - StringComparer.InvariantCultureIgnoreCase - ); - - // load mods from metadata - using (AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.Platform, this.Monitor)) - { - InterfaceProxyFactory proxyFactory = new InterfaceProxyFactory(); - foreach (IModMetadata metadata in mods.Where(p => !p.IsContentPack)) - { - // get basic info - IManifest manifest = metadata.Manifest; - this.Monitor.Log(metadata.Manifest?.EntryDll != null - ? $" {metadata.DisplayName} ({PathUtilities.GetRelativePath(this.ModsPath, metadata.DirectoryPath)}{Path.DirectorySeparatorChar}{metadata.Manifest.EntryDll})..." // don't use Path.Combine here, since EntryDLL might not be valid - : $" {metadata.DisplayName}...", LogLevel.Trace); - - // show warnings - if (metadata.HasManifest() && !metadata.HasUpdateKeys() && !suppressUpdateChecks.Contains(metadata.Manifest.UniqueID)) - metadata.SetWarning(ModWarning.NoUpdateKeys); - - // validate status - if (metadata.Status == ModMetadataStatus.Failed) - { - this.Monitor.Log($" Failed: {metadata.Error}", LogLevel.Trace); - TrackSkip(metadata, metadata.Error); - continue; - } - - // load mod - string assemblyPath = metadata.Manifest?.EntryDll != null - ? Path.Combine(metadata.DirectoryPath, metadata.Manifest.EntryDll) - : null; - Assembly modAssembly; - try - { - modAssembly = modAssemblyLoader.Load(metadata, assemblyPath, assumeCompatible: metadata.DataRecord?.Status == ModStatus.AssumeCompatible); - } - catch (IncompatibleInstructionException) // details already in trace logs - { - string[] updateUrls = new[] { modDatabase.GetModPageUrlFor(metadata.Manifest.UniqueID), "https://smapi.io/compat" }.Where(p => p != null).ToArray(); - - TrackSkip(metadata, $"it's no longer compatible. Please check for a new version at {string.Join(" or ", updateUrls)}."); - continue; - } - catch (SAssemblyLoadFailedException ex) - { - TrackSkip(metadata, $"it DLL couldn't be loaded: {ex.Message}"); - continue; - } - catch (Exception ex) - { - TrackSkip(metadata, "its DLL couldn't be loaded.", $"Error: {ex.GetLogSummary()}"); - continue; - } - - // initialise mod - try - { - // get mod instance - if (!this.TryLoadModEntry(modAssembly, error => TrackSkip(metadata, error), out Mod mod)) - continue; - - // get content packs - if (!contentPacksByModID.TryGetValue(manifest.UniqueID, out IContentPack[] contentPacks)) - contentPacks = new IContentPack[0]; - - // init mod helpers - IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName); - IModHelper modHelper; - { - IModEvents events = new ModEvents(metadata, this.EventManager); - ICommandHelper commandHelper = new CommandHelper(manifest.UniqueID, metadata.DisplayName, this.GameInstance.CommandManager); - IContentHelper contentHelper = new ContentHelper(contentCore, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor); - IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, metadata.DisplayName, this.Reflection, this.DeprecationManager); - IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry, proxyFactory, monitor); - IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(manifest.UniqueID, this.GameInstance.Multiplayer); - ITranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, manifest.Name, contentCore.GetLocale(), contentCore.Language); - - IContentPack CreateTransitionalContentPack(string packDirPath, IManifest packManifest) - { - IMonitor packMonitor = this.GetSecondaryMonitor(packManifest.Name); - IContentHelper packContentHelper = new ContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor); - return new ContentPack(packDirPath, packManifest, packContentHelper, this.Toolkit.JsonHelper); - } - - modHelper = new ModHelper(manifest.UniqueID, metadata.DirectoryPath, jsonHelper, this.GameInstance.Input, events, contentHelper, commandHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper, contentPacks, CreateTransitionalContentPack, this.DeprecationManager); - } - - // init mod - mod.ModManifest = manifest; - mod.Helper = modHelper; - mod.Monitor = monitor; - - // track mod - metadata.SetMod(mod); - this.ModRegistry.Add(metadata); - } - catch (Exception ex) - { - TrackSkip(metadata, $"initialisation failed:\n{ex.GetLogSummary()}"); - } - } - } - } - IModMetadata[] loadedMods = this.ModRegistry.GetAll(contentPacks: false).ToArray(); - - // log loaded mods - this.Monitor.Log($"Loaded {loadedMods.Length} mods" + (loadedMods.Length > 0 ? ":" : "."), LogLevel.Info); - foreach (IModMetadata metadata in loadedMods.OrderBy(p => p.DisplayName)) + // min version + if (Constants.GameVersion.IsOlderThan(Constants.MinimumGameVersion)) { - IManifest manifest = metadata.Manifest; - this.Monitor.Log( - $" {metadata.DisplayName} {manifest.Version}" - + (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "") - + (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""), - LogLevel.Info + ISemanticVersion suggestedApiVersion = Constants.GetCompatibleApiVersion(Constants.GameVersion); + Program.PrintErrorAndExit(suggestedApiVersion != null + ? $"Oops! You're running Stardew Valley {Constants.GameVersion}, but the oldest supported version is {Constants.MinimumGameVersion}. You can install SMAPI {suggestedApiVersion} instead to fix this error, or update your game to the latest version." + : $"Oops! You're running Stardew Valley {Constants.GameVersion}, but the oldest supported version is {Constants.MinimumGameVersion}. Please update your game before using SMAPI." ); } - this.Monitor.Newline(); - - // log loaded content packs - if (loadedContentPacks.Any()) - { - string GetModDisplayName(string id) => loadedMods.FirstOrDefault(p => id != null && id.Equals(p.Manifest?.UniqueID, StringComparison.InvariantCultureIgnoreCase))?.DisplayName; - - this.Monitor.Log($"Loaded {loadedContentPacks.Length} content packs:", LogLevel.Info); - foreach (IModMetadata metadata in loadedContentPacks.OrderBy(p => p.DisplayName)) - { - IManifest manifest = metadata.Manifest; - this.Monitor.Log( - $" {metadata.DisplayName} {manifest.Version}" - + (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "") - + (metadata.IsContentPack ? $" | for {GetModDisplayName(metadata.Manifest.ContentPackFor.UniqueID)}" : "") - + (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""), - LogLevel.Info - ); - } - this.Monitor.Newline(); - } - - // log mod warnings - this.LogModWarnings(this.ModRegistry.GetAll().ToArray(), skippedMods); - - // initialise translations - this.ReloadTranslations(loadedMods); - // initialise loaded non-content-pack mods - foreach (IModMetadata metadata in loadedMods) - { - // add interceptors - if (metadata.Mod.Helper.Content is ContentHelper helper) - { - // ReSharper disable SuspiciousTypeConversion.Global - if (metadata.Mod is IAssetEditor editor) - helper.ObservableAssetEditors.Add(editor); - if (metadata.Mod is IAssetLoader loader) - helper.ObservableAssetLoaders.Add(loader); - // ReSharper restore SuspiciousTypeConversion.Global - - this.ContentCore.Editors[metadata] = helper.ObservableAssetEditors; - this.ContentCore.Loaders[metadata] = helper.ObservableAssetLoaders; - } - - // call entry method - try - { - IMod mod = metadata.Mod; - mod.Entry(mod.Helper); - } - catch (Exception ex) - { - metadata.LogAsMod($"Mod crashed on entry and might not work correctly. Technical details:\n{ex.GetLogSummary()}", LogLevel.Error); - } - - // get mod API - try - { - object api = metadata.Mod.GetApi(); - if (api != null && !api.GetType().IsPublic) - { - api = null; - this.Monitor.Log($"{metadata.DisplayName} provides an API instance with a non-public type. This isn't currently supported, so the API won't be available to other mods.", LogLevel.Warn); - } + // max version + else if (Constants.MaximumGameVersion != null && Constants.GameVersion.IsNewerThan(Constants.MaximumGameVersion)) + Program.PrintErrorAndExit($"Oops! You're running Stardew Valley {Constants.GameVersion}, but this version of SMAPI is only compatible up to Stardew Valley {Constants.MaximumGameVersion}. Please check for a newer version of SMAPI: https://smapi.io."); - if (api != null) - this.Monitor.Log($" Found mod-provided API ({api.GetType().FullName}).", LogLevel.Trace); - metadata.SetApi(api); - } - catch (Exception ex) - { - this.Monitor.Log($"Failed loading mod-provided API for {metadata.DisplayName}. Integrations with other mods may not work. Error: {ex.GetLogSummary()}", LogLevel.Error); - } - } - - // invalidate cache entries when needed - // (These listeners are registered after Entry to avoid repeatedly reloading assets as mods initialise.) - foreach (IModMetadata metadata in loadedMods) - { - if (metadata.Mod.Helper.Content is ContentHelper helper) - { - helper.ObservableAssetEditors.CollectionChanged += (sender, e) => - { - if (e.NewItems?.Count > 0) - { - this.Monitor.Log("Invalidating cache entries for new asset editors...", LogLevel.Trace); - this.ContentCore.InvalidateCacheFor(e.NewItems.Cast<IAssetEditor>().ToArray(), new IAssetLoader[0]); - } - }; - helper.ObservableAssetLoaders.CollectionChanged += (sender, e) => - { - if (e.NewItems?.Count > 0) - { - this.Monitor.Log("Invalidating cache entries for new asset loaders...", LogLevel.Trace); - this.ContentCore.InvalidateCacheFor(new IAssetEditor[0], e.NewItems.Cast<IAssetLoader>().ToArray()); - } - }; - } - } - - // reset cache now if any editors or loaders were added during entry - IAssetEditor[] editors = loadedMods.SelectMany(p => p.Mod.Helper.Content.AssetEditors).ToArray(); - IAssetLoader[] loaders = loadedMods.SelectMany(p => p.Mod.Helper.Content.AssetLoaders).ToArray(); - if (editors.Any() || loaders.Any()) - { - this.Monitor.Log("Invalidating cached assets for new editors & loaders...", LogLevel.Trace); - this.ContentCore.InvalidateCacheFor(editors, loaders); - } - - // unlock mod integrations - this.ModRegistry.AreAllModsInitialised = true; } - /// <summary>Write a summary of mod warnings to the console and log.</summary> - /// <param name="mods">The loaded mods.</param> - /// <param name="skippedMods">The mods which were skipped, along with the friendly and developer reasons.</param> - private void LogModWarnings(IModMetadata[] mods, IDictionary<IModMetadata, string[]> skippedMods) - { - // get mods with warnings - IModMetadata[] modsWithWarnings = mods.Where(p => p.Warnings != ModWarning.None).ToArray(); - if (!modsWithWarnings.Any() && !skippedMods.Any()) - return; - - // log intro - { - int count = modsWithWarnings.Union(skippedMods.Keys).Count(); - this.Monitor.Log($"Found {count} mod{(count == 1 ? "" : "s")} with warnings:", LogLevel.Info); - } - - // log skipped mods - if (skippedMods.Any()) - { - this.Monitor.Log(" Skipped mods", LogLevel.Error); - this.Monitor.Log(" " + "".PadRight(50, '-'), LogLevel.Error); - this.Monitor.Log(" These mods could not be added to your game.", LogLevel.Error); - this.Monitor.Newline(); - - foreach (var pair in skippedMods.OrderBy(p => p.Key.DisplayName)) - { - IModMetadata mod = pair.Key; - string[] reason = pair.Value; - - this.Monitor.Log($" - {mod.DisplayName}{(mod.Manifest?.Version != null ? " " + mod.Manifest.Version.ToString() : "")} because {reason[0]}", LogLevel.Error); - if (reason[1] != null) - this.Monitor.Log($" ({reason[1]})", LogLevel.Trace); - } - this.Monitor.Newline(); - } - - // log warnings - if (modsWithWarnings.Any()) - { - // issue block format logic - void LogWarningGroup(ModWarning warning, LogLevel logLevel, string heading, params string[] blurb) - { - IModMetadata[] matches = modsWithWarnings.Where(p => p.Warnings.HasFlag(warning)).ToArray(); - if (!matches.Any()) - return; - - this.Monitor.Log(" " + heading, logLevel); - this.Monitor.Log(" " + "".PadRight(50, '-'), logLevel); - foreach (string line in blurb) - this.Monitor.Log(" " + line, logLevel); - this.Monitor.Newline(); - foreach (IModMetadata match in matches) - this.Monitor.Log($" - {match.DisplayName}", logLevel); - this.Monitor.Newline(); - } - - // supported issues - LogWarningGroup(ModWarning.BrokenCodeLoaded, LogLevel.Error, "Broken mods", - "These mods have broken code, but you configured SMAPI to load them anyway. This may cause bugs,", - "errors, or crashes in-game." - ); - LogWarningGroup(ModWarning.ChangesSaveSerialiser, LogLevel.Warn, "Changed save serialiser", - "These mods change the save serialiser. They may corrupt your save files, or make them unusable if", - "you uninstall these mods." - ); - LogWarningGroup(ModWarning.PatchesGame, LogLevel.Info, "Patched game code", - "These mods directly change the game code. They're more likely to cause errors or bugs in-game; if", - "your game has issues, try removing these first. Otherwise you can ignore this warning." - ); - LogWarningGroup(ModWarning.UsesUnvalidatedUpdateTick, LogLevel.Info, "Bypassed safety checks", - "These mods bypass SMAPI's normal safety checks, so they're more likely to cause errors or save", - "corruption. If your game has issues, try removing these first." - ); - LogWarningGroup(ModWarning.NoUpdateKeys, LogLevel.Debug, "No update keys", - "These mods have no update keys in their manifest. SMAPI may not notify you about updates for these", - "mods. Consider notifying the mod authors about this problem." - ); - LogWarningGroup(ModWarning.UsesDynamic, LogLevel.Debug, "Not crossplatform", - "These mods use the 'dynamic' keyword, and won't work on Linux/Mac." - ); - } - } - - /// <summary>Load a mod's entry class.</summary> - /// <param name="modAssembly">The mod assembly.</param> - /// <param name="onError">A callback invoked when loading fails.</param> - /// <param name="mod">The loaded instance.</param> - private bool TryLoadModEntry(Assembly modAssembly, Action<string> onError, out Mod mod) + /// <summary>Initialise SMAPI and launch the game.</summary> + /// <param name="args">The command-line arguments.</param> + /// <remarks>This method is separate from <see cref="Main"/> because that can't contain any references to assemblies loaded by <see cref="CurrentDomain_AssemblyResolve"/> (e.g. via <see cref="Constants"/>), or Mono will incorrectly show an assembly resolution error before assembly resolution is set up.</remarks> + private static void Start(string[] args) { - mod = null; - - // find type - TypeInfo[] modEntries = modAssembly.DefinedTypes.Where(type => typeof(Mod).IsAssignableFrom(type) && !type.IsAbstract).Take(2).ToArray(); - if (modEntries.Length == 0) - { - onError($"its DLL has no '{nameof(Mod)}' subclass."); - return false; - } - if (modEntries.Length > 1) - { - onError($"its DLL contains multiple '{nameof(Mod)}' subclasses."); - return false; - } - - // get implementation - mod = (Mod)modAssembly.CreateInstance(modEntries[0].ToString()); - if (mod == null) - { - onError("its entry class couldn't be instantiated."); - return false; - } - - return true; - } + // get flags from arguments + bool writeToConsole = !args.Contains("--no-terminal"); - /// <summary>Reload translations for all mods.</summary> - /// <param name="mods">The mods for which to reload translations.</param> - private void ReloadTranslations(IEnumerable<IModMetadata> mods) - { - JsonHelper jsonHelper = this.Toolkit.JsonHelper; - foreach (IModMetadata metadata in mods) + // get mods path from arguments + string modsPath = null; { - if (metadata.IsContentPack) - throw new InvalidOperationException("Can't reload translations for a content pack."); - - // read translation files - IDictionary<string, IDictionary<string, string>> translations = new Dictionary<string, IDictionary<string, string>>(); - DirectoryInfo translationsDir = new DirectoryInfo(Path.Combine(metadata.DirectoryPath, "i18n")); - if (translationsDir.Exists) - { - foreach (FileInfo file in translationsDir.EnumerateFiles("*.json")) - { - string locale = Path.GetFileNameWithoutExtension(file.Name.ToLower().Trim()); - try - { - if (jsonHelper.ReadJsonFileIfExists(file.FullName, out IDictionary<string, string> data)) - translations[locale] = data; - else - metadata.LogAsMod($"Mod's i18n/{locale}.json file couldn't be parsed."); - } - catch (Exception ex) - { - metadata.LogAsMod($"Mod's i18n/{locale}.json file couldn't be parsed: {ex.GetLogSummary()}"); - } - } - } - - // validate translations - foreach (string locale in translations.Keys.ToArray()) + int pathIndex = Array.LastIndexOf(args, "--mods-path") + 1; + if (pathIndex >= 1 && args.Length >= pathIndex) { - // skip empty files - if (translations[locale] == null || !translations[locale].Keys.Any()) - { - metadata.LogAsMod($"Mod's i18n/{locale}.json is empty and will be ignored.", LogLevel.Warn); - translations.Remove(locale); - continue; - } - - // handle duplicates - HashSet<string> keys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); - HashSet<string> duplicateKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); - foreach (string key in translations[locale].Keys.ToArray()) - { - if (!keys.Add(key)) - { - duplicateKeys.Add(key); - translations[locale].Remove(key); - } - } - if (duplicateKeys.Any()) - metadata.LogAsMod($"Mod's i18n/{locale}.json has duplicate translation keys: [{string.Join(", ", duplicateKeys)}]. Keys are case-insensitive.", LogLevel.Warn); + modsPath = args[pathIndex]; + if (!string.IsNullOrWhiteSpace(modsPath) && !Path.IsPathRooted(modsPath)) + modsPath = Path.Combine(Constants.ExecutionPath, modsPath); } - - // update translation - TranslationHelper translationHelper = (TranslationHelper)metadata.Mod.Helper.Translation; - translationHelper.SetTranslations(translations); - } - } - - /// <summary>The method called when the user submits a core SMAPI command in the console.</summary> - /// <param name="name">The command name.</param> - /// <param name="arguments">The command arguments.</param> - private void HandleCommand(string name, string[] arguments) - { - switch (name) - { - case "help": - if (arguments.Any()) - { - Command result = this.GameInstance.CommandManager.Get(arguments[0]); - if (result == null) - this.Monitor.Log("There's no command with that name.", LogLevel.Error); - else - this.Monitor.Log($"{result.Name}: {result.Documentation}\n(Added by {result.ModName}.)", LogLevel.Info); - } - else - { - string message = "The following commands are registered:\n"; - IGrouping<string, string>[] groups = (from command in this.GameInstance.CommandManager.GetAll() orderby command.ModName, command.Name group command.Name by command.ModName).ToArray(); - foreach (var group in groups) - { - string modName = group.Key; - string[] commandNames = group.ToArray(); - message += $"{modName}:\n {string.Join("\n ", commandNames)}\n\n"; - } - message += "For more information about a command, type 'help command_name'."; - - this.Monitor.Log(message, LogLevel.Info); - } - break; - - case "reload_i18n": - this.ReloadTranslations(this.ModRegistry.GetAll(contentPacks: false)); - this.Monitor.Log("Reloaded translation files for all mods. This only affects new translations the mods fetch; if they cached some text, it may not be updated.", LogLevel.Info); - break; - - default: - throw new NotSupportedException($"Unrecognise core SMAPI command '{name}'."); + if (string.IsNullOrWhiteSpace(modsPath)) + modsPath = Constants.DefaultModsPath; } - } - - /// <summary>Redirect messages logged directly to the console to the given monitor.</summary> - /// <param name="monitor">The monitor with which to log messages.</param> - /// <param name="message">The message to log.</param> - private void HandleConsoleMessage(IMonitor monitor, string message) - { - // detect exception - LogLevel level = message.Contains("Exception") ? LogLevel.Error : LogLevel.Trace; - - // ignore suppressed message - if (level != LogLevel.Error && this.SuppressConsolePatterns.Any(p => p.IsMatch(message))) - return; - // forward to monitor - monitor.Log(message, level); + // load SMAPI + using (SCore core = new SCore(modsPath, writeToConsole)) + core.RunInteractively(); } - /// <summary>Show a 'press any key to exit' message, and exit when they press a key.</summary> - private void PressAnyKeyToExit() + /// <summary>Write an error directly to the console and exit.</summary> + /// <param name="message">The error message to display.</param> + private static void PrintErrorAndExit(string message) { - this.Monitor.Log("Game has ended. Press any key to exit.", LogLevel.Info); - Program.PressAnyKeyToExit(showMessage: false); + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine(message); + Console.ResetColor(); + Program.PressAnyKeyToExit(showMessage: true); } /// <summary>Show a 'press any key to exit' message, and exit when they press a key.</summary> @@ -1278,70 +147,5 @@ namespace StardewModdingAPI Console.ReadKey(); Environment.Exit(0); } - - /// <summary>Get a monitor instance derived from SMAPI's current settings.</summary> - /// <param name="name">The name of the module which will log messages with this instance.</param> - private Monitor GetSecondaryMonitor(string name) - { - return new Monitor(name, this.ConsoleManager, this.LogFile, this.CancellationTokenSource, this.Settings.ColorScheme) - { - WriteToConsole = this.Monitor.WriteToConsole, - ShowTraceInConsole = this.Settings.DeveloperMode, - ShowFullStampInConsole = this.Settings.DeveloperMode - }; - } - - /// <summary>Log a message if verbose mode is enabled.</summary> - /// <param name="message">The message to log.</param> - private void VerboseLog(string message) - { - if (this.Settings.VerboseLogging) - this.Monitor.Log(message, LogLevel.Trace); - } - - /// <summary>Get the absolute path to the next available log file.</summary> - private string GetLogPath() - { - // default path - { - FileInfo defaultFile = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogFilename}.{Constants.LogExtension}")); - if (!defaultFile.Exists) - return defaultFile.FullName; - } - - // get first disambiguated path - for (int i = 2; i < int.MaxValue; i++) - { - FileInfo file = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogFilename}.player-{i}.{Constants.LogExtension}")); - if (!file.Exists) - return file.FullName; - } - - // should never happen - throw new InvalidOperationException("Could not find an available log path."); - } - - /// <summary>Delete all log files created by SMAPI.</summary> - private void PurgeLogFiles() - { - DirectoryInfo logsDir = new DirectoryInfo(Constants.LogDir); - if (!logsDir.Exists) - return; - - foreach (FileInfo logFile in logsDir.EnumerateFiles()) - { - if (logFile.Name.StartsWith(Constants.LogNamePrefix, StringComparison.InvariantCultureIgnoreCase)) - { - try - { - FileUtilities.ForceDelete(logFile); - } - catch (IOException) - { - // ignore file if it's in use - } - } - } - } } } diff --git a/src/SMAPI/SemanticVersion.cs b/src/SMAPI/SemanticVersion.cs index 587ff286..401f62c2 100644 --- a/src/SMAPI/SemanticVersion.cs +++ b/src/SMAPI/SemanticVersion.cs @@ -1,5 +1,6 @@ using System; using Newtonsoft.Json; +using StardewModdingAPI.Framework; namespace StardewModdingAPI { @@ -12,6 +13,9 @@ namespace StardewModdingAPI /// <summary>The underlying semantic version implementation.</summary> private readonly ISemanticVersion Version; + /// <summary>Manages deprecation warnings.</summary> + internal static DeprecationManager DeprecationManager { get; set; } + /********* ** Accessors @@ -26,7 +30,18 @@ namespace StardewModdingAPI public int PatchVersion => this.Version.PatchVersion; /// <summary>An optional build tag.</summary> - public string Build => this.Version.Build; + [Obsolete("Use " + nameof(ISemanticVersion.PrereleaseTag) + " instead")] + public string Build + { + get + { + SemanticVersion.DeprecationManager?.Warn($"{nameof(ISemanticVersion)}.{nameof(ISemanticVersion.Build)}", "2.8", DeprecationLevel.Notice); + return this.Version.PrereleaseTag; + } + } + + /// <summary>An optional prerelease tag.</summary> + public string PrereleaseTag => this.Version.PrereleaseTag; /********* @@ -70,7 +85,7 @@ namespace StardewModdingAPI /// <summary>Get an integer indicating whether this version precedes (less than 0), supercedes (more than 0), or is equivalent to (0) the specified version.</summary> /// <param name="other">The version to compare with this instance.</param> /// <exception cref="ArgumentNullException">The <paramref name="other"/> value is null.</exception> - /// <remarks>The implementation is defined by Semantic Version 2.0 (http://semver.org/).</remarks> + /// <remarks>The implementation is defined by Semantic Version 2.0 (https://semver.org/).</remarks> public int CompareTo(ISemanticVersion other) { return this.Version.CompareTo(other); diff --git a/src/SMAPI/StardewModdingAPI.config.json b/src/SMAPI/StardewModdingAPI.config.json index 115997ba..ad908fc0 100644 --- a/src/SMAPI/StardewModdingAPI.config.json +++ b/src/SMAPI/StardewModdingAPI.config.json @@ -9,10 +9,12 @@ This file contains advanced configuration for SMAPI. You generally shouldn't cha */ { /** - * Whether to enable features intended for mod developers. Currently this only makes TRACE-level - * messages appear in the console. + * The console color theme to use. The possible values are: + * - AutoDetect: SMAPI will assume a light background on Mac, and detect the background color automatically on Linux or Windows. + * - LightBackground: use darker text colors that look better on a white or light background. + * - DarkBackground: use lighter text colors that look better on a black or dark background. */ - "DeveloperMode": true, + "ColorScheme": "AutoDetect", /** * Whether SMAPI should check for newer versions of SMAPI and mods when you load the game. If new @@ -22,8 +24,23 @@ This file contains advanced configuration for SMAPI. You generally shouldn't cha "CheckForUpdates": true, /** - * Whether SMAPI should show newer beta versions as an available update. If not specified, SMAPI - * will only show beta updates if the current version is beta. + * Whether to enable features intended for mod developers. Currently this only makes TRACE-level + * messages appear in the console. + */ + "DeveloperMode": 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 + * investigation. When this is commented out, it'll be true for local debug builds and false + * otherwise. + */ + //"ParanoidWarnings": true, + + /** + * Whether SMAPI should show newer beta versions as an available update. When this is commented + * out, it'll be true if the current SMAPI version is beta, and false otherwise. */ //"UseBetaChannel": true, @@ -51,14 +68,6 @@ This file contains advanced configuration for SMAPI. You generally shouldn't cha "DumpMetadata": false, /** - * The console color theme to use. The possible values are: - * - AutoDetect: SMAPI will assume a light background on Mac, and detect the background color automatically on Linux or Windows. - * - LightBackground: use darker text colors that look better on a white or light background. - * - DarkBackground: use lighter text colors that look better on a black or dark background. - */ - "ColorScheme": "AutoDetect", - - /** * The mod IDs SMAPI should ignore when performing update checks or validating update keys. */ "SuppressUpdateChecks": [ diff --git a/src/SMAPI/StardewModdingAPI.csproj b/src/SMAPI/StardewModdingAPI.csproj index fc2d45ba..5a098b8a 100644 --- a/src/SMAPI/StardewModdingAPI.csproj +++ b/src/SMAPI/StardewModdingAPI.csproj @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> -<Project ToolsVersion="12.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> +<Project ToolsVersion="15.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> <PropertyGroup> <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> @@ -54,26 +54,12 @@ <ApplicationIcon>icon.ico</ApplicationIcon> </PropertyGroup> <ItemGroup> - <Reference Include="0Harmony, Version=1.0.9.1, Culture=neutral, processorArchitecture=MSIL"> - <SpecificVersion>False</SpecificVersion> - <HintPath>..\lib\0Harmony.dll</HintPath> - </Reference> - <Reference Include="Mono.Cecil, Version=0.10.0.0, Culture=neutral, PublicKeyToken=50cebf1cceb9d05e, processorArchitecture=MSIL"> - <HintPath>..\packages\Mono.Cecil.0.10.0\lib\net40\Mono.Cecil.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="Mono.Cecil.Mdb, Version=0.10.0.0, Culture=neutral, PublicKeyToken=50cebf1cceb9d05e, processorArchitecture=MSIL"> - <HintPath>..\packages\Mono.Cecil.0.10.0\lib\net40\Mono.Cecil.Mdb.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="Mono.Cecil.Pdb, Version=0.10.0.0, Culture=neutral, PublicKeyToken=50cebf1cceb9d05e, processorArchitecture=MSIL"> - <HintPath>..\packages\Mono.Cecil.0.10.0\lib\net40\Mono.Cecil.Pdb.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="Newtonsoft.Json, Version=11.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL"> - <HintPath>..\packages\Newtonsoft.Json.11.0.2\lib\net45\Newtonsoft.Json.dll</HintPath> - <Private>True</Private> - </Reference> + <PackageReference Include="LargeAddressAware" Version="1.0.3" /> + <PackageReference Include="Lib.Harmony" Version="1.2.0.1" /> + <PackageReference Include="Mono.Cecil" Version="0.10.1" /> + <PackageReference Include="Newtonsoft.Json" Version="11.0.2" /> + </ItemGroup> + <ItemGroup> <Reference Include="System" /> <Reference Include="System.Core" /> <Reference Include="System.Drawing" /> @@ -90,25 +76,116 @@ <Compile Include="..\..\build\GlobalAssemblyInfo.cs"> <Link>Properties\GlobalAssemblyInfo.cs</Link> </Compile> - <Compile Include="Events\GameLoopUpdatedEventArgs.cs" /> - <Compile Include="Events\GameLoopLaunchedEventArgs.cs" /> - <Compile Include="Events\InputMouseWheelScrolledEventArgs.cs" /> - <Compile Include="Events\InputCursorMovedEventArgs.cs" /> - <Compile Include="Events\InputButtonReleasedEventArgs.cs" /> - <Compile Include="Events\InputButtonPressedEventArgs.cs" /> + <Compile Include="Enums\SkillType.cs" /> + <Compile Include="Events\BuildingListChangedEventArgs.cs" /> + <Compile Include="Events\ButtonPressedEventArgs.cs" /> + <Compile Include="Events\ButtonReleasedEventArgs.cs" /> + <Compile Include="Events\ChangeType.cs" /> + <Compile Include="Events\ContentEvents.cs" /> + <Compile Include="Events\ControlEvents.cs" /> + <Compile Include="Events\CursorMovedEventArgs.cs" /> + <Compile Include="Events\DayEndingEventArgs.cs" /> + <Compile Include="Events\DayStartedEventArgs.cs" /> + <Compile Include="Events\DebrisListChangedEventArgs.cs" /> + <Compile Include="Events\EventArgsClickableMenuChanged.cs" /> + <Compile Include="Events\EventArgsClickableMenuClosed.cs" /> + <Compile Include="Events\EventArgsControllerButtonPressed.cs" /> + <Compile Include="Events\EventArgsControllerButtonReleased.cs" /> + <Compile Include="Events\EventArgsControllerTriggerPressed.cs" /> + <Compile Include="Events\EventArgsControllerTriggerReleased.cs" /> + <Compile Include="Events\EventArgsInput.cs" /> + <Compile Include="Events\EventArgsIntChanged.cs" /> + <Compile Include="Events\EventArgsInventoryChanged.cs" /> + <Compile Include="Events\EventArgsKeyboardStateChanged.cs" /> + <Compile Include="Events\EventArgsKeyPressed.cs" /> + <Compile Include="Events\EventArgsLevelUp.cs" /> <Compile Include="Events\EventArgsLocationBuildingsChanged.cs" /> - <Compile Include="Events\IInputEvents.cs" /> + <Compile Include="Events\EventArgsLocationObjectsChanged.cs" /> + <Compile Include="Events\EventArgsLocationsChanged.cs" /> + <Compile Include="Events\EventArgsMineLevelChanged.cs" /> + <Compile Include="Events\EventArgsMouseStateChanged.cs" /> + <Compile Include="Events\EventArgsPlayerWarped.cs" /> + <Compile Include="Events\EventArgsValueChanged.cs" /> + <Compile Include="Events\GameEvents.cs" /> + <Compile Include="Events\GameLaunchedEventArgs.cs" /> + <Compile Include="Events\GraphicsEvents.cs" /> + <Compile Include="Events\IDisplayEvents.cs" /> <Compile Include="Events\IGameLoopEvents.cs" /> + <Compile Include="Events\IInputEvents.cs" /> + <Compile Include="Events\IModEvents.cs" /> + <Compile Include="Events\IMultiplayerEvents.cs" /> + <Compile Include="Events\InputEvents.cs" /> + <Compile Include="Events\InventoryChangedEventArgs.cs" /> + <Compile Include="Events\IPlayerEvents.cs" /> + <Compile Include="Events\ISpecialisedEvents.cs" /> + <Compile Include="Events\ItemStackChange.cs" /> + <Compile Include="Events\ItemStackSizeChange.cs" /> <Compile Include="Events\IWorldEvents.cs" /> + <Compile Include="Events\LargeTerrainFeatureListChangedEventArgs.cs" /> + <Compile Include="Events\LevelChangedEventArgs.cs" /> + <Compile Include="Events\LocationEvents.cs" /> + <Compile Include="Events\LocationListChangedEventArgs.cs" /> + <Compile Include="Events\MenuChangedEventArgs.cs" /> + <Compile Include="Events\MenuEvents.cs" /> + <Compile Include="Events\MineEvents.cs" /> + <Compile Include="Events\ModMessageReceivedEventArgs.cs" /> + <Compile Include="Events\MouseWheelScrolledEventArgs.cs" /> <Compile Include="Events\MultiplayerEvents.cs" /> - <Compile Include="Events\WorldDebrisListChangedEventArgs.cs" /> - <Compile Include="Events\GameLoopUpdatingEventArgs.cs" /> - <Compile Include="Events\WorldNpcListChangedEventArgs.cs" /> - <Compile Include="Events\WorldLargeTerrainFeatureListChangedEventArgs.cs" /> - <Compile Include="Events\WorldTerrainFeatureListChangedEventArgs.cs" /> - <Compile Include="Events\WorldBuildingListChangedEventArgs.cs" /> - <Compile Include="Events\WorldLocationListChangedEventArgs.cs" /> - <Compile Include="Events\WorldObjectListChangedEventArgs.cs" /> + <Compile Include="Events\NpcListChangedEventArgs.cs" /> + <Compile Include="Events\ObjectListChangedEventArgs.cs" /> + <Compile Include="Events\PeerContextReceivedEventArgs.cs" /> + <Compile Include="Events\PeerDisconnectedEventArgs.cs" /> + <Compile Include="Events\PlayerEvents.cs" /> + <Compile Include="Events\RenderedActiveMenuEventArgs.cs" /> + <Compile Include="Events\RenderedEventArgs.cs" /> + <Compile Include="Events\RenderedHudEventArgs.cs" /> + <Compile Include="Events\RenderedWorldEventArgs.cs" /> + <Compile Include="Events\RenderingActiveMenuEventArgs.cs" /> + <Compile Include="Events\RenderingEventArgs.cs" /> + <Compile Include="Events\RenderingHudEventArgs.cs" /> + <Compile Include="Events\RenderingWorldEventArgs.cs" /> + <Compile Include="Events\ReturnedToTitleEventArgs.cs" /> + <Compile Include="Events\SaveCreatedEventArgs.cs" /> + <Compile Include="Events\SaveCreatingEventArgs.cs" /> + <Compile Include="Events\SavedEventArgs.cs" /> + <Compile Include="Events\SaveEvents.cs" /> + <Compile Include="Events\SaveLoadedEventArgs.cs" /> + <Compile Include="Events\SavingEventArgs.cs" /> + <Compile Include="Events\SpecialisedEvents.cs" /> + <Compile Include="Events\TerrainFeatureListChangedEventArgs.cs" /> + <Compile Include="Events\TimeChangedEventArgs.cs" /> + <Compile Include="Events\TimeEvents.cs" /> + <Compile Include="Events\UnvalidatedUpdateTickedEventArgs.cs" /> + <Compile Include="Events\UnvalidatedUpdateTickingEventArgs.cs" /> + <Compile Include="Events\UpdateTickedEventArgs.cs" /> + <Compile Include="Events\UpdateTickingEventArgs.cs" /> + <Compile Include="Events\WarpedEventArgs.cs" /> + <Compile Include="Events\WindowResizedEventArgs.cs" /> + <Compile Include="Framework\DeprecationWarning.cs" /> + <Compile Include="Framework\Events\EventManager.cs" /> + <Compile Include="Framework\Events\ManagedEvent.cs" /> + <Compile Include="Framework\Events\ManagedEventBase.cs" /> + <Compile Include="Framework\Events\ModDisplayEvents.cs" /> + <Compile Include="Framework\Events\ModEvents.cs" /> + <Compile Include="Framework\Events\ModEventsBase.cs" /> + <Compile Include="Framework\Events\ModGameLoopEvents.cs" /> + <Compile Include="Framework\Events\ModInputEvents.cs" /> + <Compile Include="Framework\Events\ModMultiplayerEvents.cs" /> + <Compile Include="Framework\Events\ModPlayerEvents.cs" /> + <Compile Include="Framework\Events\ModSpecialisedEvents.cs" /> + <Compile Include="Framework\Events\ModWorldEvents.cs" /> + <Compile Include="Framework\ModHelpers\DataHelper.cs" /> + <Compile Include="Framework\Networking\MessageType.cs" /> + <Compile Include="Framework\Networking\ModMessageModel.cs" /> + <Compile Include="Framework\Networking\MultiplayerPeer.cs" /> + <Compile Include="Framework\Networking\MultiplayerPeerMod.cs" /> + <Compile Include="Framework\Networking\RemoteContextModel.cs" /> + <Compile Include="Framework\Networking\RemoteContextModModel.cs" /> + <Compile Include="Framework\Networking\SGalaxyNetClient.cs" /> + <Compile Include="Framework\Networking\SGalaxyNetServer.cs" /> + <Compile Include="Framework\Networking\SLidgrenClient.cs" /> + <Compile Include="Framework\Networking\SLidgrenServer.cs" /> + <Compile Include="Framework\SCore.cs" /> <Compile Include="Framework\SGameConstructorHack.cs" /> <Compile Include="Framework\ContentManagers\BaseContentManager.cs" /> <Compile Include="Framework\ContentManagers\GameContentManager.cs" /> @@ -121,21 +198,15 @@ <Compile Include="Framework\Serialisation\ColorConverter.cs" /> <Compile Include="Framework\Serialisation\PointConverter.cs" /> <Compile Include="Framework\Serialisation\RectangleConverter.cs" /> - <Compile Include="Framework\Events\ModEventsBase.cs" /> - <Compile Include="Framework\Events\EventManager.cs" /> - <Compile Include="Events\IModEvents.cs" /> - <Compile Include="Framework\Events\ManagedEvent.cs" /> - <Compile Include="Events\SpecialisedEvents.cs" /> <Compile Include="Framework\ContentPack.cs" /> <Compile Include="Framework\Content\ContentCache.cs" /> - <Compile Include="Framework\Events\ManagedEventBase.cs" /> - <Compile Include="Framework\Events\ModEvents.cs" /> - <Compile Include="Framework\Events\ModGameLoopEvents.cs" /> - <Compile Include="Framework\Events\ModInputEvents.cs" /> <Compile Include="Framework\Input\GamePadStateBuilder.cs" /> <Compile Include="Framework\ModHelpers\InputHelper.cs" /> + <Compile Include="Framework\SModHooks.cs" /> + <Compile Include="Framework\Singleton.cs" /> <Compile Include="Framework\StateTracking\Comparers\GenericEqualsComparer.cs" /> <Compile Include="Framework\WatcherCore.cs" /> + <Compile Include="IDataHelper.cs" /> <Compile Include="IInputHelper.cs" /> <Compile Include="Framework\Input\SInputState.cs" /> <Compile Include="Framework\Input\InputStatus.cs" /> @@ -156,12 +227,10 @@ <Compile Include="Framework\ModLoading\Rewriters\StaticFieldToConstantRewriter.cs" /> <Compile Include="Framework\ModLoading\Rewriters\FieldToPropertyRewriter.cs" /> <Compile Include="Framework\ModLoading\Finders\ReferenceToMemberWithUnexpectedTypeFinder.cs" /> - <Compile Include="Framework\ModLoading\Rewriters\VirtualEntryCallRemover.cs" /> <Compile Include="Framework\ModLoading\Rewriters\MethodParentRewriter.cs" /> <Compile Include="Framework\ModLoading\Rewriters\TypeReferenceRewriter.cs" /> <Compile Include="Framework\Exceptions\SAssemblyLoadFailedException.cs" /> <Compile Include="Framework\ModLoading\AssemblyLoadStatus.cs" /> - <Compile Include="Framework\Events\ModWorldEvents.cs" /> <Compile Include="Framework\Reflection\InterfaceProxyBuilder.cs" /> <Compile Include="Framework\Reflection\InterfaceProxyFactory.cs" /> <Compile Include="Framework\RewriteFacades\SpriteBatchMethods.cs" /> @@ -184,39 +253,19 @@ <Compile Include="Framework\StateTracking\PlayerTracker.cs" /> <Compile Include="Framework\Utilities\ContextHash.cs" /> <Compile Include="IContentPack.cs" /> + <Compile Include="IModInfo.cs" /> <Compile Include="IMultiplayerHelper.cs" /> + <Compile Include="IMultiplayerPeer.cs" /> <Compile Include="IReflectedField.cs" /> <Compile Include="IReflectedMethod.cs" /> <Compile Include="IReflectedProperty.cs" /> + <Compile Include="IMultiplayerPeerMod.cs" /> <Compile Include="Metadata\CoreAssetPropagator.cs" /> <Compile Include="ContentSource.cs" /> - <Compile Include="Events\ContentEvents.cs" /> - <Compile Include="Events\EventArgsInput.cs" /> - <Compile Include="Events\EventArgsValueChanged.cs" /> - <Compile Include="Events\InputEvents.cs" /> <Compile Include="Framework\Content\AssetInfo.cs" /> <Compile Include="Framework\Exceptions\SContentLoadException.cs" /> <Compile Include="Framework\Command.cs" /> <Compile Include="Constants.cs" /> - <Compile Include="Events\ControlEvents.cs" /> - <Compile Include="Events\EventArgsClickableMenuChanged.cs" /> - <Compile Include="Events\EventArgsClickableMenuClosed.cs" /> - <Compile Include="Events\EventArgsControllerButtonPressed.cs" /> - <Compile Include="Events\EventArgsControllerButtonReleased.cs" /> - <Compile Include="Events\EventArgsControllerTriggerPressed.cs" /> - <Compile Include="Events\EventArgsControllerTriggerReleased.cs" /> - <Compile Include="Events\EventArgsPlayerWarped.cs" /> - <Compile Include="Events\EventArgsLocationsChanged.cs" /> - <Compile Include="Events\EventArgsIntChanged.cs" /> - <Compile Include="Events\EventArgsInventoryChanged.cs" /> - <Compile Include="Events\EventArgsKeyboardStateChanged.cs" /> - <Compile Include="Events\EventArgsKeyPressed.cs" /> - <Compile Include="Events\EventArgsLevelUp.cs" /> - <Compile Include="Events\EventArgsLocationObjectsChanged.cs" /> - <Compile Include="Events\EventArgsMineLevelChanged.cs" /> - <Compile Include="Events\EventArgsMouseStateChanged.cs" /> - <Compile Include="Events\GameEvents.cs" /> - <Compile Include="Events\GraphicsEvents.cs" /> <Compile Include="Framework\Utilities\Countdown.cs" /> <Compile Include="Framework\GameVersion.cs" /> <Compile Include="Framework\IModMetadata.cs" /> @@ -255,12 +304,6 @@ <Compile Include="IAssetDataForImage.cs" /> <Compile Include="IContentHelper.cs" /> <Compile Include="IModRegistry.cs" /> - <Compile Include="Events\LocationEvents.cs" /> - <Compile Include="Events\MenuEvents.cs" /> - <Compile Include="Events\MineEvents.cs" /> - <Compile Include="Events\PlayerEvents.cs" /> - <Compile Include="Events\SaveEvents.cs" /> - <Compile Include="Events\TimeEvents.cs" /> <Compile Include="Framework\DeprecationLevel.cs" /> <Compile Include="Framework\DeprecationManager.cs" /> <Compile Include="Framework\InternalExtensions.cs" /> @@ -277,11 +320,10 @@ <Compile Include="LogLevel.cs" /> <Compile Include="Framework\ModRegistry.cs" /> <Compile Include="IMonitor.cs" /> - <Compile Include="Events\ChangeType.cs" /> - <Compile Include="Events\ItemStackChange.cs" /> <Compile Include="Framework\Monitor.cs" /> <Compile Include="Metadata\InstructionMetadata.cs" /> <Compile Include="Mod.cs" /> + <Compile Include="Patches\DialogueErrorPatch.cs" /> <Compile Include="PatchMode.cs" /> <Compile Include="GamePlatform.cs" /> <Compile Include="Program.cs" /> @@ -296,9 +338,6 @@ <Compile Include="Framework\CursorPosition.cs" /> </ItemGroup> <ItemGroup> - <None Include="packages.config"> - <SubType>Designer</SubType> - </None> <Content Include="StardewModdingAPI.config.json"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> @@ -341,11 +380,4 @@ <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> <Import Project="..\..\build\common.targets" /> - <Import Project="..\packages\LargeAddressAware.1.0.3\build\LargeAddressAware.targets" Condition="Exists('..\packages\LargeAddressAware.1.0.3\build\LargeAddressAware.targets')" /> - <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> - <PropertyGroup> - <ErrorText>This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}.</ErrorText> - </PropertyGroup> - <Error Condition="!Exists('..\packages\LargeAddressAware.1.0.3\build\LargeAddressAware.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\LargeAddressAware.1.0.3\build\LargeAddressAware.targets'))" /> - </Target> </Project>
\ No newline at end of file diff --git a/src/SMAPI/packages.config b/src/SMAPI/packages.config deleted file mode 100644 index 84c6bed0..00000000 --- a/src/SMAPI/packages.config +++ /dev/null @@ -1,6 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<packages> - <package id="LargeAddressAware" version="1.0.3" targetFramework="net45" /> - <package id="Mono.Cecil" version="0.10.0" targetFramework="net45" /> - <package id="Newtonsoft.Json" version="11.0.2" targetFramework="net45" /> -</packages>
\ No newline at end of file |