diff options
Diffstat (limited to 'src/SMAPI/Framework')
40 files changed, 1654 insertions, 583 deletions
diff --git a/src/SMAPI/Framework/Content/ContentCache.cs b/src/SMAPI/Framework/Content/ContentCache.cs index 4508e641..533da398 100644 --- a/src/SMAPI/Framework/Content/ContentCache.cs +++ b/src/SMAPI/Framework/Content/ContentCache.cs @@ -5,6 +5,7 @@ using System.Linq; using Microsoft.Xna.Framework; using StardewModdingAPI.Framework.ModLoading; using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Framework.Utilities; using StardewValley; namespace StardewModdingAPI.Framework.Content @@ -18,12 +19,6 @@ namespace StardewModdingAPI.Framework.Content /// <summary>The underlying asset cache.</summary> private readonly IDictionary<string, object> Cache; - /// <summary>The possible directory separator characters in an asset key.</summary> - private readonly char[] PossiblePathSeparators; - - /// <summary>The preferred directory separator chaeacter in an asset key.</summary> - private readonly string PreferredPathSeparator; - /// <summary>Applies platform-specific asset key normalisation so it's consistent with the underlying cache.</summary> private readonly Func<string, string> NormaliseAssetNameForPlatform; @@ -52,14 +47,10 @@ namespace StardewModdingAPI.Framework.Content /// <summary>Construct an instance.</summary> /// <param name="contentManager">The underlying content manager whose cache to manage.</param> /// <param name="reflection">Simplifies access to private game code.</param> - /// <param name="possiblePathSeparators">The possible directory separator characters in an asset key.</param> - /// <param name="preferredPathSeparator">The preferred directory separator chaeacter in an asset key.</param> - public ContentCache(LocalizedContentManager contentManager, Reflector reflection, char[] possiblePathSeparators, string preferredPathSeparator) + public ContentCache(LocalizedContentManager contentManager, Reflector reflection) { // init this.Cache = reflection.GetField<Dictionary<string, object>>(contentManager, "loadedAssets").GetValue(); - this.PossiblePathSeparators = possiblePathSeparators; - this.PreferredPathSeparator = preferredPathSeparator; // get key normalisation logic if (Constants.TargetPlatform == Platform.Windows) @@ -90,11 +81,7 @@ namespace StardewModdingAPI.Framework.Content [Pure] public string NormalisePathSeparators(string path) { - string[] parts = path.Split(this.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries); - string normalised = string.Join(this.PreferredPathSeparator, parts); - if (path.StartsWith(this.PreferredPathSeparator)) - normalised = this.PreferredPathSeparator + normalised; // keep root slash - return normalised; + return PathUtilities.NormalisePathSeparators(path); } /// <summary>Normalise a cache key so it's consistent with the underlying cache.</summary> diff --git a/src/SMAPI/Framework/ContentPack.cs b/src/SMAPI/Framework/ContentPack.cs new file mode 100644 index 00000000..071fb872 --- /dev/null +++ b/src/SMAPI/Framework/ContentPack.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework.Serialisation; +using StardewModdingAPI.Framework.Utilities; +using xTile; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Manages access to a content pack's metadata and files.</summary> + internal class ContentPack : IContentPack + { + /********* + ** Properties + *********/ + /// <summary>Provides an API for loading content assets.</summary> + private readonly IContentHelper Content; + + /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> + private readonly JsonHelper JsonHelper; + + + /********* + ** Accessors + *********/ + /// <summary>The full path to the content pack's folder.</summary> + public string DirectoryPath { get; } + + /// <summary>The content pack's manifest.</summary> + public IManifest Manifest { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="directoryPath">The full path to the content pack's folder.</param> + /// <param name="manifest">The content pack's manifest.</param> + /// <param name="content">Provides an API for loading content assets.</param> + /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> + public ContentPack(string directoryPath, IManifest manifest, IContentHelper content, JsonHelper jsonHelper) + { + this.DirectoryPath = directoryPath; + this.Manifest = manifest; + this.Content = content; + this.JsonHelper = jsonHelper; + } + + /// <summary>Read a JSON file from the content pack folder.</summary> + /// <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> + public TModel ReadJsonFile<TModel>(string path) where TModel : class + { + path = Path.Combine(this.DirectoryPath, PathUtilities.NormalisePathSeparators(path)); + return this.JsonHelper.ReadJsonFile<TModel>(path); + } + + /// <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> + /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> + /// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception> + public T LoadAsset<T>(string key) + { + return this.Content.Load<T>(key, ContentSource.ModFolder); + } + + /// <summary>Get the underlying key in the game's content cache for an asset. This can be used to load custom map tilesheets, but should be avoided when you can use the content API instead. This does not validate whether the asset exists.</summary> + /// <param name="key">The the local path to a content file relative to the content pack folder.</param> + /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> + public string GetActualAssetKey(string key) + { + return this.Content.GetActualAssetKey(key, ContentSource.ModFolder); + } + + } +} diff --git a/src/SMAPI/Framework/Events/EventManager.cs b/src/SMAPI/Framework/Events/EventManager.cs new file mode 100644 index 00000000..d7c89a76 --- /dev/null +++ b/src/SMAPI/Framework/Events/EventManager.cs @@ -0,0 +1,249 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework.Input; +using StardewModdingAPI.Events; + +namespace StardewModdingAPI.Framework.Events +{ + /// <summary>Manages SMAPI events.</summary> + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Private fields are deliberately named to simplify organisation.")] + internal class EventManager + { + /********* + ** Properties + *********/ + /**** + ** ContentEvents + ****/ + /// <summary>Raised after the content language changes.</summary> + public readonly ManagedEvent<EventArgsValueChanged<string>> Content_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> Control_KeyboardChanged; + + /// <summary>Raised when the player presses a keyboard key.</summary> + public readonly ManagedEvent<EventArgsKeyPressed> Control_KeyPressed; + + /// <summary>Raised when the player releases a keyboard key.</summary> + public readonly ManagedEvent<EventArgsKeyPressed> Control_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> Control_MouseChanged; + + /// <summary>The player pressed a controller button. This event isn't raised for trigger buttons.</summary> + public readonly ManagedEvent<EventArgsControllerButtonPressed> Control_ControllerButtonPressed; + + /// <summary>The player released a controller button. This event isn't raised for trigger buttons.</summary> + public readonly ManagedEvent<EventArgsControllerButtonReleased> Control_ControllerButtonReleased; + + /// <summary>The player pressed a controller trigger button.</summary> + public readonly ManagedEvent<EventArgsControllerTriggerPressed> Control_ControllerTriggerPressed; + + /// <summary>The player released a controller trigger button.</summary> + public readonly ManagedEvent<EventArgsControllerTriggerReleased> Control_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; + + /// <summary>Raised when the game updates its state (≈60 times per second).</summary> + public readonly ManagedEvent Game_UpdateTick; + + /// <summary>Raised every other tick (≈30 times per second).</summary> + public readonly ManagedEvent Game_SecondUpdateTick; + + /// <summary>Raised every fourth tick (≈15 times per second).</summary> + public readonly ManagedEvent Game_FourthUpdateTick; + + /// <summary>Raised every eighth tick (≈8 times per second).</summary> + public readonly ManagedEvent Game_EighthUpdateTick; + + /// <summary>Raised every 15th tick (≈4 times per second).</summary> + public readonly ManagedEvent Game_QuarterSecondTick; + + /// <summary>Raised every 30th tick (≈twice per second).</summary> + public readonly ManagedEvent Game_HalfSecondTick; + + /// <summary>Raised every 60th tick (≈once per second).</summary> + public readonly ManagedEvent Game_OneSecondTick; + + /**** + ** GraphicsEvents + ****/ + /// <summary>Raised after the game window is resized.</summary> + public readonly ManagedEvent Graphics_Resize; + + /// <summary>Raised before drawing the world to the screen.</summary> + public readonly ManagedEvent Graphics_OnPreRenderEvent; + + /// <summary>Raised after drawing the world to the screen.</summary> + public readonly ManagedEvent Graphics_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; + + /// <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; + + /// <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; + + /// <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; + + /**** + ** InputEvents + ****/ + /// <summary>Raised when the player presses a button on the keyboard, controller, or mouse.</summary> + public readonly ManagedEvent<EventArgsInput> Input_ButtonPressed; + + /// <summary>Raised when the player releases a keyboard key on the keyboard, controller, or mouse.</summary> + public readonly ManagedEvent<EventArgsInput> Input_ButtonReleased; + + /**** + ** LocationEvents + ****/ + /// <summary>Raised after the player warps to a new location.</summary> + public readonly ManagedEvent<EventArgsCurrentLocationChanged> Location_CurrentLocationChanged; + + /// <summary>Raised after a game location is added or removed.</summary> + public readonly ManagedEvent<EventArgsGameLocationsChanged> Location_LocationsChanged; + + /// <summary>Raised after the list of objects in the current location changes (e.g. an object is added or removed).</summary> + public readonly ManagedEvent<EventArgsLocationObjectsChanged> Location_LocationObjectsChanged; + + /**** + ** 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; + + /// <summary>Raised after a game menu is closed.</summary> + public readonly ManagedEvent<EventArgsClickableMenuClosed> Menu_Closed; + + /**** + ** MineEvents + ****/ + /// <summary>Raised after the player warps to a new level of the mine.</summary> + public readonly ManagedEvent<EventArgsMineLevelChanged> Mine_LevelChanged; + + /**** + ** 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; + + /// <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; + + /**** + ** SaveEvents + ****/ + /// <summary>Raised before the game creates the save file.</summary> + public readonly ManagedEvent Save_BeforeCreate; + + /// <summary>Raised after the game finishes creating the save file.</summary> + public readonly ManagedEvent Save_AfterCreate; + + /// <summary>Raised before the game begins writes data to the save file.</summary> + public readonly ManagedEvent Save_BeforeSave; + + /// <summary>Raised after the game finishes writing data to the save file.</summary> + public readonly ManagedEvent Save_AfterSave; + + /// <summary>Raised after the player loads a save slot.</summary> + public readonly ManagedEvent Save_AfterLoad; + + /// <summary>Raised after the game returns to the title screen.</summary> + public readonly ManagedEvent Save_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; + + /**** + ** TimeEvents + ****/ + /// <summary>Raised after the game begins a new day, including when loading a save.</summary> + public readonly ManagedEvent Time_AfterDayStarted; + + /// <summary>Raised after the in-game clock changes.</summary> + public readonly ManagedEvent<EventArgsIntChanged> Time_TimeOfDayChanged; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="monitor">Writes messages to the log.</param> + /// <param name="modRegistry">The mod registry with which to identify mods.</param> + public EventManager(IMonitor monitor, ModRegistry modRegistry) + { + // create shortcut initialisers + ManagedEvent<TEventArgs> ManageEventOf<TEventArgs>(string typeName, string eventName) => new ManagedEvent<TEventArgs>($"{typeName}.{eventName}", monitor, modRegistry); + ManagedEvent ManageEvent(string typeName, string eventName) => new ManagedEvent($"{typeName}.{eventName}", monitor, modRegistry); + + // init events + this.Content_LocaleChanged = ManageEventOf<EventArgsValueChanged<string>>(nameof(ContentEvents), nameof(ContentEvents.AfterLocaleChanged)); + + this.Control_ControllerButtonPressed = ManageEventOf<EventArgsControllerButtonPressed>(nameof(ControlEvents), nameof(ControlEvents.ControllerButtonPressed)); + this.Control_ControllerButtonReleased = ManageEventOf<EventArgsControllerButtonReleased>(nameof(ControlEvents), nameof(ControlEvents.ControllerButtonReleased)); + this.Control_ControllerTriggerPressed = ManageEventOf<EventArgsControllerTriggerPressed>(nameof(ControlEvents), nameof(ControlEvents.ControllerTriggerPressed)); + this.Control_ControllerTriggerReleased = ManageEventOf<EventArgsControllerTriggerReleased>(nameof(ControlEvents), nameof(ControlEvents.ControllerTriggerReleased)); + this.Control_KeyboardChanged = ManageEventOf<EventArgsKeyboardStateChanged>(nameof(ControlEvents), nameof(ControlEvents.KeyboardChanged)); + this.Control_KeyPressed = ManageEventOf<EventArgsKeyPressed>(nameof(ControlEvents), nameof(ControlEvents.KeyPressed)); + this.Control_KeyReleased = ManageEventOf<EventArgsKeyPressed>(nameof(ControlEvents), nameof(ControlEvents.KeyReleased)); + this.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.Input_ButtonPressed = ManageEventOf<EventArgsInput>(nameof(InputEvents), nameof(InputEvents.ButtonPressed)); + this.Input_ButtonReleased = ManageEventOf<EventArgsInput>(nameof(InputEvents), nameof(InputEvents.ButtonReleased)); + + this.Location_CurrentLocationChanged = ManageEventOf<EventArgsCurrentLocationChanged>(nameof(LocationEvents), nameof(LocationEvents.CurrentLocationChanged)); + this.Location_LocationsChanged = ManageEventOf<EventArgsGameLocationsChanged>(nameof(LocationEvents), nameof(LocationEvents.LocationsChanged)); + this.Location_LocationObjectsChanged = ManageEventOf<EventArgsLocationObjectsChanged>(nameof(LocationEvents), nameof(LocationEvents.LocationObjectsChanged)); + + this.Menu_Changed = ManageEventOf<EventArgsClickableMenuChanged>(nameof(MenuEvents), nameof(MenuEvents.MenuChanged)); + this.Menu_Closed = ManageEventOf<EventArgsClickableMenuClosed>(nameof(MenuEvents), nameof(MenuEvents.MenuClosed)); + + 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.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)); + } + } +} diff --git a/src/SMAPI/Framework/Events/ManagedEvent.cs b/src/SMAPI/Framework/Events/ManagedEvent.cs new file mode 100644 index 00000000..e54a4fd3 --- /dev/null +++ b/src/SMAPI/Framework/Events/ManagedEvent.cs @@ -0,0 +1,119 @@ +using System; +using System.Linq; + +namespace StardewModdingAPI.Framework.Events +{ + /// <summary>An event wrapper which intercepts and logs errors in handler code.</summary> + /// <typeparam name="TEventArgs">The event arguments type.</typeparam> + internal class ManagedEvent<TEventArgs> : ManagedEventBase<EventHandler<TEventArgs>> + { + /********* + ** Properties + *********/ + /// <summary>The underlying event.</summary> + private event EventHandler<TEventArgs> Event; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="eventName">A human-readable name for the event.</param> + /// <param name="monitor">Writes messages to the log.</param> + /// <param name="modRegistry">The mod registry with which to identify mods.</param> + public ManagedEvent(string eventName, IMonitor monitor, ModRegistry modRegistry) + : base(eventName, monitor, modRegistry) { } + + /// <summary>Add an event handler.</summary> + /// <param name="handler">The event handler.</param> + public void Add(EventHandler<TEventArgs> handler) + { + this.Event += handler; + this.AddTracking(handler, this.Event?.GetInvocationList().Cast<EventHandler<TEventArgs>>()); + } + + /// <summary>Remove an event handler.</summary> + /// <param name="handler">The event handler.</param> + public void Remove(EventHandler<TEventArgs> handler) + { + this.Event -= handler; + this.RemoveTracking(handler, this.Event?.GetInvocationList().Cast<EventHandler<TEventArgs>>()); + } + + /// <summary>Raise the event and notify all handlers.</summary> + /// <param name="args">The event arguments to pass.</param> + public void Raise(TEventArgs args) + { + if (this.Event == null) + return; + + foreach (EventHandler<TEventArgs> handler in this.CachedInvocationList) + { + 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> + internal class ManagedEvent : ManagedEventBase<EventHandler> + { + /********* + ** Properties + *********/ + /// <summary>The underlying event.</summary> + private event EventHandler Event; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="eventName">A human-readable name for the event.</param> + /// <param name="monitor">Writes messages to the log.</param> + /// <param name="modRegistry">The mod registry with which to identify mods.</param> + public ManagedEvent(string eventName, IMonitor monitor, ModRegistry modRegistry) + : base(eventName, monitor, modRegistry) { } + + /// <summary>Add an event handler.</summary> + /// <param name="handler">The event handler.</param> + public void Add(EventHandler handler) + { + this.Event += handler; + this.AddTracking(handler, this.Event?.GetInvocationList().Cast<EventHandler>()); + } + + /// <summary>Remove an event handler.</summary> + /// <param name="handler">The event handler.</param> + public void Remove(EventHandler handler) + { + this.Event -= handler; + this.RemoveTracking(handler, this.Event?.GetInvocationList().Cast<EventHandler>()); + } + + /// <summary>Raise the event and notify all handlers.</summary> + public void Raise() + { + if (this.Event == null) + return; + + foreach (EventHandler handler in this.CachedInvocationList) + { + try + { + handler.Invoke(null, EventArgs.Empty); + } + catch (Exception ex) + { + this.LogError(handler, ex); + } + } + } + } +} diff --git a/src/SMAPI/Framework/Events/ManagedEventBase.cs b/src/SMAPI/Framework/Events/ManagedEventBase.cs new file mode 100644 index 00000000..cc4d89ec --- /dev/null +++ b/src/SMAPI/Framework/Events/ManagedEventBase.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace StardewModdingAPI.Framework.Events +{ + /// <summary>The base implementation for an event wrapper which intercepts and logs errors in handler code.</summary> + internal abstract class ManagedEventBase<TEventHandler> + { + /********* + ** Properties + *********/ + /// <summary>A human-readable name for the event.</summary> + private readonly string EventName; + + /// <summary>Writes messages to the log.</summary> + private readonly IMonitor Monitor; + + /// <summary>The mod registry with which to identify mods.</summary> + private readonly ModRegistry ModRegistry; + + /// <summary>The display names for the mods which added each delegate.</summary> + private readonly IDictionary<TEventHandler, IModMetadata> SourceMods = new Dictionary<TEventHandler, IModMetadata>(); + + /// <summary>The cached invocation list.</summary> + protected TEventHandler[] CachedInvocationList { get; private set; } + + + /********* + ** Public methods + *********/ + /// <summary>Get whether anything is listening to the event.</summary> + public bool HasListeners() + { + return this.CachedInvocationList?.Length > 0; + } + + /********* + ** Protected methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="eventName">A human-readable name for the event.</param> + /// <param name="monitor">Writes messages to the log.</param> + /// <param name="modRegistry">The mod registry with which to identify mods.</param> + protected ManagedEventBase(string eventName, IMonitor monitor, ModRegistry modRegistry) + { + this.EventName = eventName; + this.Monitor = monitor; + this.ModRegistry = modRegistry; + } + + /// <summary>Track an event handler.</summary> + /// <param name="handler">The event handler.</param> + /// <param name="invocationList">The updated event invocation list.</param> + protected void AddTracking(TEventHandler handler, IEnumerable<TEventHandler> invocationList) + { + this.SourceMods[handler] = this.ModRegistry.GetFromStack(); + this.CachedInvocationList = invocationList.ToArray(); + } + + /// <summary>Remove tracking for an event handler.</summary> + /// <param name="handler">The event handler.</param> + /// <param name="invocationList">The updated event invocation list.</param> + protected void RemoveTracking(TEventHandler handler, IEnumerable<TEventHandler> invocationList) + { + this.SourceMods.Remove(handler); + this.CachedInvocationList = invocationList.ToArray(); + } + + /// <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)) + 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/IModMetadata.cs b/src/SMAPI/Framework/IModMetadata.cs index a36994fd..d1e8eb7d 100644 --- a/src/SMAPI/Framework/IModMetadata.cs +++ b/src/SMAPI/Framework/IModMetadata.cs @@ -1,4 +1,4 @@ -using StardewModdingAPI.Framework.Models; +using StardewModdingAPI.Framework.ModData; using StardewModdingAPI.Framework.ModLoading; namespace StardewModdingAPI.Framework @@ -18,8 +18,8 @@ namespace StardewModdingAPI.Framework /// <summary>The mod manifest.</summary> IManifest Manifest { get; } - /// <summary>>Metadata about the mod from SMAPI's internal data (if any).</summary> - ModDataRecord DataRecord { get; } + /// <summary>Metadata about the mod from SMAPI's internal data (if any).</summary> + ParsedModDataRecord DataRecord { get; } /// <summary>The metadata resolution status.</summary> ModMetadataStatus Status { get; } @@ -27,12 +27,21 @@ namespace StardewModdingAPI.Framework /// <summary>The reason the metadata is invalid, if any.</summary> string Error { get; } - /// <summary>The mod instance (if it was loaded).</summary> + /// <summary>The mod instance (if loaded and <see cref="IsContentPack"/> is false).</summary> IMod Mod { get; } + /// <summary>The content pack instance (if loaded and <see cref="IsContentPack"/> is true).</summary> + IContentPack ContentPack { get; } + + /// <summary>Writes messages to the console and log file as this mod.</summary> + IMonitor Monitor { get; } + /// <summary>The mod-provided API (if any).</summary> object Api { get; } + /// <summary>Whether the mod is a content pack.</summary> + bool IsContentPack { get; } + /********* ** Public methods @@ -47,6 +56,11 @@ namespace StardewModdingAPI.Framework /// <param name="mod">The mod instance to set.</param> IModMetadata SetMod(IMod mod); + /// <summary>Set the mod instance.</summary> + /// <param name="contentPack">The contentPack instance to set.</param> + /// <param name="monitor">Writes messages to the console and log file.</param> + IModMetadata SetMod(IContentPack contentPack, IMonitor monitor); + /// <summary>Set the mod-provided API instance.</summary> /// <param name="api">The mod-provided API.</param> IModMetadata SetApi(object api); diff --git a/src/SMAPI/Framework/InternalExtensions.cs b/src/SMAPI/Framework/InternalExtensions.cs index bec6c183..bff4807c 100644 --- a/src/SMAPI/Framework/InternalExtensions.cs +++ b/src/SMAPI/Framework/InternalExtensions.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Reflection; using Microsoft.Xna.Framework.Graphics; +using Newtonsoft.Json.Linq; using StardewModdingAPI.Framework.Reflection; using StardewValley; @@ -14,63 +14,6 @@ namespace StardewModdingAPI.Framework /**** ** IMonitor ****/ - /// <summary>Safely raise an <see cref="EventHandler"/> event, and intercept any exceptions thrown by its handlers.</summary> - /// <param name="monitor">Encapsulates monitoring and logging.</param> - /// <param name="name">The event name for error messages.</param> - /// <param name="handlers">The event handlers.</param> - /// <param name="sender">The event sender.</param> - /// <param name="args">The event arguments (or <c>null</c> to pass <see cref="EventArgs.Empty"/>).</param> - public static void SafelyRaisePlainEvent(this IMonitor monitor, string name, IEnumerable<Delegate> handlers, object sender = null, EventArgs args = null) - { - if (handlers == null) - return; - - foreach (EventHandler handler in handlers.Cast<EventHandler>()) - { - // handle SMAPI exiting - if (monitor.IsExiting) - { - monitor.Log($"SMAPI shutting down: aborting {name} event.", LogLevel.Warn); - return; - } - - // raise event - try - { - handler.Invoke(sender, args ?? EventArgs.Empty); - } - catch (Exception ex) - { - monitor.Log($"A mod failed handling the {name} event:\n{ex.GetLogSummary()}", LogLevel.Error); - } - } - } - - /// <summary>Safely raise an <see cref="EventHandler{TEventArgs}"/> event, and intercept any exceptions thrown by its handlers.</summary> - /// <typeparam name="TEventArgs">The event argument object type.</typeparam> - /// <param name="monitor">Encapsulates monitoring and logging.</param> - /// <param name="name">The event name for error messages.</param> - /// <param name="handlers">The event handlers.</param> - /// <param name="sender">The event sender.</param> - /// <param name="args">The event arguments.</param> - public static void SafelyRaiseGenericEvent<TEventArgs>(this IMonitor monitor, string name, IEnumerable<Delegate> handlers, object sender, TEventArgs args) - { - if (handlers == null) - return; - - foreach (EventHandler<TEventArgs> handler in handlers.Cast<EventHandler<TEventArgs>>()) - { - try - { - handler.Invoke(sender, args); - } - catch (Exception ex) - { - monitor.Log($"A mod failed handling the {name} event:\n{ex.GetLogSummary()}", LogLevel.Error); - } - } - } - /// <summary>Log a message for the player or developer the first time it occurs.</summary> /// <param name="monitor">The monitor through which to log the message.</param> /// <param name="hash">The hash of logged messages.</param> @@ -86,6 +29,18 @@ namespace StardewModdingAPI.Framework } /**** + ** IModMetadata + ****/ + /// <summary>Log a message using the mod's monitor.</summary> + /// <param name="metadata">The mod whose monitor to use.</param> + /// <param name="message">The message to log.</param> + /// <param name="level">The log severity level.</param> + public static void LogAsMod(this IModMetadata metadata, string message, LogLevel level = LogLevel.Trace) + { + metadata.Monitor.Log(message, level); + } + + /**** ** Exceptions ****/ /// <summary>Get a string representation of an exception suitable for writing to the error log.</summary> @@ -136,5 +91,20 @@ namespace StardewModdingAPI.Framework // get result return reflection.GetField<bool>(Game1.spriteBatch, fieldName).GetValue(); } + + /**** + ** Json.NET + ****/ + /// <summary>Get a JSON field value from a case-insensitive field name. This will check for an exact match first, then search without case sensitivity.</summary> + /// <typeparam name="T">The value type.</typeparam> + /// <param name="obj">The JSON object to search.</param> + /// <param name="fieldName">The field name.</param> + public static T ValueIgnoreCase<T>(this JObject obj, string fieldName) + { + JToken token = obj.GetValue(fieldName, StringComparison.InvariantCultureIgnoreCase); + return token != null + ? token.Value<T>() + : default(T); + } } } diff --git a/src/SMAPI/Framework/ModData/ModDataField.cs b/src/SMAPI/Framework/ModData/ModDataField.cs new file mode 100644 index 00000000..fa8dd6d0 --- /dev/null +++ b/src/SMAPI/Framework/ModData/ModDataField.cs @@ -0,0 +1,82 @@ +using System.Linq; + +namespace StardewModdingAPI.Framework.ModData +{ + /// <summary>A versioned mod metadata field.</summary> + internal class ModDataField + { + /********* + ** Accessors + *********/ + /// <summary>The field key.</summary> + public ModDataFieldKey Key { get; } + + /// <summary>The field value.</summary> + public string Value { get; } + + /// <summary>Whether this field should only be applied if it's not already set.</summary> + public bool IsDefault { get; } + + /// <summary>The lowest version in the range, or <c>null</c> for all past versions.</summary> + public ISemanticVersion LowerVersion { get; } + + /// <summary>The highest version in the range, or <c>null</c> for all future versions.</summary> + public ISemanticVersion UpperVersion { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="key">The field key.</param> + /// <param name="value">The field value.</param> + /// <param name="isDefault">Whether this field should only be applied if it's not already set.</param> + /// <param name="lowerVersion">The lowest version in the range, or <c>null</c> for all past versions.</param> + /// <param name="upperVersion">The highest version in the range, or <c>null</c> for all future versions.</param> + public ModDataField(ModDataFieldKey key, string value, bool isDefault, ISemanticVersion lowerVersion, ISemanticVersion upperVersion) + { + this.Key = key; + this.Value = value; + this.IsDefault = isDefault; + this.LowerVersion = lowerVersion; + this.UpperVersion = upperVersion; + } + + /// <summary>Get whether this data field applies for the given manifest.</summary> + /// <param name="manifest">The mod manifest.</param> + public bool IsMatch(IManifest manifest) + { + return + manifest?.Version != null // ignore invalid manifest + && (!this.IsDefault || !this.HasFieldValue(manifest, this.Key)) + && (this.LowerVersion == null || !manifest.Version.IsOlderThan(this.LowerVersion)) + && (this.UpperVersion == null || !manifest.Version.IsNewerThan(this.UpperVersion)); + } + + + /********* + ** Private methods + *********/ + /// <summary>Get whether a manifest field has a meaningful value for the purposes of enforcing <see cref="IsDefault"/>.</summary> + /// <param name="manifest">The mod manifest.</param> + /// <param name="key">The field key matching <see cref="ModDataFieldKey"/>.</param> + private bool HasFieldValue(IManifest manifest, ModDataFieldKey key) + { + switch (key) + { + // update key + case ModDataFieldKey.UpdateKey: + return manifest.UpdateKeys != null && manifest.UpdateKeys.Any(); + + // non-manifest fields + case ModDataFieldKey.AlternativeUrl: + case ModDataFieldKey.StatusReasonPhrase: + case ModDataFieldKey.Status: + return false; + + default: + return false; + } + } + } +} diff --git a/src/SMAPI/Framework/ModData/ModDataFieldKey.cs b/src/SMAPI/Framework/ModData/ModDataFieldKey.cs new file mode 100644 index 00000000..f68f575c --- /dev/null +++ b/src/SMAPI/Framework/ModData/ModDataFieldKey.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI.Framework.ModData +{ + /// <summary>The valid field keys.</summary> + public enum ModDataFieldKey + { + /// <summary>A manifest update key.</summary> + UpdateKey, + + /// <summary>An alternative URL the player can check for an updated version.</summary> + AlternativeUrl, + + /// <summary>The mod's predefined compatibility status.</summary> + Status, + + /// <summary>A reason phrase for the <see cref="Status"/>, or <c>null</c> to use the default reason.</summary> + StatusReasonPhrase + } +} diff --git a/src/SMAPI/Framework/ModData/ModDataRecord.cs b/src/SMAPI/Framework/ModData/ModDataRecord.cs new file mode 100644 index 00000000..79a954f7 --- /dev/null +++ b/src/SMAPI/Framework/ModData/ModDataRecord.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace StardewModdingAPI.Framework.ModData +{ + /// <summary>Raw mod metadata from SMAPI's internal mod list.</summary> + internal class ModDataRecord + { + /********* + ** Properties + *********/ + /// <summary>This field stores properties that aren't mapped to another field before they're parsed into <see cref="Fields"/>.</summary> + [JsonExtensionData] + private IDictionary<string, JToken> ExtensionData; + + + /********* + ** Accessors + *********/ + /// <summary>The mod's current unique ID.</summary> + public string ID { get; set; } + + /// <summary>The former mod IDs (if any).</summary> + /// <remarks> + /// This uses a custom format which uniquely identifies a mod across multiple versions and + /// supports matching other fields if no ID was specified. This doesn't include the latest + /// ID, if any. Format rules: + /// 1. If the mod's ID changed over time, multiple variants can be separated by the + /// <c>|</c> character. + /// 2. Each variant can take one of two forms: + /// - A simple string matching the mod's UniqueID value. + /// - A JSON structure containing any of four manifest fields (ID, Name, Author, and + /// EntryDll) to match. + /// </remarks> + public string FormerIDs { get; set; } + + /// <summary>Maps local versions to a semantic version for update checks.</summary> + public IDictionary<string, string> MapLocalVersions { get; set; } = new Dictionary<string, string>(); + + /// <summary>Maps remote versions to a semantic version for update checks.</summary> + public IDictionary<string, string> MapRemoteVersions { get; set; } = new Dictionary<string, string>(); + + /// <summary>The versioned field data.</summary> + /// <remarks> + /// This maps field names to values. This should be accessed via <see cref="GetFields"/>. + /// Format notes: + /// - Each key consists of a field name prefixed with any combination of version range + /// and <c>Default</c>, separated by pipes (whitespace trimmed). For example, <c>Name</c> + /// will always override the name, <c>Default | Name</c> will only override a blank + /// name, and <c>~1.1 | Default | Name</c> will override blank names up to version 1.1. + /// - The version format is <c>min~max</c> (where either side can be blank for unbounded), or + /// a single version number. + /// - The field name itself corresponds to a <see cref="ModDataFieldKey"/> value. + /// </remarks> + public IDictionary<string, string> Fields { get; set; } = new Dictionary<string, string>(); + + + /********* + ** Public methods + *********/ + /// <summary>Get a parsed representation of the <see cref="Fields"/>.</summary> + public IEnumerable<ModDataField> GetFields() + { + foreach (KeyValuePair<string, string> pair in this.Fields) + { + // init fields + string packedKey = pair.Key; + string value = pair.Value; + bool isDefault = false; + ISemanticVersion lowerVersion = null; + ISemanticVersion upperVersion = null; + + // parse + string[] parts = packedKey.Split('|').Select(p => p.Trim()).ToArray(); + ModDataFieldKey fieldKey = (ModDataFieldKey)Enum.Parse(typeof(ModDataFieldKey), parts.Last(), ignoreCase: true); + foreach (string part in parts.Take(parts.Length - 1)) + { + // 'default' + if (part.Equals("Default", StringComparison.InvariantCultureIgnoreCase)) + { + isDefault = true; + continue; + } + + // version range + if (part.Contains("~")) + { + string[] versionParts = part.Split(new[] { '~' }, 2); + lowerVersion = versionParts[0] != "" ? new SemanticVersion(versionParts[0]) : null; + upperVersion = versionParts[1] != "" ? new SemanticVersion(versionParts[1]) : null; + continue; + } + + // single version + lowerVersion = new SemanticVersion(part); + upperVersion = new SemanticVersion(part); + } + + yield return new ModDataField(fieldKey, value, isDefault, lowerVersion, upperVersion); + } + } + + /// <summary>Get a semantic local version for update checks.</summary> + /// <param name="version">The remote version to normalise.</param> + public string GetLocalVersionForUpdateChecks(string version) + { + return this.MapLocalVersions != null && this.MapLocalVersions.TryGetValue(version, out string newVersion) + ? newVersion + : version; + } + + /// <summary>Get a semantic remote version for update checks.</summary> + /// <param name="version">The remote version to normalise.</param> + public string GetRemoteVersionForUpdateChecks(string version) + { + return this.MapRemoteVersions != null && this.MapRemoteVersions.TryGetValue(version, out string newVersion) + ? newVersion + : version; + } + + + /********* + ** Private methods + *********/ + /// <summary>The method invoked after JSON deserialisation.</summary> + /// <param name="context">The deserialisation context.</param> + [OnDeserialized] + private void OnDeserialized(StreamingContext context) + { + if (this.ExtensionData != null) + { + this.Fields = this.ExtensionData.ToDictionary(p => p.Key, p => p.Value.ToString()); + this.ExtensionData = null; + } + } + } +} diff --git a/src/SMAPI/Framework/ModData/ModDatabase.cs b/src/SMAPI/Framework/ModData/ModDatabase.cs new file mode 100644 index 00000000..332c5c48 --- /dev/null +++ b/src/SMAPI/Framework/ModData/ModDatabase.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Newtonsoft.Json; + +namespace StardewModdingAPI.Framework.ModData +{ + /// <summary>Handles access to SMAPI's internal mod metadata list.</summary> + internal class ModDatabase + { + /********* + ** Properties + *********/ + /// <summary>The underlying mod data records indexed by default display name.</summary> + private readonly IDictionary<string, ModDataRecord> Records; + + /// <summary>Get an update URL for an update key (if valid).</summary> + private readonly Func<string, string> GetUpdateUrl; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an empty instance.</summary> + public ModDatabase() + : this(new Dictionary<string, ModDataRecord>(), key => null) { } + + /// <summary>Construct an instance.</summary> + /// <param name="records">The underlying mod data records indexed by default display name.</param> + /// <param name="getUpdateUrl">Get an update URL for an update key (if valid).</param> + public ModDatabase(IDictionary<string, ModDataRecord> records, Func<string, string> getUpdateUrl) + { + this.Records = records; + this.GetUpdateUrl = getUpdateUrl; + } + + /// <summary>Get a parsed representation of the <see cref="ModDataRecord.Fields"/> which match a given manifest.</summary> + /// <param name="manifest">The manifest to match.</param> + public ParsedModDataRecord GetParsed(IManifest manifest) + { + // get raw record + if (!this.TryGetRaw(manifest, out string displayName, out ModDataRecord record)) + return null; + + // parse fields + ParsedModDataRecord parsed = new ParsedModDataRecord { DisplayName = displayName, DataRecord = record }; + foreach (ModDataField field in record.GetFields().Where(field => field.IsMatch(manifest))) + { + switch (field.Key) + { + // update key + case ModDataFieldKey.UpdateKey: + parsed.UpdateKey = field.Value; + break; + + // alternative URL + case ModDataFieldKey.AlternativeUrl: + parsed.AlternativeUrl = field.Value; + break; + + // status + case ModDataFieldKey.Status: + parsed.Status = (ModStatus)Enum.Parse(typeof(ModStatus), field.Value, ignoreCase: true); + parsed.StatusUpperVersion = field.UpperVersion; + break; + + // status reason phrase + case ModDataFieldKey.StatusReasonPhrase: + parsed.StatusReasonPhrase = field.Value; + break; + } + } + + return parsed; + } + + /// <summary>Get the display name for a given mod ID (if available).</summary> + /// <param name="id">The unique mod ID.</param> + public string GetDisplayNameFor(string id) + { + return this.TryGetRaw(id, out string displayName, out ModDataRecord _) + ? displayName + : null; + } + + /// <summary>Get the mod page URL for a mod (if available).</summary> + /// <param name="id">The unique mod ID.</param> + public string GetModPageUrlFor(string id) + { + // get raw record + if (!this.TryGetRaw(id, out string _, out ModDataRecord record)) + return null; + + // get update key + ModDataField updateKeyField = record.GetFields().FirstOrDefault(p => p.Key == ModDataFieldKey.UpdateKey); + if (updateKeyField == null) + return null; + + // get update URL + return this.GetUpdateUrl(updateKeyField.Value); + } + + + /********* + ** Private models + *********/ + /// <summary>Get a raw data record.</summary> + /// <param name="id">The mod ID to match.</param> + /// <param name="displayName">The mod's default display name.</param> + /// <param name="record">The raw mod record.</param> + private bool TryGetRaw(string id, out string displayName, out ModDataRecord record) + { + foreach (var entry in this.Records) + { + if (entry.Value.ID != null && entry.Value.ID.Equals(id, StringComparison.InvariantCultureIgnoreCase)) + { + displayName = entry.Key; + record = entry.Value; + return true; + } + } + + displayName = null; + record = null; + return false; + } + /// <summary>Get a raw data record.</summary> + /// <param name="manifest">The mod manifest whose fields to match.</param> + /// <param name="displayName">The mod's default display name.</param> + /// <param name="record">The raw mod record.</param> + private bool TryGetRaw(IManifest manifest, out string displayName, out ModDataRecord record) + { + if (manifest != null) + { + foreach (var entry in this.Records) + { + displayName = entry.Key; + record = entry.Value; + + // try main ID + if (record.ID != null && record.ID.Equals(manifest.UniqueID, StringComparison.InvariantCultureIgnoreCase)) + return true; + + // try former IDs + if (record.FormerIDs != null) + { + foreach (string part in record.FormerIDs.Split('|')) + { + // packed field snapshot + if (part.StartsWith("{")) + { + FieldSnapshot snapshot = JsonConvert.DeserializeObject<FieldSnapshot>(part); + bool isMatch = + (snapshot.ID == null || snapshot.ID.Equals(manifest.UniqueID, StringComparison.InvariantCultureIgnoreCase)) + && (snapshot.EntryDll == null || snapshot.EntryDll.Equals(manifest.EntryDll, StringComparison.InvariantCultureIgnoreCase)) + && ( + snapshot.Author == null + || snapshot.Author.Equals(manifest.Author, StringComparison.InvariantCultureIgnoreCase) + || (manifest.ExtraFields.ContainsKey("Authour") && snapshot.Author.Equals(manifest.ExtraFields["Authour"].ToString(), StringComparison.InvariantCultureIgnoreCase)) + ) + && (snapshot.Name == null || snapshot.Name.Equals(manifest.Name, StringComparison.InvariantCultureIgnoreCase)); + + if (isMatch) + return true; + } + + // plain ID + else if (part.Equals(manifest.UniqueID, StringComparison.InvariantCultureIgnoreCase)) + return true; + } + } + } + } + + displayName = null; + record = null; + return false; + } + + + /********* + ** Private models + *********/ + /// <summary>A unique set of fields which identifies the mod.</summary> + [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local", Justification = "Used via JSON deserialisation.")] + [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local", Justification = "Used via JSON deserialisation.")] + private class FieldSnapshot + { + /********* + ** Accessors + *********/ + /// <summary>The unique mod ID (or <c>null</c> to ignore it).</summary> + public string ID { get; set; } + + /// <summary>The entry DLL (or <c>null</c> to ignore it).</summary> + public string EntryDll { get; set; } + + /// <summary>The mod name (or <c>null</c> to ignore it).</summary> + public string Name { get; set; } + + /// <summary>The author name (or <c>null</c> to ignore it).</summary> + public string Author { get; set; } + } + } +} diff --git a/src/SMAPI/Framework/Models/ModStatus.cs b/src/SMAPI/Framework/ModData/ModStatus.cs index 343ccb7e..0e1d94d4 100644 --- a/src/SMAPI/Framework/Models/ModStatus.cs +++ b/src/SMAPI/Framework/ModData/ModStatus.cs @@ -1,4 +1,4 @@ -namespace StardewModdingAPI.Framework.Models +namespace StardewModdingAPI.Framework.ModData { /// <summary>Indicates how SMAPI should treat a mod.</summary> internal enum ModStatus diff --git a/src/SMAPI/Framework/ModData/ParsedModDataRecord.cs b/src/SMAPI/Framework/ModData/ParsedModDataRecord.cs new file mode 100644 index 00000000..7f49790d --- /dev/null +++ b/src/SMAPI/Framework/ModData/ParsedModDataRecord.cs @@ -0,0 +1,48 @@ +namespace StardewModdingAPI.Framework.ModData +{ + /// <summary>A parsed representation of the fields from a <see cref="ModDataRecord"/> for a specific manifest.</summary> + internal class ParsedModDataRecord + { + /********* + ** Accessors + *********/ + /// <summary>The underlying data record.</summary> + public ModDataRecord DataRecord { get; set; } + + /// <summary>The default mod name to display when the name isn't available (e.g. during dependency checks).</summary> + public string DisplayName { get; set; } + + /// <summary>The update key to apply.</summary> + public string UpdateKey { get; set; } + + /// <summary>The alternative URL the player can check for an updated version.</summary> + public string AlternativeUrl { get; set; } + + /// <summary>The predefined compatibility status.</summary> + public ModStatus Status { get; set; } = ModStatus.None; + + /// <summary>A reason phrase for the <see cref="Status"/>, or <c>null</c> to use the default reason.</summary> + public string StatusReasonPhrase { get; set; } + + /// <summary>The upper version for which the <see cref="Status"/> applies (if any).</summary> + public ISemanticVersion StatusUpperVersion { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Get a semantic local version for update checks.</summary> + /// <param name="version">The remote version to normalise.</param> + public string GetLocalVersionForUpdateChecks(string version) + { + return this.DataRecord.GetLocalVersionForUpdateChecks(version); + } + + /// <summary>Get a semantic remote version for update checks.</summary> + /// <param name="version">The remote version to normalise.</param> + public string GetRemoteVersionForUpdateChecks(string version) + { + return this.DataRecord.GetRemoteVersionForUpdateChecks(version); + } + } +} diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs index 4a1d3853..7d8bec1e 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs @@ -8,6 +8,7 @@ using System.Linq; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.Exceptions; +using StardewModdingAPI.Framework.Utilities; using StardewValley; using xTile; using xTile.Format; @@ -238,7 +239,7 @@ namespace StardewModdingAPI.Framework.ModHelpers string imageSource = tilesheet.ImageSource; // validate tilesheet path - if (Path.IsPathRooted(imageSource) || imageSource.Split(SContentManager.PossiblePathSeparators).Contains("..")) + if (Path.IsPathRooted(imageSource) || PathUtilities.GetSegments(imageSource).Contains("..")) throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded. Tilesheet paths must be a relative path without directory climbing (../)."); // get seasonal name (if applicable) diff --git a/src/SMAPI/Framework/ModHelpers/ModHelper.cs b/src/SMAPI/Framework/ModHelpers/ModHelper.cs index 665b9cf4..b5758d21 100644 --- a/src/SMAPI/Framework/ModHelpers/ModHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModHelper.cs @@ -1,6 +1,10 @@ -using System; +using System; +using System.Collections.Generic; using System.IO; +using System.Linq; +using StardewModdingAPI.Framework.Models; using StardewModdingAPI.Framework.Serialisation; +using StardewModdingAPI.Framework.Utilities; namespace StardewModdingAPI.Framework.ModHelpers { @@ -13,6 +17,15 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <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; + + /// <summary>Create a transitional content pack.</summary> + private readonly Func<string, IManifest, IContentPack> CreateContentPack; + + /// <summary>Manages deprecation warnings.</summary> + private readonly DeprecationManager DeprecationManager; + /********* ** Accessors @@ -48,9 +61,12 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <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="translationHelper">An API for reading translations stored in the mod's <c>i18n</c> folder.</param> + /// <param name="contentPacks">The content packs loaded for this mod.</param> + /// <param name="createContentPack">Create a transitional content pack.</param> + /// <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, IContentHelper contentHelper, ICommandHelper commandHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, ITranslationHelper translationHelper) + public ModHelper(string modID, string modDirectory, JsonHelper jsonHelper, IContentHelper contentHelper, ICommandHelper commandHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, ITranslationHelper translationHelper, IEnumerable<IContentPack> contentPacks, Func<string, IManifest, IContentPack> createContentPack, DeprecationManager deprecationManager) : base(modID) { // validate directory @@ -67,6 +83,9 @@ namespace StardewModdingAPI.Framework.ModHelpers this.ConsoleCommands = commandHelper ?? throw new ArgumentNullException(nameof(commandHelper)); this.Reflection = reflectionHelper ?? throw new ArgumentNullException(nameof(reflectionHelper)); this.Translation = translationHelper ?? throw new ArgumentNullException(nameof(translationHelper)); + this.ContentPacks = contentPacks.ToArray(); + this.CreateContentPack = createContentPack; + this.DeprecationManager = deprecationManager; } /**** @@ -101,7 +120,7 @@ namespace StardewModdingAPI.Framework.ModHelpers public TModel ReadJsonFile<TModel>(string path) where TModel : class { - path = Path.Combine(this.DirectoryPath, path); + path = Path.Combine(this.DirectoryPath, PathUtilities.NormalisePathSeparators(path)); return this.JsonHelper.ReadJsonFile<TModel>(path); } @@ -112,10 +131,57 @@ namespace StardewModdingAPI.Framework.ModHelpers public void WriteJsonFile<TModel>(string path, TModel model) where TModel : class { - path = Path.Combine(this.DirectoryPath, path); + path = Path.Combine(this.DirectoryPath, PathUtilities.NormalisePathSeparators(path)); this.JsonHelper.WriteJsonFile(path, model); } + /**** + ** Content packs + ****/ + /// <summary>Manually create a transitional content pack to support pre-SMAPI content packs. This provides a way to access legacy content packs using the SMAPI content pack APIs, but the content pack will not be visible in the log or validated by SMAPI.</summary> + /// <param name="directoryPath">The absolute directory path containing the content pack files.</param> + /// <param name="id">The content pack's unique ID.</param> + /// <param name="name">The content pack name.</param> + /// <param name="description">The content pack description.</param> + /// <param name="author">The content pack author's name.</param> + /// <param name="version">The content pack version.</param> + [Obsolete("This method supports mods which previously had their own content packs, and shouldn't be used by new mods. It will be removed in SMAPI 3.0.")] + public IContentPack CreateTransitionalContentPack(string directoryPath, string id, string name, string description, string author, ISemanticVersion version) + { + // raise deprecation notice + this.DeprecationManager.Warn($"{nameof(IModHelper)}.{nameof(IModHelper.CreateTransitionalContentPack)}", "2.5", DeprecationLevel.Notice); + + // validate + if(string.IsNullOrWhiteSpace(directoryPath)) + throw new ArgumentNullException(nameof(directoryPath)); + if(string.IsNullOrWhiteSpace(id)) + throw new ArgumentNullException(nameof(id)); + if(string.IsNullOrWhiteSpace(name)) + throw new ArgumentNullException(nameof(name)); + if(!Directory.Exists(directoryPath)) + throw new ArgumentException($"Can't create content pack for directory path '{directoryPath}' because no such directory exists."); + + // create manifest + IManifest manifest = new Manifest + { + Name = name, + Author = author, + Description = description, + Version = version, + UniqueID = id, + UpdateKeys = new string[0], + ContentPackFor = new ManifestContentPackFor { UniqueID = this.ModID } + }; + + // create content pack + return this.CreateContentPack(directoryPath, manifest); + } + + /// <summary>Get all content packs loaded for this mod.</summary> + public IEnumerable<IContentPack> GetContentPacks() + { + return this.ContentPacks; + } /**** ** Disposal diff --git a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs index ea0dbb38..e579a830 100644 --- a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs @@ -20,7 +20,7 @@ namespace StardewModdingAPI.Framework.ModHelpers private readonly HashSet<string> AccessedModApis = new HashSet<string>(); /// <summary>Generates proxy classes to access mod APIs through an arbitrary interface.</summary> - private readonly InterfaceProxyBuilder ProxyBuilder; + private readonly InterfaceProxyFactory ProxyFactory; /********* @@ -29,13 +29,13 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <summary>Construct an instance.</summary> /// <param name="modID">The unique ID of the relevant mod.</param> /// <param name="registry">The underlying mod registry.</param> - /// <param name="proxyBuilder">Generates proxy classes to access mod APIs through an arbitrary interface.</param> + /// <param name="proxyFactory">Generates proxy classes to access mod APIs through an arbitrary interface.</param> /// <param name="monitor">Encapsulates monitoring and logging for the mod.</param> - public ModRegistryHelper(string modID, ModRegistry registry, InterfaceProxyBuilder proxyBuilder, IMonitor monitor) + public ModRegistryHelper(string modID, ModRegistry registry, InterfaceProxyFactory proxyFactory, IMonitor monitor) : base(modID) { this.Registry = registry; - this.ProxyBuilder = proxyBuilder; + this.ProxyFactory = proxyFactory; this.Monitor = monitor; } @@ -99,7 +99,7 @@ namespace StardewModdingAPI.Framework.ModHelpers // get API of type if (api is TInterface castApi) return castApi; - return this.ProxyBuilder.CreateProxy<TInterface>(api, this.ModID, uniqueID); + return this.ProxyFactory.CreateProxy<TInterface>(api, this.ModID, uniqueID); } } } diff --git a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs index 3a7b214a..ccbd053e 100644 --- a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs +++ b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs @@ -285,6 +285,11 @@ namespace StardewModdingAPI.Framework.ModLoading this.Monitor.LogOnce(loggedMessages, $"{mod.DisplayName} seems to change the save serialiser. It may change your saves in such a way that they won't work without this mod in the future.", LogLevel.Warn); break; + case InstructionHandleResult.DetectedUnvalidatedUpdateTick: + this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected reference to {handler.NounPhrase} in assembly {filename}."); + this.Monitor.LogOnce(loggedMessages, $"{mod.DisplayName} uses a specialised SMAPI event that may crash the game or corrupt your save file. If you encounter problems, try removing this mod first.", LogLevel.Warn); + break; + case InstructionHandleResult.DetectedDynamic: this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected 'dynamic' keyword ({handler.NounPhrase}) in assembly {filename}."); this.Monitor.LogOnce(loggedMessages, $"{mod.DisplayName} uses the 'dynamic' keyword, which isn't compatible with Stardew Valley on Linux or Mac.", diff --git a/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs b/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs index 0ae598fc..cfa23d08 100644 --- a/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs +++ b/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs @@ -1,3 +1,5 @@ +using StardewModdingAPI.Events; + namespace StardewModdingAPI.Framework.ModLoading { /// <summary>Indicates how an instruction was handled.</summary> @@ -19,6 +21,9 @@ namespace StardewModdingAPI.Framework.ModLoading DetectedSaveSerialiser, /// <summary>The instruction is compatible, but uses the <c>dynamic</c> keyword which won't work on Linux/Mac.</summary> - DetectedDynamic + DetectedDynamic, + + /// <summary>The instruction is compatible, but references <see cref="SpecialisedEvents.UnvalidatedUpdateTick"/> which may impact stability.</summary> + DetectedUnvalidatedUpdateTick } } diff --git a/src/SMAPI/Framework/ModLoading/ModMetadata.cs b/src/SMAPI/Framework/ModLoading/ModMetadata.cs index 30fe211b..1a0f9994 100644 --- a/src/SMAPI/Framework/ModLoading/ModMetadata.cs +++ b/src/SMAPI/Framework/ModLoading/ModMetadata.cs @@ -1,4 +1,5 @@ -using StardewModdingAPI.Framework.Models; +using System; +using StardewModdingAPI.Framework.ModData; namespace StardewModdingAPI.Framework.ModLoading { @@ -18,7 +19,7 @@ namespace StardewModdingAPI.Framework.ModLoading public IManifest Manifest { get; } /// <summary>Metadata about the mod from SMAPI's internal data (if any).</summary> - public ModDataRecord DataRecord { get; } + public ParsedModDataRecord DataRecord { get; } /// <summary>The metadata resolution status.</summary> public ModMetadataStatus Status { get; private set; } @@ -26,12 +27,21 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>The reason the metadata is invalid, if any.</summary> public string Error { get; private set; } - /// <summary>The mod instance (if it was loaded).</summary> + /// <summary>The mod instance (if loaded and <see cref="IsContentPack"/> is false).</summary> public IMod Mod { get; private set; } + /// <summary>The content pack instance (if loaded and <see cref="IsContentPack"/> is true).</summary> + public IContentPack ContentPack { get; private set; } + + /// <summary>Writes messages to the console and log file as this mod.</summary> + public IMonitor Monitor { get; private set; } + /// <summary>The mod-provided API (if any).</summary> public object Api { get; private set; } + /// <summary>Whether the mod is a content pack.</summary> + public bool IsContentPack => this.Manifest?.ContentPackFor != null; + /********* ** Public methods @@ -41,7 +51,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="directoryPath">The mod's full directory path.</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, ModDataRecord dataRecord) + public ModMetadata(string displayName, string directoryPath, IManifest manifest, ParsedModDataRecord dataRecord) { this.DisplayName = displayName; this.DirectoryPath = directoryPath; @@ -64,7 +74,24 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="mod">The mod instance to set.</param> public IModMetadata SetMod(IMod mod) { + if (this.ContentPack != null) + throw new InvalidOperationException("A mod can't be both an assembly mod and content pack."); + this.Mod = mod; + this.Monitor = mod.Monitor; + return this; + } + + /// <summary>Set the mod instance.</summary> + /// <param name="contentPack">The contentPack instance to set.</param> + /// <param name="monitor">Writes messages to the console and log file.</param> + public IModMetadata SetMod(IContentPack contentPack, IMonitor monitor) + { + if (this.Mod != null) + throw new InvalidOperationException("A mod can't be both an assembly mod and content pack."); + + this.ContentPack = contentPack; + this.Monitor = monitor; return this; } diff --git a/src/SMAPI/Framework/ModLoading/ModResolver.cs b/src/SMAPI/Framework/ModLoading/ModResolver.cs index 9802d9e9..ba6dab1a 100644 --- a/src/SMAPI/Framework/ModLoading/ModResolver.cs +++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs @@ -3,8 +3,10 @@ using System.Collections.Generic; using System.IO; using System.Linq; using StardewModdingAPI.Framework.Exceptions; +using StardewModdingAPI.Framework.ModData; using StardewModdingAPI.Framework.Models; using StardewModdingAPI.Framework.Serialisation; +using StardewModdingAPI.Framework.Utilities; namespace StardewModdingAPI.Framework.ModLoading { @@ -17,12 +19,10 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>Get manifest metadata for each folder in the given root path.</summary> /// <param name="rootPath">The root path to search for mods.</param> /// <param name="jsonHelper">The JSON helper with which to read manifests.</param> - /// <param name="dataRecords">Metadata about mods from SMAPI's internal data.</param> + /// <param name="modDatabase">Handles access to SMAPI's internal mod metadata list.</param> /// <returns>Returns the manifests by relative folder.</returns> - public IEnumerable<IModMetadata> ReadManifests(string rootPath, JsonHelper jsonHelper, IEnumerable<ModDataRecord> dataRecords) + public IEnumerable<IModMetadata> ReadManifests(string rootPath, JsonHelper jsonHelper, ModDatabase modDatabase) { - dataRecords = dataRecords.ToArray(); - foreach (DirectoryInfo modDir in this.GetModFolders(rootPath)) { // read file @@ -31,18 +31,13 @@ namespace StardewModdingAPI.Framework.ModLoading string error = null; try { - // read manifest manifest = jsonHelper.ReadJsonFile<Manifest>(path); - - // validate if (manifest == null) { error = File.Exists(path) ? "its manifest is invalid." : "it doesn't have a manifest."; } - else if (string.IsNullOrWhiteSpace(manifest.EntryDll)) - error = "its manifest doesn't set an entry DLL."; } catch (SParseException ex) { @@ -53,26 +48,27 @@ namespace StardewModdingAPI.Framework.ModLoading error = $"parsing its manifest failed:\n{ex.GetLogSummary()}"; } - // get internal data record (if any) - ModDataRecord dataRecord = null; - if (manifest != null) + // parse internal data record (if any) + ParsedModDataRecord dataRecord = modDatabase.GetParsed(manifest); + + // get display name + string displayName = manifest?.Name; + if (string.IsNullOrWhiteSpace(displayName)) + displayName = dataRecord?.DisplayName; + if (string.IsNullOrWhiteSpace(displayName)) + displayName = PathUtilities.GetRelativePath(rootPath, modDir.FullName); + + // apply defaults + if (manifest != null && dataRecord != null) { - string key = !string.IsNullOrWhiteSpace(manifest.UniqueID) ? manifest.UniqueID : manifest.EntryDll; - dataRecord = dataRecords.FirstOrDefault(record => record.ID.Matches(key, manifest)); + if (dataRecord.UpdateKey != null) + manifest.UpdateKeys = new[] { dataRecord.UpdateKey }; } - // add default update keys - if (manifest != null && manifest.UpdateKeys == null && dataRecord?.UpdateKeys != null) - manifest.UpdateKeys = dataRecord.UpdateKeys; - // build metadata - string displayName = !string.IsNullOrWhiteSpace(manifest?.Name) - ? manifest.Name - : modDir.FullName.Replace(rootPath, "").Trim('/', '\\'); ModMetadataStatus status = error == null ? ModMetadataStatus.Found : ModMetadataStatus.Failed; - yield return new ModMetadata(displayName, modDir.FullName, manifest, dataRecord).SetStatus(status, error); } } @@ -80,8 +76,8 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>Validate manifest metadata.</summary> /// <param name="mods">The mod manifests to validate.</param> /// <param name="apiVersion">The current SMAPI version.</param> - /// <param name="vendorModUrls">Maps vendor keys (like <c>Nexus</c>) to their mod URL template (where <c>{0}</c> is the mod ID).</param> - public void ValidateManifests(IEnumerable<IModMetadata> mods, ISemanticVersion apiVersion, IDictionary<string, string> vendorModUrls) + /// <param name="getUpdateUrl">Get an update URL for an update key (if valid).</param> + public void ValidateManifests(IEnumerable<IModMetadata> mods, ISemanticVersion apiVersion, Func<string, string> getUpdateUrl) { mods = mods.ToArray(); @@ -92,42 +88,35 @@ namespace StardewModdingAPI.Framework.ModLoading if (mod.Status == ModMetadataStatus.Failed) continue; - // validate compatibility - ModCompatibility compatibility = mod.DataRecord?.GetCompatibility(mod.Manifest.Version); - switch (compatibility?.Status) + // validate compatibility from internal data + switch (mod.DataRecord?.Status) { case ModStatus.Obsolete: - mod.SetStatus(ModMetadataStatus.Failed, $"it's obsolete: {compatibility.ReasonPhrase}"); + mod.SetStatus(ModMetadataStatus.Failed, $"it's obsolete: {mod.DataRecord.StatusReasonPhrase}"); continue; case ModStatus.AssumeBroken: { // get reason - string reasonPhrase = compatibility.ReasonPhrase ?? "it's no longer compatible"; + string reasonPhrase = mod.DataRecord.StatusReasonPhrase ?? "it's no longer compatible"; // get update URLs List<string> updateUrls = new List<string>(); foreach (string key in mod.Manifest.UpdateKeys ?? new string[0]) { - string[] parts = key.Split(new[] { ':' }, 2); - if (parts.Length != 2) - continue; - - string vendorKey = parts[0].Trim(); - string modID = parts[1].Trim(); - - if (vendorModUrls.TryGetValue(vendorKey, out string urlTemplate)) - updateUrls.Add(string.Format(urlTemplate, modID)); + string url = getUpdateUrl(key); + if (url != null) + updateUrls.Add(url); } if (mod.DataRecord.AlternativeUrl != null) updateUrls.Add(mod.DataRecord.AlternativeUrl); // build error string error = $"{reasonPhrase}. Please check for a "; - if (mod.Manifest.Version.Equals(compatibility.UpperVersion)) + if (mod.DataRecord.StatusUpperVersion == null || mod.Manifest.Version.Equals(mod.DataRecord.StatusUpperVersion)) error += "newer version"; else - error += $"version newer than {compatibility.UpperVersion}"; + error += $"version newer than {mod.DataRecord.StatusUpperVersion}"; error += " at " + string.Join(" or ", updateUrls); mod.SetStatus(ModMetadataStatus.Failed, error); @@ -142,24 +131,52 @@ namespace StardewModdingAPI.Framework.ModLoading continue; } - // validate DLL value - if (string.IsNullOrWhiteSpace(mod.Manifest.EntryDll)) - { - mod.SetStatus(ModMetadataStatus.Failed, "its manifest has no EntryDLL field."); - continue; - } - if (mod.Manifest.EntryDll.Intersect(Path.GetInvalidFileNameChars()).Any()) + // validate DLL / content pack fields { - mod.SetStatus(ModMetadataStatus.Failed, $"its manifest has invalid filename '{mod.Manifest.EntryDll}' for the EntryDLL field."); - continue; - } + bool hasDll = !string.IsNullOrWhiteSpace(mod.Manifest.EntryDll); + bool isContentPack = mod.Manifest.ContentPackFor != null; - // validate DLL path - string assemblyPath = Path.Combine(mod.DirectoryPath, mod.Manifest.EntryDll); - if (!File.Exists(assemblyPath)) - { - mod.SetStatus(ModMetadataStatus.Failed, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist."); - continue; + // validate field presence + if (!hasDll && !isContentPack) + { + mod.SetStatus(ModMetadataStatus.Failed, $"its manifest has no {nameof(IManifest.EntryDll)} or {nameof(IManifest.ContentPackFor)} field; must specify one."); + continue; + } + if (hasDll && isContentPack) + { + mod.SetStatus(ModMetadataStatus.Failed, $"its manifest sets both {nameof(IManifest.EntryDll)} and {nameof(IManifest.ContentPackFor)}, which are mutually exclusive."); + continue; + } + + // validate DLL + if (hasDll) + { + // invalid filename format + if (mod.Manifest.EntryDll.Intersect(Path.GetInvalidFileNameChars()).Any()) + { + mod.SetStatus(ModMetadataStatus.Failed, $"its manifest has invalid filename '{mod.Manifest.EntryDll}' for the EntryDLL field."); + continue; + } + + // invalid path + string assemblyPath = Path.Combine(mod.DirectoryPath, mod.Manifest.EntryDll); + if (!File.Exists(assemblyPath)) + { + mod.SetStatus(ModMetadataStatus.Failed, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist."); + continue; + } + } + + // validate content pack + else + { + // invalid content pack ID + if (string.IsNullOrWhiteSpace(mod.Manifest.ContentPackFor.UniqueID)) + { + mod.SetStatus(ModMetadataStatus.Failed, $"its manifest declares {nameof(IManifest.ContentPackFor)} without its required {nameof(IManifestContentPackFor.UniqueID)} field."); + continue; + } + } } // validate required fields @@ -197,7 +214,8 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>Sort the given mods by the order they should be loaded.</summary> /// <param name="mods">The mods to process.</param> - public IEnumerable<IModMetadata> ProcessDependencies(IEnumerable<IModMetadata> mods) + /// <param name="modDatabase">Handles access to SMAPI's internal mod metadata list.</param> + public IEnumerable<IModMetadata> ProcessDependencies(IEnumerable<IModMetadata> mods, ModDatabase modDatabase) { // initialise metadata mods = mods.ToArray(); @@ -213,7 +231,7 @@ namespace StardewModdingAPI.Framework.ModLoading // sort mods foreach (IModMetadata mod in mods) - this.ProcessDependencies(mods.ToArray(), mod, states, sortedMods, new List<IModMetadata>()); + this.ProcessDependencies(mods.ToArray(), modDatabase, mod, states, sortedMods, new List<IModMetadata>()); return sortedMods.Reverse(); } @@ -224,12 +242,13 @@ namespace StardewModdingAPI.Framework.ModLoading *********/ /// <summary>Sort a mod's dependencies by the order they should be loaded, and remove any mods that can't be loaded due to missing or conflicting dependencies.</summary> /// <param name="mods">The full list of mods being validated.</param> + /// <param name="modDatabase">Handles access to SMAPI's internal mod metadata list.</param> /// <param name="mod">The mod whose dependencies to process.</param> /// <param name="states">The dependency state for each mod.</param> /// <param name="sortedMods">The list in which to save mods sorted by dependency order.</param> /// <param name="currentChain">The current change of mod dependencies.</param> /// <returns>Returns the mod dependency status.</returns> - private ModDependencyStatus ProcessDependencies(IModMetadata[] mods, IModMetadata mod, IDictionary<IModMetadata, ModDependencyStatus> states, Stack<IModMetadata> sortedMods, ICollection<IModMetadata> currentChain) + private ModDependencyStatus ProcessDependencies(IModMetadata[] mods, ModDatabase modDatabase, IModMetadata mod, IDictionary<IModMetadata, ModDependencyStatus> states, Stack<IModMetadata> sortedMods, ICollection<IModMetadata> currentChain) { // check if already visited switch (states[mod]) @@ -255,36 +274,32 @@ namespace StardewModdingAPI.Framework.ModLoading throw new InvalidModStateException($"Unknown dependency status '{states[mod]}'."); } - // no dependencies, mark sorted - if (mod.Manifest.Dependencies == null || !mod.Manifest.Dependencies.Any()) + // collect dependencies + ModDependency[] dependencies = this.GetDependenciesFrom(mod.Manifest, mods).ToArray(); + + // mark sorted if no dependencies + if (!dependencies.Any()) { sortedMods.Push(mod); return states[mod] = ModDependencyStatus.Sorted; } - // get dependencies - var dependencies = - ( - from entry in mod.Manifest.Dependencies - let dependencyMod = mods.FirstOrDefault(m => string.Equals(m.Manifest?.UniqueID, entry.UniqueID, StringComparison.InvariantCultureIgnoreCase)) - orderby entry.UniqueID - select new - { - ID = entry.UniqueID, - MinVersion = entry.MinimumVersion, - Mod = dependencyMod, - IsRequired = entry.IsRequired - } - ) - .ToArray(); - - // missing required dependencies, mark failed + // mark failed if missing dependencies { - string[] failedIDs = (from entry in dependencies where entry.IsRequired && entry.Mod == null select entry.ID).ToArray(); - if (failedIDs.Any()) + string[] failedModNames = ( + from entry in dependencies + where entry.IsRequired && entry.Mod == null + let displayName = modDatabase.GetDisplayNameFor(entry.ID) ?? entry.ID + let modUrl = modDatabase.GetModPageUrlFor(entry.ID) + orderby displayName + select modUrl != null + ? $"{displayName}: {modUrl}" + : displayName + ).ToArray(); + if (failedModNames.Any()) { sortedMods.Push(mod); - mod.SetStatus(ModMetadataStatus.Failed, $"it requires mods which aren't installed ({string.Join(", ", failedIDs)})."); + mod.SetStatus(ModMetadataStatus.Failed, $"it requires mods which aren't installed ({string.Join(", ", failedModNames)})."); return states[mod] = ModDependencyStatus.Failed; } } @@ -329,7 +344,7 @@ namespace StardewModdingAPI.Framework.ModLoading } // recursively process each dependency - var substatus = this.ProcessDependencies(mods, requiredMod, states, sortedMods, subchain); + var substatus = this.ProcessDependencies(mods, modDatabase, requiredMod, states, sortedMods, subchain); switch (substatus) { // sorted successfully @@ -374,5 +389,64 @@ namespace StardewModdingAPI.Framework.ModLoading yield return directory; } } + + /// <summary>Get the dependencies declared in a manifest.</summary> + /// <param name="manifest">The mod manifest.</param> + /// <param name="loadedMods">The loaded mods.</param> + private IEnumerable<ModDependency> GetDependenciesFrom(IManifest manifest, IModMetadata[] loadedMods) + { + IModMetadata FindMod(string id) => loadedMods.FirstOrDefault(m => string.Equals(m.Manifest?.UniqueID, id, StringComparison.InvariantCultureIgnoreCase)); + + // yield dependencies + if (manifest.Dependencies != null) + { + foreach (var entry in manifest.Dependencies) + yield return new ModDependency(entry.UniqueID, entry.MinimumVersion, FindMod(entry.UniqueID), entry.IsRequired); + } + + // yield content pack parent + if (manifest.ContentPackFor != null) + yield return new ModDependency(manifest.ContentPackFor.UniqueID, manifest.ContentPackFor.MinimumVersion, FindMod(manifest.ContentPackFor.UniqueID), isRequired: true); + } + + + /********* + ** Private models + *********/ + /// <summary>Represents a dependency from one mod to another.</summary> + private struct ModDependency + { + /********* + ** Accessors + *********/ + /// <summary>The unique ID of the required mod.</summary> + public string ID { get; } + + /// <summary>The minimum required version (if any).</summary> + public ISemanticVersion MinVersion { get; } + + /// <summary>Whether the mod shouldn't be loaded if the dependency isn't available.</summary> + public bool IsRequired { get; } + + /// <summary>The loaded mod that fulfills the dependency (if available).</summary> + public IModMetadata Mod { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="id">The unique ID of the required mod.</param> + /// <param name="minVersion">The minimum required version (if any).</param> + /// <param name="mod">The loaded mod that fulfills the dependency (if available).</param> + /// <param name="isRequired">Whether the mod shouldn't be loaded if the dependency isn't available.</param> + public ModDependency(string id, ISemanticVersion minVersion, IModMetadata mod, bool isRequired) + { + this.ID = id; + this.MinVersion = minVersion; + this.Mod = mod; + this.IsRequired = isRequired; + } + } } } diff --git a/src/SMAPI/Framework/ModRegistry.cs b/src/SMAPI/Framework/ModRegistry.cs index 453d2868..e7d4f89a 100644 --- a/src/SMAPI/Framework/ModRegistry.cs +++ b/src/SMAPI/Framework/ModRegistry.cs @@ -25,18 +25,27 @@ namespace StardewModdingAPI.Framework /********* ** Public methods *********/ - /// <summary>Register a mod as a possible source of deprecation warnings.</summary> + /// <summary>Register a mod.</summary> /// <param name="metadata">The mod metadata.</param> public void Add(IModMetadata metadata) { this.Mods.Add(metadata); - this.ModNamesByAssembly[metadata.Mod.GetType().Assembly.FullName] = metadata; + if (!metadata.IsContentPack) + this.ModNamesByAssembly[metadata.Mod.GetType().Assembly.FullName] = metadata; } /// <summary>Get metadata for all loaded mods.</summary> - public IEnumerable<IModMetadata> GetAll() + /// <param name="assemblyMods">Whether to include SMAPI mods.</param> + /// <param name="contentPacks">Whether to include content pack mods.</param> + public IEnumerable<IModMetadata> GetAll(bool assemblyMods = true, bool contentPacks = true) { - return this.Mods.Select(p => p); + IEnumerable<IModMetadata> query = this.Mods; + if (!assemblyMods) + query = query.Where(p => p.IsContentPack); + if (!contentPacks) + query = query.Where(p => !p.IsContentPack); + + return query; } /// <summary>Get metadata for a loaded mod.</summary> diff --git a/src/SMAPI/Framework/Models/Manifest.cs b/src/SMAPI/Framework/Models/Manifest.cs index f9762406..f5867cf3 100644 --- a/src/SMAPI/Framework/Models/Manifest.cs +++ b/src/SMAPI/Framework/Models/Manifest.cs @@ -20,16 +20,18 @@ namespace StardewModdingAPI.Framework.Models public string Author { get; set; } /// <summary>The mod version.</summary> - [JsonConverter(typeof(SemanticVersionConverter))] public ISemanticVersion Version { get; set; } /// <summary>The minimum SMAPI version required by this mod, if any.</summary> - [JsonConverter(typeof(SemanticVersionConverter))] public ISemanticVersion MinimumApiVersion { get; set; } - /// <summary>The name of the DLL in the directory that has the <see cref="IMod.Entry"/> method.</summary> + /// <summary>The name of the DLL in the directory that has the <see cref="IMod.Entry"/> method. Mutually exclusive with <see cref="ContentPackFor"/>.</summary> public string EntryDll { get; set; } + /// <summary>The mod which will read this as a content pack. Mutually exclusive with <see cref="IManifest.EntryDll"/>.</summary> + [JsonConverter(typeof(ManifestContentPackForConverter))] + public IManifestContentPackFor ContentPackFor { get; set; } + /// <summary>The other mods that must be loaded before this mod.</summary> [JsonConverter(typeof(ManifestDependencyArrayConverter))] public IManifestDependency[] Dependencies { get; set; } diff --git a/src/SMAPI/Framework/Models/ManifestContentPackFor.cs b/src/SMAPI/Framework/Models/ManifestContentPackFor.cs new file mode 100644 index 00000000..7836bbcc --- /dev/null +++ b/src/SMAPI/Framework/Models/ManifestContentPackFor.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Framework.Models +{ + /// <summary>Indicates which mod can read the content pack represented by the containing manifest.</summary> + internal class ManifestContentPackFor : IManifestContentPackFor + { + /********* + ** Accessors + *********/ + /// <summary>The unique ID of the mod which can read this content pack.</summary> + public string UniqueID { get; set; } + + /// <summary>The minimum required version (if any).</summary> + public ISemanticVersion MinimumVersion { get; set; } + } +} diff --git a/src/SMAPI/Framework/Models/ModCompatibility.cs b/src/SMAPI/Framework/Models/ModCompatibility.cs deleted file mode 100644 index 54737e6c..00000000 --- a/src/SMAPI/Framework/Models/ModCompatibility.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; - -namespace StardewModdingAPI.Framework.Models -{ - /// <summary>Specifies the compatibility of a given mod version range.</summary> - internal class ModCompatibility - { - /********* - ** Accessors - *********/ - /// <summary>The lowest version in the range, or <c>null</c> for all past versions.</summary> - public ISemanticVersion LowerVersion { get; } - - /// <summary>The highest version in the range, or <c>null</c> for all future versions.</summary> - public ISemanticVersion UpperVersion { get; } - - /// <summary>The mod compatibility.</summary> - public ModStatus Status { get; } - - /// <summary>The reason phrase to show in log output, or <c>null</c> to use the default value.</summary> - /// <example>For example, "this version is incompatible with the latest version of the game".</example> - public string ReasonPhrase { get; } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="versionRange">A version range, which consists of two version strings separated by a '~' character. Either side can be left blank for an unbounded range.</param> - /// <param name="status">The mod compatibility.</param> - /// <param name="reasonPhrase">The reason phrase to show in log output, or <c>null</c> to use the default value.</param> - public ModCompatibility(string versionRange, ModStatus status, string reasonPhrase) - { - // extract version strings - string[] versions = versionRange.Split('~'); - if (versions.Length != 2) - throw new FormatException($"Could not parse '{versionRange}' as a version range. It must have two version strings separated by a '~' character (either side can be left blank for an unbounded range)."); - - // initialise - this.LowerVersion = !string.IsNullOrWhiteSpace(versions[0]) ? new SemanticVersion(versions[0]) : null; - this.UpperVersion = !string.IsNullOrWhiteSpace(versions[1]) ? new SemanticVersion(versions[1]) : null; - this.Status = status; - this.ReasonPhrase = reasonPhrase; - } - - /// <summary>Get whether a given version is contained within this compatibility range.</summary> - /// <param name="version">The version to check.</param> - public bool MatchesVersion(ISemanticVersion version) - { - return - (this.LowerVersion == null || !version.IsOlderThan(this.LowerVersion)) - && (this.UpperVersion == null || !version.IsNewerThan(this.UpperVersion)); - } - } -} diff --git a/src/SMAPI/Framework/Models/ModDataID.cs b/src/SMAPI/Framework/Models/ModDataID.cs deleted file mode 100644 index d19434fa..00000000 --- a/src/SMAPI/Framework/Models/ModDataID.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Linq; -using Newtonsoft.Json; - -namespace StardewModdingAPI.Framework.Models -{ - /// <summary>Uniquely identifies a mod in SMAPI's internal data.</summary> - /// <remarks> - /// This represents a custom format which uniquely identifies a mod across all versions, even - /// if its field values change or it doesn't specify a unique ID. This is mapped to a string - /// with the following format: - /// - /// 1. If the mod's identifier changed over time, multiple variants can be separated by the <c>|</c> - /// character. - /// 2. Each variant can take one of two forms: - /// - A simple string matching the mod's UniqueID value. - /// - A JSON structure containing any of three manifest fields (ID, Name, and Author) to match. - /// </remarks> - internal class ModDataID - { - /********* - ** Properties - *********/ - /// <summary>The unique sets of field values which identify this mod.</summary> - private readonly FieldSnapshot[] Snapshots; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - public ModDataID() { } - - /// <summary>Construct an instance.</summary> - /// <param name="data">The mod identifier string (see remarks on <see cref="ModDataID"/>).</param> - public ModDataID(string data) - { - this.Snapshots = - ( - from string part in data.Split('|') - let str = part.Trim() - select str.StartsWith("{") - ? JsonConvert.DeserializeObject<FieldSnapshot>(str) - : new FieldSnapshot { ID = str } - ) - .ToArray(); - } - - /// <summary>Get whether this ID matches a given mod manifest.</summary> - /// <param name="id">The mod's unique ID, or a substitute ID if it isn't set in the manifest.</param> - /// <param name="manifest">The manifest to check.</param> - public bool Matches(string id, IManifest manifest) - { - return this.Snapshots.Any(snapshot => - snapshot.ID.Equals(id, StringComparison.InvariantCultureIgnoreCase) - && ( - snapshot.Author == null - || snapshot.Author.Equals(manifest.Author, StringComparison.InvariantCultureIgnoreCase) - || (manifest.ExtraFields.ContainsKey("Authour") && snapshot.Author.Equals(manifest.ExtraFields["Authour"].ToString(), StringComparison.InvariantCultureIgnoreCase)) - ) - && (snapshot.Name == null || snapshot.Name.Equals(manifest.Name, StringComparison.InvariantCultureIgnoreCase)) - ); - } - - - /********* - ** Private models - *********/ - /// <summary>A unique set of fields which identifies the mod.</summary> - private class FieldSnapshot - { - /********* - ** Accessors - *********/ - /// <summary>The unique mod ID.</summary> - public string ID { get; set; } - - /// <summary>The mod name, or <c>null</c> to ignore the mod name.</summary> - public string Name { get; set; } - - /// <summary>The author name, or <c>null</c> to ignore the author.</summary> - public string Author { get; set; } - } - } -} diff --git a/src/SMAPI/Framework/Models/ModDataRecord.cs b/src/SMAPI/Framework/Models/ModDataRecord.cs deleted file mode 100644 index 580acb70..00000000 --- a/src/SMAPI/Framework/Models/ModDataRecord.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using Newtonsoft.Json; -using StardewModdingAPI.Framework.Serialisation.SmapiConverters; - -namespace StardewModdingAPI.Framework.Models -{ - /// <summary>Metadata about a mod from SMAPI's internal data.</summary> - internal class ModDataRecord - { - /********* - ** Accessors - *********/ - /// <summary>The unique mod identifier.</summary> - [JsonConverter(typeof(ModDataIdConverter))] - public ModDataID ID { get; set; } - - /// <summary>A value to inject into <see cref="IManifest.UpdateKeys"/> field if it's not already set.</summary> - public string[] UpdateKeys { get; set; } - - /// <summary>The URL where the player can get an unofficial or alternative version of the mod if the official version isn't compatible.</summary> - public string AlternativeUrl { get; set; } - - /// <summary>The compatibility of given mod versions (if any).</summary> - [JsonConverter(typeof(ModCompatibilityArrayConverter))] - public ModCompatibility[] Compatibility { get; set; } = new ModCompatibility[0]; - - /// <summary>Map local versions to a semantic version for update checks.</summary> - public IDictionary<string, string> MapLocalVersions { get; set; } = new Dictionary<string, string>(); - - /// <summary>Map remote versions to a semantic version for update checks.</summary> - public IDictionary<string, string> MapRemoteVersions { get; set; } = new Dictionary<string, string>(); - - - /********* - ** Public methods - *********/ - /// <summary>Get the compatibility record for a given version, if any.</summary> - /// <param name="version">The mod version to check.</param> - public ModCompatibility GetCompatibility(ISemanticVersion version) - { - return this.Compatibility.FirstOrDefault(p => p.MatchesVersion(version)); - } - - /// <summary>Get a semantic local version for update checks.</summary> - /// <param name="version">The local version to normalise.</param> - public string GetLocalVersionForUpdateChecks(string version) - { - return this.MapLocalVersions != null && this.MapLocalVersions.TryGetValue(version, out string newVersion) - ? newVersion - : version; - } - - /// <summary>Get a semantic remote version for update checks.</summary> - /// <param name="version">The remote version to normalise.</param> - public string GetRemoteVersionForUpdateChecks(string version) - { - return this.MapRemoteVersions != null && this.MapRemoteVersions.TryGetValue(version, out string newVersion) - ? newVersion - : version; - } - } -} diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs index 401e1a3a..17169714 100644 --- a/src/SMAPI/Framework/Models/SConfig.cs +++ b/src/SMAPI/Framework/Models/SConfig.cs @@ -1,3 +1,6 @@ +using System.Collections.Generic; +using StardewModdingAPI.Framework.ModData; + namespace StardewModdingAPI.Framework.Models { /// <summary>The SMAPI configuration settings.</summary> @@ -22,6 +25,6 @@ namespace StardewModdingAPI.Framework.Models public bool VerboseLogging { get; set; } /// <summary>Extra metadata about mods.</summary> - public ModDataRecord[] ModData { get; set; } + public IDictionary<string, ModDataRecord> ModData { get; set; } } } diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs index 5abebc18..7a2958fb 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs @@ -1,82 +1,47 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Reflection.Emit; namespace StardewModdingAPI.Framework.Reflection { - /// <summary>Generates proxy classes to access mod APIs through an arbitrary interface.</summary> + /// <summary>Generates a proxy class to access a mod API through an arbitrary interface.</summary> internal class InterfaceProxyBuilder { /********* ** Properties *********/ - /// <summary>The CLR module in which to create proxy classes.</summary> - private readonly ModuleBuilder ModuleBuilder; + /// <summary>The target class type.</summary> + private readonly Type TargetType; - /// <summary>The generated proxy types.</summary> - private readonly IDictionary<string, Type> GeneratedTypes = new Dictionary<string, Type>(); + /// <summary>The generated proxy type.</summary> + private readonly Type ProxyType; /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - public InterfaceProxyBuilder() - { - AssemblyBuilder assemblyBuilder = AppDomain.CurrentDomain.DefineDynamicAssembly(new AssemblyName($"StardewModdingAPI.Proxies, Version={this.GetType().Assembly.GetName().Version}, Culture=neutral"), AssemblyBuilderAccess.Run); - this.ModuleBuilder = assemblyBuilder.DefineDynamicModule("StardewModdingAPI.Proxies"); - } - - /// <summary>Create an API proxy.</summary> - /// <typeparam name="TInterface">The interface through which to access the API.</typeparam> - /// <param name="instance">The API instance to access.</param> - /// <param name="sourceModID">The unique ID of the mod consuming the API.</param> - /// <param name="targetModID">The unique ID of the mod providing the API.</param> - public TInterface CreateProxy<TInterface>(object instance, string sourceModID, string targetModID) - where TInterface : class + /// <param name="name">The type name to generate.</param> + /// <param name="moduleBuilder">The CLR module in which to create proxy classes.</param> + /// <param name="interfaceType">The interface type to implement.</param> + /// <param name="targetType">The target type.</param> + public InterfaceProxyBuilder(string name, ModuleBuilder moduleBuilder, Type interfaceType, Type targetType) { // validate - if (instance == null) - throw new InvalidOperationException("Can't proxy access to a null API."); - if (!typeof(TInterface).IsInterface) - throw new InvalidOperationException("The proxy type must be an interface, not a class."); - - // get proxy type - Type targetType = instance.GetType(); - string proxyTypeName = $"StardewModdingAPI.Proxies.From<{sourceModID}_{typeof(TInterface).FullName}>_To<{targetModID}_{targetType.FullName}>"; - if (!this.GeneratedTypes.TryGetValue(proxyTypeName, out Type type)) - { - type = this.CreateProxyType(proxyTypeName, typeof(TInterface), targetType); - this.GeneratedTypes[proxyTypeName] = type; - } + if (name == null) + throw new ArgumentNullException(nameof(name)); + if (targetType == null) + throw new ArgumentNullException(nameof(targetType)); - // create instance - ConstructorInfo constructor = type.GetConstructor(new[] { targetType }); - if (constructor == null) - throw new InvalidOperationException($"Couldn't find the constructor for generated proxy type '{proxyTypeName}'."); // should never happen - return (TInterface)constructor.Invoke(new[] { instance }); - } - - - /********* - ** Private methods - *********/ - /// <summary>Define a class which proxies access to a target type through an interface.</summary> - /// <param name="proxyTypeName">The name of the proxy type to generate.</param> - /// <param name="interfaceType">The interface type through which to access the target.</param> - /// <param name="targetType">The target type to access.</param> - private Type CreateProxyType(string proxyTypeName, Type interfaceType, Type targetType) - { // define proxy type - TypeBuilder proxyBuilder = this.ModuleBuilder.DefineType(proxyTypeName, TypeAttributes.Public | TypeAttributes.Class); + TypeBuilder proxyBuilder = moduleBuilder.DefineType(name, TypeAttributes.Public | TypeAttributes.Class); proxyBuilder.AddInterfaceImplementation(interfaceType); // create field to store target instance - FieldBuilder field = proxyBuilder.DefineField("__Target", targetType, FieldAttributes.Private); + FieldBuilder targetField = proxyBuilder.DefineField("__Target", targetType, FieldAttributes.Private); - // create constructor which accepts target instance + // create constructor which accepts target instance and sets field { ConstructorBuilder constructor = proxyBuilder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard | CallingConventions.HasThis, new[] { targetType }); ILGenerator il = constructor.GetILGenerator(); @@ -86,7 +51,7 @@ namespace StardewModdingAPI.Framework.Reflection il.Emit(OpCodes.Call, typeof(object).GetConstructor(new Type[0])); // call base constructor il.Emit(OpCodes.Ldarg_0); // this il.Emit(OpCodes.Ldarg_1); // load argument - il.Emit(OpCodes.Stfld, field); // set field to loaded argument + il.Emit(OpCodes.Stfld, targetField); // set field to loaded argument il.Emit(OpCodes.Ret); } @@ -97,13 +62,28 @@ namespace StardewModdingAPI.Framework.Reflection if (targetMethod == null) throw new InvalidOperationException($"The {interfaceType.FullName} interface defines method {proxyMethod.Name} which doesn't exist in the API."); - this.ProxyMethod(proxyBuilder, targetMethod, field); + this.ProxyMethod(proxyBuilder, targetMethod, targetField); } - // create type - return proxyBuilder.CreateType(); + // save info + this.TargetType = targetType; + this.ProxyType = proxyBuilder.CreateType(); + } + + /// <summary>Create an instance of the proxy for a target instance.</summary> + /// <param name="targetInstance">The target instance.</param> + public object CreateInstance(object targetInstance) + { + ConstructorInfo constructor = this.ProxyType.GetConstructor(new[] { this.TargetType }); + if (constructor == null) + throw new InvalidOperationException($"Couldn't find the constructor for generated proxy type '{this.ProxyType.Name}'."); // should never happen + return constructor.Invoke(new[] { targetInstance }); } + + /********* + ** Private methods + *********/ /// <summary>Define a method which proxies access to a method on the target.</summary> /// <param name="proxyBuilder">The proxy type being generated.</param> /// <param name="target">The target method.</param> diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs new file mode 100644 index 00000000..e14a9f08 --- /dev/null +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Emit; + +namespace StardewModdingAPI.Framework.Reflection +{ + /// <summary>Generates proxy classes to access mod APIs through an arbitrary interface.</summary> + internal class InterfaceProxyFactory + { + /********* + ** Properties + *********/ + /// <summary>The CLR module in which to create proxy classes.</summary> + private readonly ModuleBuilder ModuleBuilder; + + /// <summary>The generated proxy types.</summary> + private readonly IDictionary<string, InterfaceProxyBuilder> Builders = new Dictionary<string, InterfaceProxyBuilder>(); + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public InterfaceProxyFactory() + { + AssemblyBuilder assemblyBuilder = AppDomain.CurrentDomain.DefineDynamicAssembly(new AssemblyName($"StardewModdingAPI.Proxies, Version={this.GetType().Assembly.GetName().Version}, Culture=neutral"), AssemblyBuilderAccess.Run); + this.ModuleBuilder = assemblyBuilder.DefineDynamicModule("StardewModdingAPI.Proxies"); + } + + /// <summary>Create an API proxy.</summary> + /// <typeparam name="TInterface">The interface through which to access the API.</typeparam> + /// <param name="instance">The API instance to access.</param> + /// <param name="sourceModID">The unique ID of the mod consuming the API.</param> + /// <param name="targetModID">The unique ID of the mod providing the API.</param> + public TInterface CreateProxy<TInterface>(object instance, string sourceModID, string targetModID) + where TInterface : class + { + // validate + if (instance == null) + throw new InvalidOperationException("Can't proxy access to a null API."); + if (!typeof(TInterface).IsInterface) + throw new InvalidOperationException("The proxy type must be an interface, not a class."); + + // get proxy type + Type targetType = instance.GetType(); + string proxyTypeName = $"StardewModdingAPI.Proxies.From<{sourceModID}_{typeof(TInterface).FullName}>_To<{targetModID}_{targetType.FullName}>"; + if (!this.Builders.TryGetValue(proxyTypeName, out InterfaceProxyBuilder builder)) + { + builder = new InterfaceProxyBuilder(proxyTypeName, this.ModuleBuilder, typeof(TInterface), targetType); + this.Builders[proxyTypeName] = builder; + } + + // create instance + return (TInterface)builder.CreateInstance(instance); + } + } +} diff --git a/src/SMAPI/Framework/SContentManager.cs b/src/SMAPI/Framework/SContentManager.cs index ebea6c84..fa51bd53 100644 --- a/src/SMAPI/Framework/SContentManager.cs +++ b/src/SMAPI/Framework/SContentManager.cs @@ -35,9 +35,6 @@ namespace StardewModdingAPI.Framework /********* ** Properties *********/ - /// <summary>The preferred directory separator chaeacter in an asset key.</summary> - private static readonly string PreferredPathSeparator = Path.DirectorySeparatorChar.ToString(); - /// <summary>Encapsulates monitoring and logging.</summary> private readonly IMonitor Monitor; @@ -75,9 +72,6 @@ namespace StardewModdingAPI.Framework /// <summary>Interceptors which edit matching assets after they're loaded.</summary> internal IDictionary<IModMetadata, IList<IAssetEditor>> Editors { get; } = new Dictionary<IModMetadata, IList<IAssetEditor>>(); - /// <summary>The possible directory separator characters in an asset key.</summary> - internal static readonly char[] PossiblePathSeparators = new[] { '/', '\\', Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }.Distinct().ToArray(); - /// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary> internal string FullRootDirectory => Path.Combine(Constants.ExecutionPath, this.RootDirectory); @@ -100,7 +94,7 @@ namespace StardewModdingAPI.Framework { // init this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); - this.Cache = new ContentCache(this, reflection, SContentManager.PossiblePathSeparators, SContentManager.PreferredPathSeparator); + this.Cache = new ContentCache(this, reflection); this.GetKeyLocale = reflection.GetMethod(this, "languageCode"); this.ModContentPrefix = this.GetAssetNameFromFilePath(Constants.ModPath); @@ -399,15 +393,7 @@ namespace StardewModdingAPI.Framework /// <param name="targetPath">The target file path.</param> private string GetRelativePath(string targetPath) { - // convert to URIs - Uri from = new Uri(this.FullRootDirectory + "/"); - Uri to = new Uri(targetPath + "/"); - if (from.Scheme != to.Scheme) - throw new InvalidOperationException($"Can't get path for '{targetPath}' relative to '{this.FullRootDirectory}'."); - - // get relative path - return Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString()) - .Replace(Path.DirectorySeparatorChar == '/' ? '\\' : '/', Path.DirectorySeparatorChar); // use correct separator for platform + return PathUtilities.GetRelativePath(this.FullRootDirectory, targetPath); } /// <summary>Get the locale codes (like <c>ja-JP</c>) used in asset keys.</summary> @@ -581,7 +567,7 @@ namespace StardewModdingAPI.Framework } catch (Exception ex) { - this.Monitor.Log($"{entry.Key.DisplayName} crashed when checking whether it could load asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + entry.Key.LogAsMod($"Mod failed when checking whether it could load asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); return false; } }) @@ -608,14 +594,14 @@ namespace StardewModdingAPI.Framework } catch (Exception ex) { - this.Monitor.Log($"{mod.DisplayName} crashed when loading asset '{info.AssetName}'. SMAPI will use the default asset instead. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + mod.LogAsMod($"Mod crashed when loading asset '{info.AssetName}'. SMAPI will use the default asset instead. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); return null; } // validate asset if (data == null) { - this.Monitor.Log($"{mod.DisplayName} incorrectly set asset '{info.AssetName}' to a null value; ignoring override.", LogLevel.Error); + mod.LogAsMod($"Mod incorrectly set asset '{info.AssetName}' to a null value; ignoring override.", LogLevel.Error); return null; } @@ -644,7 +630,7 @@ namespace StardewModdingAPI.Framework } catch (Exception ex) { - this.Monitor.Log($"{mod.DisplayName} crashed when checking whether it could edit asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); continue; } @@ -657,18 +643,18 @@ namespace StardewModdingAPI.Framework } catch (Exception ex) { - this.Monitor.Log($"{mod.DisplayName} crashed when editing asset '{info.AssetName}', which may cause errors in-game. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + mod.LogAsMod($"Mod crashed when editing asset '{info.AssetName}', which may cause errors in-game. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); } // validate edit if (asset.Data == null) { - this.Monitor.Log($"{mod.DisplayName} incorrectly set asset '{info.AssetName}' to a null value; ignoring override.", LogLevel.Warn); + mod.LogAsMod($"Mod incorrectly set asset '{info.AssetName}' to a null value; ignoring override.", LogLevel.Warn); asset = GetNewData(prevAsset); } else if (!(asset.Data is T)) { - this.Monitor.Log($"{mod.DisplayName} incorrectly set asset '{asset.AssetName}' to incompatible type '{asset.Data.GetType()}', expected '{typeof(T)}'; ignoring override.", LogLevel.Warn); + mod.LogAsMod($"Mod incorrectly set asset '{asset.AssetName}' to incompatible type '{asset.Data.GetType()}', expected '{typeof(T)}'; ignoring override.", LogLevel.Warn); asset = GetNewData(prevAsset); } } @@ -792,12 +778,12 @@ namespace StardewModdingAPI.Framework { try { - this.Lock.EnterReadLock(); + this.Lock.EnterWriteLock(); return action(); } finally { - this.Lock.ExitReadLock(); + this.Lock.ExitWriteLock(); } } } diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index e82ee778..5c45edca 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -10,6 +10,7 @@ using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; using StardewModdingAPI.Events; +using StardewModdingAPI.Framework.Events; using StardewModdingAPI.Framework.Input; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Utilities; @@ -35,6 +36,9 @@ namespace StardewModdingAPI.Framework /// <summary>Encapsulates monitoring and logging.</summary> private readonly IMonitor Monitor; + /// <summary>Manages SMAPI events for mods.</summary> + private readonly EventManager Events; + /// <summary>The maximum number of consecutive attempts SMAPI should make to recover from a draw error.</summary> private readonly Countdown DrawCrashTimer = new Countdown(60); // 60 ticks = roughly one second @@ -117,6 +121,9 @@ namespace StardewModdingAPI.Framework /// <summary>The current game instance.</summary> private static SGame Instance; + /// <summary>A callback to invoke after the game finishes initialising.</summary> + private readonly Action OnGameInitialised; + /**** ** Private wrappers ****/ @@ -132,9 +139,9 @@ namespace StardewModdingAPI.Framework set => SGame.Reflection.GetField<float>(typeof(Game1), nameof(_fps)).SetValue(value); } private static Task _newDayTask => SGame.Reflection.GetField<Task>(typeof(Game1), nameof(_newDayTask)).GetValue(); - private Color bgColor => SGame.Reflection.GetField<Color>(this, nameof(bgColor)).GetValue(); + private Color bgColor => SGame.Reflection.GetField<Color>(this, nameof(this.bgColor)).GetValue(); public RenderTarget2D screenWrapper => SGame.Reflection.GetProperty<RenderTarget2D>(this, "screen").GetValue(); // deliberately renamed to avoid an infinite loop - public BlendState lightingBlend => SGame.Reflection.GetField<BlendState>(this, nameof(lightingBlend)).GetValue(); + public BlendState lightingBlend => SGame.Reflection.GetField<BlendState>(this, nameof(this.lightingBlend)).GetValue(); private readonly Action drawFarmBuildings = () => SGame.Reflection.GetMethod(SGame.Instance, nameof(drawFarmBuildings)).Invoke(); private readonly Action drawHUD = () => SGame.Reflection.GetMethod(SGame.Instance, nameof(drawHUD)).Invoke(); private readonly Action drawDialogueBox = () => SGame.Reflection.GetMethod(SGame.Instance, nameof(drawDialogueBox)).Invoke(); @@ -158,13 +165,17 @@ namespace StardewModdingAPI.Framework /// <summary>Construct an instance.</summary> /// <param name="monitor">Encapsulates monitoring and logging.</param> /// <param name="reflection">Simplifies access to private game code.</param> - internal SGame(IMonitor monitor, Reflector reflection) + /// <param name="eventManager">Manages SMAPI events for mods.</param> + /// <param name="onGameInitialised">A callback to invoke after the game finishes initialising.</param> + internal SGame(IMonitor monitor, Reflector reflection, EventManager eventManager, Action onGameInitialised) { // initialise this.Monitor = monitor; + this.Events = eventManager; this.FirstUpdate = true; SGame.Instance = this; SGame.Reflection = reflection; + this.OnGameInitialised = onGameInitialised; // set XNA option required by Stardew Valley Game1.graphics.GraphicsProfile = GraphicsProfile.HiDef; @@ -229,6 +240,7 @@ namespace StardewModdingAPI.Framework if (SGame._newDayTask != null) { base.Update(gameTime); + this.Events.Specialised_UnvalidatedUpdateTick.Raise(); return; } @@ -236,6 +248,7 @@ namespace StardewModdingAPI.Framework if (Game1.gameMode == Game1.loadingMode) { base.Update(gameTime); + this.Events.Specialised_UnvalidatedUpdateTick.Raise(); return; } @@ -254,19 +267,20 @@ namespace StardewModdingAPI.Framework { this.IsBetweenCreateEvents = true; this.Monitor.Log("Context: before save creation.", LogLevel.Trace); - SaveEvents.InvokeBeforeCreate(this.Monitor); + this.Events.Save_BeforeCreate.Raise(); } - + // raise before-save if (Context.IsWorldReady && !this.IsBetweenSaveEvents) { this.IsBetweenSaveEvents = true; this.Monitor.Log("Context: before save.", LogLevel.Trace); - SaveEvents.InvokeBeforeSave(this.Monitor); + this.Events.Save_BeforeSave.Raise(); } // suppress non-save events base.Update(gameTime); + this.Events.Specialised_UnvalidatedUpdateTick.Raise(); return; } if (this.IsBetweenCreateEvents) @@ -274,24 +288,22 @@ 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); - SaveEvents.InvokeAfterCreated(this.Monitor); + this.Events.Save_AfterCreate.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); - SaveEvents.InvokeAfterSave(this.Monitor); - TimeEvents.InvokeAfterDayStarted(this.Monitor); + this.Events.Save_AfterSave.Raise(); + this.Events.Time_AfterDayStarted.Raise(); } /********* - ** Game loaded events + ** Notify SMAPI that game is initialised *********/ if (this.FirstUpdate) - { - GameEvents.InvokeInitialize(this.Monitor); - } + this.OnGameInitialised(); /********* ** Locale changed events @@ -304,7 +316,8 @@ namespace StardewModdingAPI.Framework this.Monitor.Log($"Context: locale set to {newValue}.", LogLevel.Trace); if (oldValue != null) - ContentEvents.InvokeAfterLocaleChanged(this.Monitor, oldValue.ToString(), newValue.ToString()); + this.Events.Content_LocaleChanged.Raise(new EventArgsValueChanged<string>(oldValue.ToString(), newValue.ToString())); + this.PreviousLocale = newValue; } @@ -321,8 +334,8 @@ namespace StardewModdingAPI.Framework this.Monitor.Log($"Context: loaded saved game '{Constants.SaveFolderName}', starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.", LogLevel.Trace); Context.IsWorldReady = true; - SaveEvents.InvokeAfterLoad(this.Monitor); - TimeEvents.InvokeAfterDayStarted(this.Monitor); + this.Events.Save_AfterLoad.Raise(); + this.Events.Time_AfterDayStarted.Raise(); } } @@ -340,7 +353,7 @@ namespace StardewModdingAPI.Framework this.IsExitingToTitle = false; this.CleanupAfterReturnToTitle(); - SaveEvents.InvokeAfterReturnToTitle(this.Monitor); + this.Events.Save_AfterReturnToTitle.Raise(); } /********* @@ -353,7 +366,7 @@ namespace StardewModdingAPI.Framework if (Game1.viewport.Width != this.PreviousWindowSize.X || Game1.viewport.Height != this.PreviousWindowSize.Y) { Point size = new Point(Game1.viewport.Width, Game1.viewport.Height); - GraphicsEvents.InvokeResize(this.Monitor); + this.Events.Graphics_Resize.Raise(); this.PreviousWindowSize = size; } @@ -393,47 +406,47 @@ namespace StardewModdingAPI.Framework if (status == InputStatus.Pressed) { - InputEvents.InvokeButtonPressed(this.Monitor, button, cursor, button.IsActionButton(), button.IsUseToolButton()); + this.Events.Input_ButtonPressed.Raise(new EventArgsInput(button, cursor, button.IsActionButton(), button.IsUseToolButton())); // legacy events if (button.TryGetKeyboard(out Keys key)) { if (key != Keys.None) - ControlEvents.InvokeKeyPressed(this.Monitor, key); + this.Events.Control_KeyPressed.Raise(new EventArgsKeyPressed(key)); } else if (button.TryGetController(out Buttons controllerButton)) { if (controllerButton == Buttons.LeftTrigger || controllerButton == Buttons.RightTrigger) - ControlEvents.InvokeTriggerPressed(this.Monitor, controllerButton, controllerButton == Buttons.LeftTrigger ? inputState.ControllerState.Triggers.Left : inputState.ControllerState.Triggers.Right); + this.Events.Control_ControllerTriggerPressed.Raise(new EventArgsControllerTriggerPressed(PlayerIndex.One, controllerButton, controllerButton == Buttons.LeftTrigger ? inputState.ControllerState.Triggers.Left : inputState.ControllerState.Triggers.Right)); else - ControlEvents.InvokeButtonPressed(this.Monitor, controllerButton); + this.Events.Control_ControllerButtonPressed.Raise(new EventArgsControllerButtonPressed(PlayerIndex.One, controllerButton)); } } else if (status == InputStatus.Released) { - InputEvents.InvokeButtonReleased(this.Monitor, button, cursor, button.IsActionButton(), button.IsUseToolButton()); + this.Events.Input_ButtonReleased.Raise(new EventArgsInput(button, cursor, button.IsActionButton(), button.IsUseToolButton())); // legacy events if (button.TryGetKeyboard(out Keys key)) { if (key != Keys.None) - ControlEvents.InvokeKeyReleased(this.Monitor, key); + this.Events.Control_KeyReleased.Raise(new EventArgsKeyPressed(key)); } else if (button.TryGetController(out Buttons controllerButton)) { if (controllerButton == Buttons.LeftTrigger || controllerButton == Buttons.RightTrigger) - ControlEvents.InvokeTriggerReleased(this.Monitor, controllerButton, controllerButton == Buttons.LeftTrigger ? inputState.ControllerState.Triggers.Left : inputState.ControllerState.Triggers.Right); + this.Events.Control_ControllerTriggerReleased.Raise(new EventArgsControllerTriggerReleased(PlayerIndex.One, controllerButton, controllerButton == Buttons.LeftTrigger ? inputState.ControllerState.Triggers.Left : inputState.ControllerState.Triggers.Right)); else - ControlEvents.InvokeButtonReleased(this.Monitor, controllerButton); + this.Events.Control_ControllerButtonReleased.Raise(new EventArgsControllerButtonReleased(PlayerIndex.One, controllerButton)); } } } // raise legacy state-changed events if (inputState.KeyboardState != this.PreviousInput.KeyboardState) - ControlEvents.InvokeKeyboardChanged(this.Monitor, this.PreviousInput.KeyboardState, inputState.KeyboardState); + this.Events.Control_KeyboardChanged.Raise(new EventArgsKeyboardStateChanged(this.PreviousInput.KeyboardState, inputState.KeyboardState)); if (inputState.MouseState != this.PreviousInput.MouseState) - ControlEvents.InvokeMouseChanged(this.Monitor, this.PreviousInput.MouseState, inputState.MouseState, this.PreviousInput.MousePosition, inputState.MousePosition); + this.Events.Control_MouseChanged.Raise(new EventArgsMouseStateChanged(this.PreviousInput.MouseState, inputState.MouseState, this.PreviousInput.MousePosition, inputState.MousePosition)); // track state this.PreviousInput = inputState; @@ -460,9 +473,9 @@ namespace StardewModdingAPI.Framework // raise menu events if (newMenu != null) - MenuEvents.InvokeMenuChanged(this.Monitor, previousMenu, newMenu); + this.Events.Menu_Changed.Raise(new EventArgsClickableMenuChanged(previousMenu, newMenu)); else - MenuEvents.InvokeMenuClosed(this.Monitor, previousMenu); + this.Events.Menu_Closed.Raise(new EventArgsClickableMenuClosed(previousMenu)); // update previous menu // (if the menu was changed in one of the handlers, deliberately defer detection until the next update so mods can be notified of the new menu change) @@ -479,46 +492,46 @@ namespace StardewModdingAPI.Framework { if (this.VerboseLogging) this.Monitor.Log($"Context: set location to {Game1.currentLocation?.Name ?? "(none)"}.", LogLevel.Trace); - LocationEvents.InvokeCurrentLocationChanged(this.Monitor, this.PreviousGameLocation, Game1.currentLocation); + this.Events.Location_CurrentLocationChanged.Raise(new EventArgsCurrentLocationChanged(this.PreviousGameLocation, Game1.currentLocation)); } // raise location list changed if (this.GetHash(Game1.locations) != this.PreviousGameLocations) - LocationEvents.InvokeLocationsChanged(this.Monitor, Game1.locations); + this.Events.Location_LocationsChanged.Raise(new EventArgsGameLocationsChanged(Game1.locations)); // raise events that shouldn't be triggered on initial load if (Game1.uniqueIDForThisGame == this.PreviousSaveID) { // raise player leveled up a skill if (Game1.player.combatLevel != this.PreviousCombatLevel) - PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Combat, Game1.player.combatLevel); + this.Events.Player_LeveledUp.Raise(new EventArgsLevelUp(EventArgsLevelUp.LevelType.Combat, Game1.player.combatLevel)); if (Game1.player.farmingLevel != this.PreviousFarmingLevel) - PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Farming, Game1.player.farmingLevel); + this.Events.Player_LeveledUp.Raise(new EventArgsLevelUp(EventArgsLevelUp.LevelType.Farming, Game1.player.farmingLevel)); if (Game1.player.fishingLevel != this.PreviousFishingLevel) - PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Fishing, Game1.player.fishingLevel); + this.Events.Player_LeveledUp.Raise(new EventArgsLevelUp(EventArgsLevelUp.LevelType.Fishing, Game1.player.fishingLevel)); if (Game1.player.foragingLevel != this.PreviousForagingLevel) - PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Foraging, Game1.player.foragingLevel); + this.Events.Player_LeveledUp.Raise(new EventArgsLevelUp(EventArgsLevelUp.LevelType.Foraging, Game1.player.foragingLevel)); if (Game1.player.miningLevel != this.PreviousMiningLevel) - PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Mining, Game1.player.miningLevel); + this.Events.Player_LeveledUp.Raise(new EventArgsLevelUp(EventArgsLevelUp.LevelType.Mining, Game1.player.miningLevel)); if (Game1.player.luckLevel != this.PreviousLuckLevel) - PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Luck, Game1.player.luckLevel); + this.Events.Player_LeveledUp.Raise(new EventArgsLevelUp(EventArgsLevelUp.LevelType.Luck, Game1.player.luckLevel)); // raise player inventory changed ItemStackChange[] changedItems = this.GetInventoryChanges(Game1.player.items, this.PreviousItems).ToArray(); if (changedItems.Any()) - PlayerEvents.InvokeInventoryChanged(this.Monitor, Game1.player.items, changedItems); + this.Events.Player_InventoryChanged.Raise(new EventArgsInventoryChanged(Game1.player.items, changedItems.ToList())); // raise current location's object list changed if (this.GetHash(Game1.currentLocation.objects) != this.PreviousLocationObjects) - LocationEvents.InvokeOnNewLocationObject(this.Monitor, Game1.currentLocation.objects); + this.Events.Location_LocationObjectsChanged.Raise(new EventArgsLocationObjectsChanged(Game1.currentLocation.objects)); // raise time changed if (Game1.timeOfDay != this.PreviousTime) - TimeEvents.InvokeTimeOfDayChanged(this.Monitor, this.PreviousTime, Game1.timeOfDay); + this.Events.Time_TimeOfDayChanged.Raise(new EventArgsIntChanged(this.PreviousTime, Game1.timeOfDay)); // raise mine level changed if (Game1.mine != null && Game1.mine.mineLevel != this.PreviousMineLevel) - MineEvents.InvokeMineLevelChanged(this.Monitor, this.PreviousMineLevel, Game1.mine.mineLevel); + this.Events.Mine_LevelChanged.Raise(new EventArgsMineLevelChanged(this.PreviousMineLevel, Game1.mine.mineLevel)); } // update state @@ -552,24 +565,25 @@ namespace StardewModdingAPI.Framework /********* ** Update events *********/ + this.Events.Specialised_UnvalidatedUpdateTick.Raise(); if (this.FirstUpdate) { this.FirstUpdate = false; - GameEvents.InvokeFirstUpdateTick(this.Monitor); + this.Events.Game_FirstUpdateTick.Raise(); } - GameEvents.InvokeUpdateTick(this.Monitor); + this.Events.Game_UpdateTick.Raise(); if (this.CurrentUpdateTick % 2 == 0) - GameEvents.InvokeSecondUpdateTick(this.Monitor); + this.Events.Game_SecondUpdateTick.Raise(); if (this.CurrentUpdateTick % 4 == 0) - GameEvents.InvokeFourthUpdateTick(this.Monitor); + this.Events.Game_FourthUpdateTick.Raise(); if (this.CurrentUpdateTick % 8 == 0) - GameEvents.InvokeEighthUpdateTick(this.Monitor); + this.Events.Game_EighthUpdateTick.Raise(); if (this.CurrentUpdateTick % 15 == 0) - GameEvents.InvokeQuarterSecondTick(this.Monitor); + this.Events.Game_QuarterSecondTick.Raise(); if (this.CurrentUpdateTick % 30 == 0) - GameEvents.InvokeHalfSecondTick(this.Monitor); + this.Events.Game_HalfSecondTick.Raise(); if (this.CurrentUpdateTick % 60 == 0) - GameEvents.InvokeOneSecondTick(this.Monitor); + this.Events.Game_OneSecondTick.Raise(); this.CurrentUpdateTick += 1; if (this.CurrentUpdateTick >= 60) this.CurrentUpdateTick = 0; @@ -678,9 +692,9 @@ namespace StardewModdingAPI.Framework Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); try { - GraphicsEvents.InvokeOnPreRenderGuiEvent(this.Monitor); + this.Events.Graphics_OnPreRenderGuiEvent.Raise(); activeClickableMenu.draw(Game1.spriteBatch); - GraphicsEvents.InvokeOnPostRenderGuiEvent(this.Monitor); + this.Events.Graphics_OnPostRenderGuiEvent.Raise(); } catch (Exception ex) { @@ -702,9 +716,9 @@ namespace StardewModdingAPI.Framework try { Game1.activeClickableMenu.drawBackground(Game1.spriteBatch); - GraphicsEvents.InvokeOnPreRenderGuiEvent(this.Monitor); + this.Events.Graphics_OnPreRenderGuiEvent.Raise(); Game1.activeClickableMenu.draw(Game1.spriteBatch); - GraphicsEvents.InvokeOnPostRenderGuiEvent(this.Monitor); + this.Events.Graphics_OnPostRenderGuiEvent.Raise(); } catch (Exception ex) { @@ -769,9 +783,9 @@ namespace StardewModdingAPI.Framework { try { - GraphicsEvents.InvokeOnPreRenderGuiEvent(this.Monitor); + this.Events.Graphics_OnPreRenderGuiEvent.Raise(); Game1.activeClickableMenu.draw(Game1.spriteBatch); - GraphicsEvents.InvokeOnPostRenderGuiEvent(this.Monitor); + this.Events.Graphics_OnPostRenderGuiEvent.Raise(); } catch (Exception ex) { @@ -856,7 +870,7 @@ namespace StardewModdingAPI.Framework Game1.bloom.BeginDraw(); this.GraphicsDevice.Clear(this.bgColor); Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - GraphicsEvents.InvokeOnPreRenderEvent(this.Monitor); + this.Events.Graphics_OnPreRenderEvent.Raise(); if (Game1.background != null) Game1.background.draw(Game1.spriteBatch); Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch); @@ -1127,9 +1141,9 @@ namespace StardewModdingAPI.Framework this.drawBillboard(); if ((Game1.displayHUD || Game1.eventUp) && (Game1.currentBillboard == 0 && (int)Game1.gameMode == 3) && (!Game1.freezeControls && !Game1.panMode)) { - GraphicsEvents.InvokeOnPreRenderHudEvent(this.Monitor); + this.Events.Graphics_OnPreRenderHudEvent.Raise(); this.drawHUD(); - GraphicsEvents.InvokeOnPostRenderHudEvent(this.Monitor); + this.Events.Graphics_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); @@ -1261,9 +1275,9 @@ namespace StardewModdingAPI.Framework { try { - GraphicsEvents.InvokeOnPreRenderGuiEvent(this.Monitor); + this.Events.Graphics_OnPreRenderGuiEvent.Raise(); Game1.activeClickableMenu.draw(Game1.spriteBatch); - GraphicsEvents.InvokeOnPostRenderGuiEvent(this.Monitor); + this.Events.Graphics_OnPostRenderGuiEvent.Raise(); } catch (Exception ex) { @@ -1348,11 +1362,11 @@ namespace StardewModdingAPI.Framework /// <param name="needsNewBatch">Whether to create a new sprite batch.</param> private void RaisePostRender(bool needsNewBatch = false) { - if (GraphicsEvents.HasPostRenderListeners()) + if (this.Events.Graphics_OnPostRenderEvent.HasListeners()) { if (needsNewBatch) Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); - GraphicsEvents.InvokeOnPostRenderEvent(this.Monitor); + this.Events.Graphics_OnPostRenderEvent.Raise(); if (needsNewBatch) Game1.spriteBatch.End(); } diff --git a/src/SMAPI/Framework/Serialisation/CrossplatformConverters/ColorConverter.cs b/src/SMAPI/Framework/Serialisation/CrossplatformConverters/ColorConverter.cs index f4a2a26e..f1b2f04f 100644 --- a/src/SMAPI/Framework/Serialisation/CrossplatformConverters/ColorConverter.cs +++ b/src/SMAPI/Framework/Serialisation/CrossplatformConverters/ColorConverter.cs @@ -20,10 +20,10 @@ namespace StardewModdingAPI.Framework.Serialisation.CrossplatformConverters /// <param name="path">The path to the current JSON node.</param> protected override Color ReadObject(JObject obj, string path) { - int r = obj.Value<int>(nameof(Color.R)); - int g = obj.Value<int>(nameof(Color.G)); - int b = obj.Value<int>(nameof(Color.B)); - int a = obj.Value<int>(nameof(Color.A)); + int r = obj.ValueIgnoreCase<int>(nameof(Color.R)); + int g = obj.ValueIgnoreCase<int>(nameof(Color.G)); + int b = obj.ValueIgnoreCase<int>(nameof(Color.B)); + int a = obj.ValueIgnoreCase<int>(nameof(Color.A)); return new Color(r, g, b, a); } diff --git a/src/SMAPI/Framework/Serialisation/CrossplatformConverters/PointConverter.cs b/src/SMAPI/Framework/Serialisation/CrossplatformConverters/PointConverter.cs index 84c70989..434b7ea5 100644 --- a/src/SMAPI/Framework/Serialisation/CrossplatformConverters/PointConverter.cs +++ b/src/SMAPI/Framework/Serialisation/CrossplatformConverters/PointConverter.cs @@ -20,8 +20,8 @@ namespace StardewModdingAPI.Framework.Serialisation.CrossplatformConverters /// <param name="path">The path to the current JSON node.</param> protected override Point ReadObject(JObject obj, string path) { - int x = obj.Value<int>(nameof(Point.X)); - int y = obj.Value<int>(nameof(Point.Y)); + int x = obj.ValueIgnoreCase<int>(nameof(Point.X)); + int y = obj.ValueIgnoreCase<int>(nameof(Point.Y)); return new Point(x, y); } diff --git a/src/SMAPI/Framework/Serialisation/CrossplatformConverters/RectangleConverter.cs b/src/SMAPI/Framework/Serialisation/CrossplatformConverters/RectangleConverter.cs index b89551e3..62bc8637 100644 --- a/src/SMAPI/Framework/Serialisation/CrossplatformConverters/RectangleConverter.cs +++ b/src/SMAPI/Framework/Serialisation/CrossplatformConverters/RectangleConverter.cs @@ -21,10 +21,10 @@ namespace StardewModdingAPI.Framework.Serialisation.CrossplatformConverters /// <param name="path">The path to the current JSON node.</param> protected override Rectangle ReadObject(JObject obj, string path) { - int x = obj.Value<int>(nameof(Rectangle.X)); - int y = obj.Value<int>(nameof(Rectangle.Y)); - int width = obj.Value<int>(nameof(Rectangle.Width)); - int height = obj.Value<int>(nameof(Rectangle.Height)); + int x = obj.ValueIgnoreCase<int>(nameof(Rectangle.X)); + int y = obj.ValueIgnoreCase<int>(nameof(Rectangle.Y)); + int width = obj.ValueIgnoreCase<int>(nameof(Rectangle.Width)); + int height = obj.ValueIgnoreCase<int>(nameof(Rectangle.Height)); return new Rectangle(x, y, width, height); } @@ -36,7 +36,7 @@ namespace StardewModdingAPI.Framework.Serialisation.CrossplatformConverters if (string.IsNullOrWhiteSpace(str)) return Rectangle.Empty; - var match = Regex.Match(str, @"^\{X:(?<x>\d+) Y:(?<y>\d+) Width:(?<width>\d+) Height:(?<height>\d+)\}$"); + var match = Regex.Match(str, @"^\{X:(?<x>\d+) Y:(?<y>\d+) Width:(?<width>\d+) Height:(?<height>\d+)\}$", RegexOptions.IgnoreCase); if (!match.Success) throw new SParseException($"Can't parse {typeof(Rectangle).Name} from invalid value '{str}' (path: {path})."); diff --git a/src/SMAPI/Framework/Serialisation/JsonHelper.cs b/src/SMAPI/Framework/Serialisation/JsonHelper.cs index 2e2a666e..6cba343e 100644 --- a/src/SMAPI/Framework/Serialisation/JsonHelper.cs +++ b/src/SMAPI/Framework/Serialisation/JsonHelper.cs @@ -21,6 +21,9 @@ namespace StardewModdingAPI.Framework.Serialisation ObjectCreationHandling = ObjectCreationHandling.Replace, // avoid issue where default ICollection<T> values are duplicated each time the config is loaded Converters = new List<JsonConverter> { + // SMAPI types + new SemanticVersionConverter(), + // enums new StringEnumConverter<Buttons>(), new StringEnumConverter<Keys>(), diff --git a/src/SMAPI/Framework/Serialisation/SmapiConverters/ModCompatibilityArrayConverter.cs b/src/SMAPI/Framework/Serialisation/SmapiConverters/ManifestContentPackForConverter.cs index 3232dde4..af7558f6 100644 --- a/src/SMAPI/Framework/Serialisation/SmapiConverters/ModCompatibilityArrayConverter.cs +++ b/src/SMAPI/Framework/Serialisation/SmapiConverters/ManifestContentPackForConverter.cs @@ -1,13 +1,11 @@ using System; -using System.Collections.Generic; using Newtonsoft.Json; -using Newtonsoft.Json.Linq; using StardewModdingAPI.Framework.Models; namespace StardewModdingAPI.Framework.Serialisation.SmapiConverters { - /// <summary>Handles deserialisation of <see cref="ModCompatibility"/> arrays.</summary> - internal class ModCompatibilityArrayConverter : JsonConverter + /// <summary>Handles deserialisation of <see cref="IManifestContentPackFor"/> arrays.</summary> + internal class ManifestContentPackForConverter : JsonConverter { /********* ** Accessors @@ -23,7 +21,7 @@ namespace StardewModdingAPI.Framework.Serialisation.SmapiConverters /// <param name="objectType">The object type.</param> public override bool CanConvert(Type objectType) { - return objectType == typeof(ModCompatibility[]); + return objectType == typeof(IManifestContentPackFor[]); } @@ -37,16 +35,7 @@ namespace StardewModdingAPI.Framework.Serialisation.SmapiConverters /// <param name="serializer">The calling serializer.</param> public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) { - List<ModCompatibility> result = new List<ModCompatibility>(); - foreach (JProperty property in JObject.Load(reader).Properties()) - { - string range = property.Name; - ModStatus status = (ModStatus)Enum.Parse(typeof(ModStatus), property.Value.Value<string>(nameof(ModCompatibility.Status))); - string reasonPhrase = property.Value.Value<string>(nameof(ModCompatibility.ReasonPhrase)); - - result.Add(new ModCompatibility(range, status, reasonPhrase)); - } - return result.ToArray(); + return serializer.Deserialize<ManifestContentPackFor>(reader); } /// <summary>Writes the JSON representation of the object.</summary> diff --git a/src/SMAPI/Framework/Serialisation/SmapiConverters/ManifestDependencyArrayConverter.cs b/src/SMAPI/Framework/Serialisation/SmapiConverters/ManifestDependencyArrayConverter.cs index 6352e367..4150d5fb 100644 --- a/src/SMAPI/Framework/Serialisation/SmapiConverters/ManifestDependencyArrayConverter.cs +++ b/src/SMAPI/Framework/Serialisation/SmapiConverters/ManifestDependencyArrayConverter.cs @@ -40,9 +40,9 @@ namespace StardewModdingAPI.Framework.Serialisation.SmapiConverters List<IManifestDependency> result = new List<IManifestDependency>(); foreach (JObject obj in JArray.Load(reader).Children<JObject>()) { - string uniqueID = obj.Value<string>(nameof(IManifestDependency.UniqueID)); - string minVersion = obj.Value<string>(nameof(IManifestDependency.MinimumVersion)); - bool required = obj.Value<bool?>(nameof(IManifestDependency.IsRequired)) ?? true; + string uniqueID = obj.ValueIgnoreCase<string>(nameof(IManifestDependency.UniqueID)); + string minVersion = obj.ValueIgnoreCase<string>(nameof(IManifestDependency.MinimumVersion)); + bool required = obj.ValueIgnoreCase<bool?>(nameof(IManifestDependency.IsRequired)) ?? true; result.Add(new ManifestDependency(uniqueID, minVersion, required)); } return result.ToArray(); diff --git a/src/SMAPI/Framework/Serialisation/SmapiConverters/ModDataIdConverter.cs b/src/SMAPI/Framework/Serialisation/SmapiConverters/ModDataIdConverter.cs deleted file mode 100644 index 8a10db47..00000000 --- a/src/SMAPI/Framework/Serialisation/SmapiConverters/ModDataIdConverter.cs +++ /dev/null @@ -1,19 +0,0 @@ -using StardewModdingAPI.Framework.Models; - -namespace StardewModdingAPI.Framework.Serialisation.SmapiConverters -{ - /// <summary>Handles deserialisation of <see cref="ModDataID"/>.</summary> - internal class ModDataIdConverter : SimpleReadOnlyConverter<ModDataID> - { - /********* - ** Protected methods - *********/ - /// <summary>Read a JSON string.</summary> - /// <param name="str">The JSON string value.</param> - /// <param name="path">The path to the current JSON node.</param> - protected override ModDataID ReadString(string str, string path) - { - return new ModDataID(str); - } - } -} diff --git a/src/SMAPI/Framework/Serialisation/SmapiConverters/SemanticVersionConverter.cs b/src/SMAPI/Framework/Serialisation/SmapiConverters/SemanticVersionConverter.cs index 50181809..7ee7e29b 100644 --- a/src/SMAPI/Framework/Serialisation/SmapiConverters/SemanticVersionConverter.cs +++ b/src/SMAPI/Framework/Serialisation/SmapiConverters/SemanticVersionConverter.cs @@ -14,10 +14,10 @@ namespace StardewModdingAPI.Framework.Serialisation.SmapiConverters /// <param name="path">The path to the current JSON node.</param> protected override ISemanticVersion ReadObject(JObject obj, string path) { - int major = obj.Value<int>(nameof(ISemanticVersion.MajorVersion)); - int minor = obj.Value<int>(nameof(ISemanticVersion.MinorVersion)); - int patch = obj.Value<int>(nameof(ISemanticVersion.PatchVersion)); - string build = obj.Value<string>(nameof(ISemanticVersion.Build)); + int major = obj.ValueIgnoreCase<int>(nameof(ISemanticVersion.MajorVersion)); + int minor = obj.ValueIgnoreCase<int>(nameof(ISemanticVersion.MinorVersion)); + int patch = obj.ValueIgnoreCase<int>(nameof(ISemanticVersion.PatchVersion)); + string build = obj.ValueIgnoreCase<string>(nameof(ISemanticVersion.Build)); return new LegacyManifestVersion(major, minor, patch, build); } diff --git a/src/SMAPI/Framework/Utilities/PathUtilities.cs b/src/SMAPI/Framework/Utilities/PathUtilities.cs new file mode 100644 index 00000000..0233d796 --- /dev/null +++ b/src/SMAPI/Framework/Utilities/PathUtilities.cs @@ -0,0 +1,62 @@ +using System; +using System.Diagnostics.Contracts; +using System.IO; +using System.Linq; + +namespace StardewModdingAPI.Framework.Utilities +{ + /// <summary>Provides utilities for normalising file paths.</summary> + internal static class PathUtilities + { + /********* + ** Properties + *********/ + /// <summary>The possible directory separator characters in a file path.</summary> + private static readonly char[] PossiblePathSeparators = new[] { '/', '\\', Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }.Distinct().ToArray(); + + /// <summary>The preferred directory separator chaeacter in an asset key.</summary> + private static readonly string PreferredPathSeparator = Path.DirectorySeparatorChar.ToString(); + + + /********* + ** Public methods + *********/ + /// <summary>Get the segments from a path (e.g. <c>/usr/bin/boop</c> => <c>usr</c>, <c>bin</c>, and <c>boop</c>).</summary> + /// <param name="path">The path to split.</param> + public static string[] GetSegments(string path) + { + return path.Split(PathUtilities.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries); + } + + /// <summary>Normalise path separators in a file path.</summary> + /// <param name="path">The file path to normalise.</param> + [Pure] + public static string NormalisePathSeparators(string path) + { + string[] parts = PathUtilities.GetSegments(path); + string normalised = string.Join(PathUtilities.PreferredPathSeparator, parts); + if (path.StartsWith(PathUtilities.PreferredPathSeparator)) + normalised = PathUtilities.PreferredPathSeparator + normalised; // keep root slash + return normalised; + } + + /// <summary>Get a directory or file path relative to a given source path.</summary> + /// <param name="sourceDir">The source folder path.</param> + /// <param name="targetPath">The target folder or file path.</param> + [Pure] + public static string GetRelativePath(string sourceDir, string targetPath) + { + // convert to URIs + Uri from = new Uri(sourceDir.TrimEnd(PathUtilities.PossiblePathSeparators) + "/"); + Uri to = new Uri(targetPath.TrimEnd(PathUtilities.PossiblePathSeparators) + "/"); + if (from.Scheme != to.Scheme) + throw new InvalidOperationException($"Can't get path for '{targetPath}' relative to '{sourceDir}'."); + + // get relative path + string relative = PathUtilities.NormalisePathSeparators(Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString())); + if (relative == "") + relative = "./"; + return relative; + } + } +} |