From eead352af26d0fcc5cac147d0eb5ec384854d931 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 21 Apr 2018 20:37:17 -0400 Subject: rewrite world/player state tracking (#453) --- .../StateTracking/Comparers/EquatableComparer.cs | 32 ++++++++++++++++++++++ .../Comparers/ObjectReferenceComparer.cs | 29 ++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 src/SMAPI/Framework/StateTracking/Comparers/EquatableComparer.cs create mode 100644 src/SMAPI/Framework/StateTracking/Comparers/ObjectReferenceComparer.cs (limited to 'src/SMAPI/Framework/StateTracking/Comparers') diff --git a/src/SMAPI/Framework/StateTracking/Comparers/EquatableComparer.cs b/src/SMAPI/Framework/StateTracking/Comparers/EquatableComparer.cs new file mode 100644 index 00000000..a96ffdb6 --- /dev/null +++ b/src/SMAPI/Framework/StateTracking/Comparers/EquatableComparer.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace StardewModdingAPI.Framework.StateTracking.Comparers +{ + /// Compares instances using . + /// The value type. + internal class EquatableComparer : IEqualityComparer where T : IEquatable + { + /********* + ** Public methods + *********/ + /// Determines whether the specified objects are equal. + /// true if the specified objects are equal; otherwise, false. + /// The first object to compare. + /// The second object to compare. + public bool Equals(T x, T y) + { + if (x == null) + return y == null; + return x.Equals(y); + } + + /// Get a hash code for the specified object. + /// The value. + public int GetHashCode(T obj) + { + return RuntimeHelpers.GetHashCode(obj); + } + } +} diff --git a/src/SMAPI/Framework/StateTracking/Comparers/ObjectReferenceComparer.cs b/src/SMAPI/Framework/StateTracking/Comparers/ObjectReferenceComparer.cs new file mode 100644 index 00000000..ef9adafb --- /dev/null +++ b/src/SMAPI/Framework/StateTracking/Comparers/ObjectReferenceComparer.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace StardewModdingAPI.Framework.StateTracking.Comparers +{ + /// A comparer which considers two references equal if they point to the same instance. + /// The value type. + internal class ObjectReferenceComparer : IEqualityComparer + { + /********* + ** Public methods + *********/ + /// Determines whether the specified objects are equal. + /// true if the specified objects are equal; otherwise, false. + /// The first object to compare. + /// The second object to compare. + public bool Equals(T x, T y) + { + return object.ReferenceEquals(x, y); + } + + /// Get a hash code for the specified object. + /// The value. + public int GetHashCode(T obj) + { + return RuntimeHelpers.GetHashCode(obj); + } + } +} -- cgit From 235d67623de648499db521606e4b9033d35388e5 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 10 Jun 2018 12:06:29 -0400 Subject: create watcher core (#310) --- src/SMAPI/Framework/CursorPosition.cs | 7 + src/SMAPI/Framework/SGame.cs | 169 ++++++--------------- .../Comparers/GenericEqualsComparer.cs | 31 ++++ .../StateTracking/FieldWatchers/WatcherFactory.cs | 8 + src/SMAPI/Framework/WatcherCore.cs | 119 +++++++++++++++ src/SMAPI/ICursorPosition.cs | 3 +- src/SMAPI/StardewModdingAPI.csproj | 2 + 7 files changed, 219 insertions(+), 120 deletions(-) create mode 100644 src/SMAPI/Framework/StateTracking/Comparers/GenericEqualsComparer.cs create mode 100644 src/SMAPI/Framework/WatcherCore.cs (limited to 'src/SMAPI/Framework/StateTracking/Comparers') diff --git a/src/SMAPI/Framework/CursorPosition.cs b/src/SMAPI/Framework/CursorPosition.cs index 6f716746..aaf089d3 100644 --- a/src/SMAPI/Framework/CursorPosition.cs +++ b/src/SMAPI/Framework/CursorPosition.cs @@ -36,5 +36,12 @@ namespace StardewModdingAPI.Framework this.Tile = tile; this.GrabTile = grabTile; } + + /// Get whether the current object is equal to another object of the same type. + /// An object to compare with this object. + public bool Equals(ICursorPosition other) + { + return other != null && this.ScreenPixels == other.ScreenPixels; + } } } diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index a4d149f3..588d30c8 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -14,7 +14,6 @@ using StardewModdingAPI.Framework.Events; using StardewModdingAPI.Framework.Input; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.StateTracking; -using StardewModdingAPI.Framework.StateTracking.FieldWatchers; using StardewModdingAPI.Framework.Utilities; using StardewValley; using StardewValley.BellsAndWhistles; @@ -85,38 +84,8 @@ namespace StardewModdingAPI.Framework /**** ** Game state ****/ - /// The underlying watchers for convenience. These are accessible individually as separate properties. - private readonly List Watchers = new List(); - - /// Tracks changes to the window size. - private IValueWatcher WindowSizeWatcher; - - /// Tracks changes to the current player. - private PlayerTracker CurrentPlayerTracker; - - /// Tracks changes to the time of day (in 24-hour military format). - private IValueWatcher TimeWatcher; - - /// Tracks changes to the save ID. - private IValueWatcher SaveIdWatcher; - - /// Tracks changes to the game's locations. - private WorldLocationsTracker LocationsWatcher; - - /// Tracks changes to . - private IValueWatcher ActiveMenuWatcher; - - /// Tracks changes to the cursor position. - private IValueWatcher CursorWatcher; - - /// Tracks changes to the mouse wheel scroll. - private IValueWatcher MouseWheelScrollWatcher; - - /// The previous content locale. - private LocalizedContentManager.LanguageCode? PreviousLocale; - - /// The previous cursor position. - private ICursorPosition PreviousCursorPosition; + /// Monitors the entire game state for changes. + private WatcherCore Watchers; /// An index incremented on every tick and reset every 60th tick (0–59). private int CurrentUpdateTick; @@ -186,23 +155,7 @@ namespace StardewModdingAPI.Framework this.Input.TrueUpdate(); // init watchers - this.CursorWatcher = WatcherFactory.ForEquatable(() => this.Input.CursorPosition.ScreenPixels); - this.MouseWheelScrollWatcher = WatcherFactory.ForEquatable(() => this.Input.RealMouse.ScrollWheelValue); - this.SaveIdWatcher = WatcherFactory.ForEquatable(() => Game1.hasLoadedGame ? Game1.uniqueIDForThisGame : 0); - this.WindowSizeWatcher = WatcherFactory.ForEquatable(() => new Point(Game1.viewport.Width, Game1.viewport.Height)); - this.TimeWatcher = WatcherFactory.ForEquatable(() => Game1.timeOfDay); - this.ActiveMenuWatcher = WatcherFactory.ForReference(() => Game1.activeClickableMenu); - this.LocationsWatcher = new WorldLocationsTracker((ObservableCollection)Game1.locations); - this.Watchers.AddRange(new IWatcher[] - { - this.CursorWatcher, - this.MouseWheelScrollWatcher, - this.SaveIdWatcher, - this.WindowSizeWatcher, - this.TimeWatcher, - this.ActiveMenuWatcher, - this.LocationsWatcher - }); + this.Watchers = new WatcherCore(this.Input); // raise callback this.OnGameInitialised(); @@ -372,44 +325,20 @@ namespace StardewModdingAPI.Framework /********* ** Update watchers *********/ - // reset player - if (Context.IsWorldReady) - { - if (this.CurrentPlayerTracker == null || this.CurrentPlayerTracker.Player != Game1.player) - { - this.CurrentPlayerTracker?.Dispose(); - this.CurrentPlayerTracker = new PlayerTracker(Game1.player); - } - } - else - { - if (this.CurrentPlayerTracker != null) - { - this.CurrentPlayerTracker.Dispose(); - this.CurrentPlayerTracker = null; - } - } - - // update values - foreach (IWatcher watcher in this.Watchers) - watcher.Update(); - this.CurrentPlayerTracker?.Update(); - this.LocationsWatcher.Update(); + this.Watchers.Update(); /********* ** Locale changed events *********/ - if (this.PreviousLocale != LocalizedContentManager.CurrentLanguageCode) + if (this.Watchers.LocaleWatcher.IsChanged) { - var oldValue = this.PreviousLocale; - var newValue = LocalizedContentManager.CurrentLanguageCode; - - this.Monitor.Log($"Context: locale set to {newValue}.", LogLevel.Trace); + var was = this.Watchers.LocaleWatcher.PreviousValue; + var now = this.Watchers.LocaleWatcher.CurrentValue; - if (oldValue != null) - this.Events.Content_LocaleChanged.Raise(new EventArgsValueChanged(oldValue.ToString(), newValue.ToString())); + this.Monitor.Log($"Context: locale set to {now}.", LogLevel.Trace); + this.Events.Content_LocaleChanged.Raise(new EventArgsValueChanged(was.ToString(), now.ToString())); - this.PreviousLocale = newValue; + this.Watchers.LocaleWatcher.Reset(); } /********* @@ -450,12 +379,12 @@ namespace StardewModdingAPI.Framework // event because we need to notify mods after the game handles the resize, so the // game's metadata (like Game1.viewport) are updated. That's a bit complicated // since the game adds & removes its own handler on the fly. - if (this.WindowSizeWatcher.IsChanged) + if (this.Watchers.WindowSizeWatcher.IsChanged) { if (this.VerboseLogging) - this.Monitor.Log($"Context: window size changed to {this.WindowSizeWatcher.CurrentValue}.", LogLevel.Trace); + this.Monitor.Log($"Context: window size changed to {this.Watchers.WindowSizeWatcher.CurrentValue}.", LogLevel.Trace); this.Events.Graphics_Resize.Raise(); - this.WindowSizeWatcher.Reset(); + this.Watchers.WindowSizeWatcher.Reset(); } /********* @@ -470,21 +399,23 @@ namespace StardewModdingAPI.Framework ICursorPosition cursor = this.Input.CursorPosition; // raise cursor moved event - if (this.CursorWatcher.IsChanged && this.PreviousCursorPosition != null) + if (this.Watchers.CursorWatcher.IsChanged) { - this.CursorWatcher.Reset(); - this.Events.Input_CursorMoved.Raise(new InputCursorMovedArgsInput(this.PreviousCursorPosition, cursor)); + ICursorPosition was = this.Watchers.CursorWatcher.PreviousValue; + ICursorPosition now = this.Watchers.CursorWatcher.CurrentValue; + this.Watchers.CursorWatcher.Reset(); + + this.Events.Input_CursorMoved.Raise(new InputCursorMovedArgsInput(was, now)); } - this.PreviousCursorPosition = cursor; // raise mouse wheel scrolled - if (this.MouseWheelScrollWatcher.IsChanged) + if (this.Watchers.MouseWheelScrollWatcher.IsChanged) { - int oldValue = this.MouseWheelScrollWatcher.PreviousValue; - int newValue = this.MouseWheelScrollWatcher.CurrentValue; - this.MouseWheelScrollWatcher.Reset(); + int was = this.Watchers.MouseWheelScrollWatcher.PreviousValue; + int now = this.Watchers.MouseWheelScrollWatcher.CurrentValue; + this.Watchers.MouseWheelScrollWatcher.Reset(); - this.Events.Input_MouseWheelScrolled.Raise(new InputMouseWheelScrolledEventArgs(cursor, oldValue, newValue)); + this.Events.Input_MouseWheelScrolled.Raise(new InputMouseWheelScrolledEventArgs(cursor, was, now)); } // raise input button events @@ -544,20 +475,20 @@ namespace StardewModdingAPI.Framework /********* ** Menu events *********/ - if (this.ActiveMenuWatcher.IsChanged) + if (this.Watchers.ActiveMenuWatcher.IsChanged) { - IClickableMenu previousMenu = this.ActiveMenuWatcher.PreviousValue; - IClickableMenu newMenu = this.ActiveMenuWatcher.CurrentValue; - this.ActiveMenuWatcher.Reset(); // reset here so a mod changing the menu will be raised as a new event afterwards + IClickableMenu was = this.Watchers.ActiveMenuWatcher.PreviousValue; + IClickableMenu now = this.Watchers.ActiveMenuWatcher.CurrentValue; + this.Watchers.ActiveMenuWatcher.Reset(); // reset here so a mod changing the menu will be raised as a new event afterwards if (this.VerboseLogging) - this.Monitor.Log($"Context: menu changed from {previousMenu?.GetType().FullName ?? "none"} to {newMenu?.GetType().FullName ?? "none"}.", LogLevel.Trace); + this.Monitor.Log($"Context: menu changed from {was?.GetType().FullName ?? "none"} to {now?.GetType().FullName ?? "none"}.", LogLevel.Trace); // raise menu events - if (newMenu != null) - this.Events.Menu_Changed.Raise(new EventArgsClickableMenuChanged(previousMenu, newMenu)); + if (now != null) + this.Events.Menu_Changed.Raise(new EventArgsClickableMenuChanged(was, now)); else - this.Events.Menu_Closed.Raise(new EventArgsClickableMenuClosed(previousMenu)); + this.Events.Menu_Closed.Raise(new EventArgsClickableMenuClosed(was)); } /********* @@ -565,22 +496,22 @@ namespace StardewModdingAPI.Framework *********/ if (Context.IsWorldReady) { - bool raiseWorldEvents = !this.SaveIdWatcher.IsChanged; // don't report changes from unloaded => loaded + bool raiseWorldEvents = !this.Watchers.SaveIdWatcher.IsChanged; // don't report changes from unloaded => loaded // raise location changes - if (this.LocationsWatcher.IsChanged) + if (this.Watchers.LocationsWatcher.IsChanged) { // location list changes - if (this.LocationsWatcher.IsLocationListChanged) + if (this.Watchers.LocationsWatcher.IsLocationListChanged) { - GameLocation[] added = this.LocationsWatcher.Added.ToArray(); - GameLocation[] removed = this.LocationsWatcher.Removed.ToArray(); - this.LocationsWatcher.ResetLocationList(); + GameLocation[] added = this.Watchers.LocationsWatcher.Added.ToArray(); + GameLocation[] removed = this.Watchers.LocationsWatcher.Removed.ToArray(); + this.Watchers.LocationsWatcher.ResetLocationList(); if (this.VerboseLogging) { - string addedText = this.LocationsWatcher.Added.Any() ? string.Join(", ", added.Select(p => p.Name)) : "none"; - string removedText = this.LocationsWatcher.Removed.Any() ? string.Join(", ", removed.Select(p => p.Name)) : "none"; + string addedText = this.Watchers.LocationsWatcher.Added.Any() ? string.Join(", ", added.Select(p => p.Name)) : "none"; + string removedText = this.Watchers.LocationsWatcher.Removed.Any() ? string.Join(", ", removed.Select(p => p.Name)) : "none"; this.Monitor.Log($"Context: location list changed (added {addedText}; removed {removedText}).", LogLevel.Trace); } @@ -591,7 +522,7 @@ namespace StardewModdingAPI.Framework // raise location contents changed if (raiseWorldEvents) { - foreach (LocationTracker watcher in this.LocationsWatcher.Locations) + foreach (LocationTracker watcher in this.Watchers.LocationsWatcher.Locations) { // buildings changed if (watcher.BuildingsWatcher.IsChanged) @@ -652,15 +583,15 @@ namespace StardewModdingAPI.Framework } } else - this.LocationsWatcher.Reset(); + this.Watchers.LocationsWatcher.Reset(); } // raise time changed - if (raiseWorldEvents && this.TimeWatcher.IsChanged) + if (raiseWorldEvents && this.Watchers.TimeWatcher.IsChanged) { - int was = this.TimeWatcher.PreviousValue; - int now = this.TimeWatcher.CurrentValue; - this.TimeWatcher.Reset(); + int was = this.Watchers.TimeWatcher.PreviousValue; + int now = this.Watchers.TimeWatcher.CurrentValue; + this.Watchers.TimeWatcher.Reset(); if (this.VerboseLogging) this.Monitor.Log($"Context: time changed from {was} to {now}.", LogLevel.Trace); @@ -668,12 +599,12 @@ namespace StardewModdingAPI.Framework this.Events.Time_TimeOfDayChanged.Raise(new EventArgsIntChanged(was, now)); } else - this.TimeWatcher.Reset(); + this.Watchers.TimeWatcher.Reset(); // raise player events if (raiseWorldEvents) { - PlayerTracker curPlayer = this.CurrentPlayerTracker; + PlayerTracker curPlayer = this.Watchers.CurrentPlayerTracker; // raise current location changed if (curPlayer.TryGetNewLocation(out GameLocation newLocation)) @@ -708,11 +639,11 @@ namespace StardewModdingAPI.Framework this.Events.Mine_LevelChanged.Raise(new EventArgsMineLevelChanged(curPlayer.MineLevelWatcher.PreviousValue, mineLevel)); } } - this.CurrentPlayerTracker?.Reset(); + this.Watchers.CurrentPlayerTracker?.Reset(); } // update save ID watcher - this.SaveIdWatcher.Reset(); + this.Watchers.SaveIdWatcher.Reset(); /********* ** Game update diff --git a/src/SMAPI/Framework/StateTracking/Comparers/GenericEqualsComparer.cs b/src/SMAPI/Framework/StateTracking/Comparers/GenericEqualsComparer.cs new file mode 100644 index 00000000..cc1d6553 --- /dev/null +++ b/src/SMAPI/Framework/StateTracking/Comparers/GenericEqualsComparer.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace StardewModdingAPI.Framework.StateTracking.Comparers +{ + /// Compares values using their method. This should only be used when won't work, since this doesn't validate whether they're comparable. + /// The value type. + internal class GenericEqualsComparer : IEqualityComparer + { + /********* + ** Public methods + *********/ + /// Determines whether the specified objects are equal. + /// true if the specified objects are equal; otherwise, false. + /// The first object to compare. + /// The second object to compare. + public bool Equals(T x, T y) + { + if (x == null) + return y == null; + return x.Equals(y); + } + + /// Get a hash code for the specified object. + /// The value. + public int GetHashCode(T obj) + { + return RuntimeHelpers.GetHashCode(obj); + } + } +} diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs index 4f1ac9f4..d7a02668 100644 --- a/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs +++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs @@ -12,6 +12,14 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers /********* ** Public methods *********/ + /// Get a watcher which compares values using their method. This method should only be used when won't work, since this doesn't validate whether they're comparable. + /// The value type. + /// Get the current value. + public static ComparableWatcher ForGenericEquality(Func getValue) where T : struct + { + return new ComparableWatcher(getValue, new GenericEqualsComparer()); + } + /// Get a watcher for an value. /// The value type. /// Get the current value. diff --git a/src/SMAPI/Framework/WatcherCore.cs b/src/SMAPI/Framework/WatcherCore.cs new file mode 100644 index 00000000..64b063cf --- /dev/null +++ b/src/SMAPI/Framework/WatcherCore.cs @@ -0,0 +1,119 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.Input; +using StardewModdingAPI.Framework.StateTracking; +using StardewModdingAPI.Framework.StateTracking.FieldWatchers; +using StardewValley; +using StardewValley.Menus; + +namespace StardewModdingAPI.Framework +{ + /// Monitors the entire game state for changes, virally spreading watchers into any new entities that get created. + internal class WatcherCore + { + /********* + ** Public methods + *********/ + /// The underlying watchers for convenience. These are accessible individually as separate properties. + private readonly List Watchers = new List(); + + + /********* + ** Accessors + *********/ + /// Tracks changes to the window size. + public readonly IValueWatcher WindowSizeWatcher; + + /// Tracks changes to the current player. + public PlayerTracker CurrentPlayerTracker; + + /// Tracks changes to the time of day (in 24-hour military format). + public readonly IValueWatcher TimeWatcher; + + /// Tracks changes to the save ID. + public readonly IValueWatcher SaveIdWatcher; + + /// Tracks changes to the game's locations. + public readonly WorldLocationsTracker LocationsWatcher; + + /// Tracks changes to . + public readonly IValueWatcher ActiveMenuWatcher; + + /// Tracks changes to the cursor position. + public readonly IValueWatcher CursorWatcher; + + /// Tracks changes to the mouse wheel scroll. + public readonly IValueWatcher MouseWheelScrollWatcher; + + /// Tracks changes to the content locale. + public readonly IValueWatcher LocaleWatcher; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Manages input visible to the game. + public WatcherCore(SInputState inputState) + { + // init watchers + this.CursorWatcher = WatcherFactory.ForEquatable(() => inputState.CursorPosition); + this.MouseWheelScrollWatcher = WatcherFactory.ForEquatable(() => inputState.RealMouse.ScrollWheelValue); + this.SaveIdWatcher = WatcherFactory.ForEquatable(() => Game1.hasLoadedGame ? Game1.uniqueIDForThisGame : 0); + this.WindowSizeWatcher = WatcherFactory.ForEquatable(() => new Point(Game1.viewport.Width, Game1.viewport.Height)); + this.TimeWatcher = WatcherFactory.ForEquatable(() => Game1.timeOfDay); + this.ActiveMenuWatcher = WatcherFactory.ForReference(() => Game1.activeClickableMenu); + this.LocationsWatcher = new WorldLocationsTracker((ObservableCollection)Game1.locations); + this.LocaleWatcher = WatcherFactory.ForGenericEquality(() => LocalizedContentManager.CurrentLanguageCode); + this.Watchers.AddRange(new IWatcher[] + { + this.CursorWatcher, + this.MouseWheelScrollWatcher, + this.SaveIdWatcher, + this.WindowSizeWatcher, + this.TimeWatcher, + this.ActiveMenuWatcher, + this.LocationsWatcher, + this.LocaleWatcher + }); + } + + /// Update the watchers and adjust for added or removed entities. + public void Update() + { + // reset player + if (Context.IsWorldReady) + { + if (this.CurrentPlayerTracker == null || this.CurrentPlayerTracker.Player != Game1.player) + { + this.CurrentPlayerTracker?.Dispose(); + this.CurrentPlayerTracker = new PlayerTracker(Game1.player); + } + } + else + { + if (this.CurrentPlayerTracker != null) + { + this.CurrentPlayerTracker.Dispose(); + this.CurrentPlayerTracker = null; + } + } + + // update values + foreach (IWatcher watcher in this.Watchers) + watcher.Update(); + this.CurrentPlayerTracker?.Update(); + this.LocationsWatcher.Update(); + } + + /// Reset the current values as the baseline. + public void Reset() + { + foreach (IWatcher watcher in this.Watchers) + watcher.Reset(); + this.CurrentPlayerTracker?.Reset(); + this.LocationsWatcher.Reset(); + } + } +} diff --git a/src/SMAPI/ICursorPosition.cs b/src/SMAPI/ICursorPosition.cs index ddb8eb49..78f4fc21 100644 --- a/src/SMAPI/ICursorPosition.cs +++ b/src/SMAPI/ICursorPosition.cs @@ -1,9 +1,10 @@ +using System; using Microsoft.Xna.Framework; namespace StardewModdingAPI { /// Represents a cursor position in the different coordinate systems. - public interface ICursorPosition + public interface ICursorPosition : IEquatable { /// The pixel position relative to the top-left corner of the visible screen. Vector2 ScreenPixels { get; } diff --git a/src/SMAPI/StardewModdingAPI.csproj b/src/SMAPI/StardewModdingAPI.csproj index 8e3ad83b..67c48a57 100644 --- a/src/SMAPI/StardewModdingAPI.csproj +++ b/src/SMAPI/StardewModdingAPI.csproj @@ -129,6 +129,8 @@ + + -- cgit