summaryrefslogtreecommitdiff
path: root/src/SMAPI
diff options
context:
space:
mode:
authorJesse Plamondon-Willard <Pathoschild@users.noreply.github.com>2020-02-01 16:21:35 -0500
committerJesse Plamondon-Willard <Pathoschild@users.noreply.github.com>2020-02-01 16:21:35 -0500
commitc8d627cdf2ae3126584ec2500877ff19987db17f (patch)
tree2cc6f604df00027239476acf3a74ae6bb0761323 /src/SMAPI
parentf976b5c0f095a881fc20f6ce5dcf5a50ebb2b5da (diff)
parent17a9193fd28c527dcba40360702adb277736cc45 (diff)
downloadSMAPI-c8d627cdf2ae3126584ec2500877ff19987db17f.tar.gz
SMAPI-c8d627cdf2ae3126584ec2500877ff19987db17f.tar.bz2
SMAPI-c8d627cdf2ae3126584ec2500877ff19987db17f.zip
Merge branch 'develop' into stable
Diffstat (limited to 'src/SMAPI')
-rw-r--r--src/SMAPI/Constants.cs10
-rw-r--r--src/SMAPI/Framework/Content/ContentCache.cs1
-rw-r--r--src/SMAPI/Framework/ContentCoordinator.cs99
-rw-r--r--src/SMAPI/Framework/ContentManagers/GameContentManager.cs36
-rw-r--r--src/SMAPI/Framework/ContentManagers/ModContentManager.cs1
-rw-r--r--src/SMAPI/Framework/Events/EventManager.cs48
-rw-r--r--src/SMAPI/Framework/Events/IManagedEvent.cs15
-rw-r--r--src/SMAPI/Framework/Events/ManagedEvent.cs58
-rw-r--r--src/SMAPI/Framework/GameVersion.cs30
-rw-r--r--src/SMAPI/Framework/InternalExtensions.cs70
-rw-r--r--src/SMAPI/Framework/ModHelpers/DataHelper.cs2
-rw-r--r--src/SMAPI/Framework/ModLinked.cs29
-rw-r--r--src/SMAPI/Framework/PerformanceMonitoring/AlertContext.cs34
-rw-r--r--src/SMAPI/Framework/PerformanceMonitoring/AlertEntry.cs38
-rw-r--r--src/SMAPI/Framework/PerformanceMonitoring/PeakEntry.cs35
-rw-r--r--src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounter.cs125
-rw-r--r--src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterCollection.cs205
-rw-r--r--src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterEntry.cs30
-rw-r--r--src/SMAPI/Framework/PerformanceMonitoring/PerformanceMonitor.cs184
-rw-r--r--src/SMAPI/Framework/SCore.cs66
-rw-r--r--src/SMAPI/Framework/SGame.cs11
-rw-r--r--src/SMAPI/Framework/Serialization/PointConverter.cs2
-rw-r--r--src/SMAPI/Framework/Serialization/Vector2Converter.cs43
-rw-r--r--src/SMAPI/Framework/SnapshotListDiff.cs6
-rw-r--r--src/SMAPI/Metadata/CoreAssetPropagator.cs53
-rw-r--r--src/SMAPI/Patches/LoadErrorPatch.cs87
-rw-r--r--src/SMAPI/Patches/ScheduleErrorPatch.cs86
-rw-r--r--src/SMAPI/Program.cs1
-rw-r--r--src/SMAPI/SMAPI.config.json6
-rw-r--r--src/SMAPI/SMAPI.csproj27
-rw-r--r--src/SMAPI/SemanticVersion.cs34
-rw-r--r--src/SMAPI/i18n/fr.json3
32 files changed, 1286 insertions, 189 deletions
diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs
index 97204d86..201d3166 100644
--- a/src/SMAPI/Constants.cs
+++ b/src/SMAPI/Constants.cs
@@ -20,10 +20,10 @@ namespace StardewModdingAPI
** Public
****/
/// <summary>SMAPI's current semantic version.</summary>
- public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.1.0");
+ public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.2.0");
/// <summary>The minimum supported version of Stardew Valley.</summary>
- public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.4.0");
+ public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.4.1");
/// <summary>The maximum supported version of Stardew Valley.</summary>
public static ISemanticVersion MaximumGameVersion { get; } = null;
@@ -55,12 +55,18 @@ namespace StardewModdingAPI
/// <summary>The URL of the SMAPI home page.</summary>
internal const string HomePageUrl = "https://smapi.io";
+ /// <summary>The default performance counter name for unknown event handlers.</summary>
+ internal const string GamePerformanceCounterName = "<StardewValley>";
+
/// <summary>The absolute path to the folder containing SMAPI's internal files.</summary>
internal static readonly string InternalFilesPath = Program.DllSearchPath;
/// <summary>The file path for the SMAPI configuration file.</summary>
internal static string ApiConfigPath => Path.Combine(Constants.InternalFilesPath, "config.json");
+ /// <summary>The file path for the overrides file for <see cref="ApiConfigPath"/>, which is applied over it.</summary>
+ internal static string ApiUserConfigPath => Path.Combine(Constants.InternalFilesPath, "config.user.json");
+
/// <summary>The file path for the SMAPI metadata file.</summary>
internal static string ApiMetadataPath => Path.Combine(Constants.InternalFilesPath, "metadata.json");
diff --git a/src/SMAPI/Framework/Content/ContentCache.cs b/src/SMAPI/Framework/Content/ContentCache.cs
index f33ff84d..b0933ac6 100644
--- a/src/SMAPI/Framework/Content/ContentCache.cs
+++ b/src/SMAPI/Framework/Content/ContentCache.cs
@@ -4,7 +4,6 @@ using System.Diagnostics.Contracts;
using System.Linq;
using Microsoft.Xna.Framework;
using StardewModdingAPI.Framework.Reflection;
-using StardewModdingAPI.Internal;
using StardewModdingAPI.Toolkit.Utilities;
using StardewValley;
diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs
index 82d3805b..2fd31263 100644
--- a/src/SMAPI/Framework/ContentCoordinator.cs
+++ b/src/SMAPI/Framework/ContentCoordinator.cs
@@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
+using System.Threading;
using Microsoft.Xna.Framework.Content;
using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.ContentManagers;
@@ -48,6 +50,10 @@ namespace StardewModdingAPI.Framework
/// <summary>Whether the content coordinator has been disposed.</summary>
private bool IsDisposed;
+ /// <summary>A lock used to prevent asynchronous changes to the content manager list.</summary>
+ /// <remarks>The game may adds content managers in asynchronous threads (e.g. when populating the load screen).</remarks>
+ private readonly ReaderWriterLockSlim ContentManagerLock = new ReaderWriterLockSlim();
+
/*********
** Accessors
@@ -59,10 +65,10 @@ namespace StardewModdingAPI.Framework
public LocalizedContentManager.LanguageCode Language => this.MainContentManager.Language;
/// <summary>Interceptors which provide the initial versions of matching assets.</summary>
- public IDictionary<IModMetadata, IList<IAssetLoader>> Loaders { get; } = new Dictionary<IModMetadata, IList<IAssetLoader>>();
+ public IList<ModLinked<IAssetLoader>> Loaders { get; } = new List<ModLinked<IAssetLoader>>();
/// <summary>Interceptors which edit matching assets after they're loaded.</summary>
- public IDictionary<IModMetadata, IList<IAssetEditor>> Editors { get; } = new Dictionary<IModMetadata, IList<IAssetEditor>>();
+ public IList<ModLinked<IAssetEditor>> Editors { get; } = new List<ModLinked<IAssetEditor>>();
/// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary>
public string FullRootDirectory { get; }
@@ -96,9 +102,12 @@ namespace StardewModdingAPI.Framework
/// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param>
public GameContentManager CreateGameContentManager(string name)
{
- GameContentManager manager = new GameContentManager(name, this.MainContentManager.ServiceProvider, this.MainContentManager.RootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.OnDisposing, this.OnLoadingFirstAsset);
- this.ContentManagers.Add(manager);
- return manager;
+ return this.ContentManagerLock.InWriteLock(() =>
+ {
+ GameContentManager manager = new GameContentManager(name, this.MainContentManager.ServiceProvider, this.MainContentManager.RootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.OnDisposing, this.OnLoadingFirstAsset);
+ this.ContentManagers.Add(manager);
+ return manager;
+ });
}
/// <summary>Get a new content manager which handles reading files from a SMAPI mod folder with support for unpacked files.</summary>
@@ -107,20 +116,23 @@ namespace StardewModdingAPI.Framework
/// <param name="gameContentManager">The game content manager used for map tilesheets not provided by the mod.</param>
public ModContentManager CreateModContentManager(string name, string rootDirectory, IContentManager gameContentManager)
{
- ModContentManager manager = new ModContentManager(
- name: name,
- gameContentManager: gameContentManager,
- serviceProvider: this.MainContentManager.ServiceProvider,
- rootDirectory: rootDirectory,
- currentCulture: this.MainContentManager.CurrentCulture,
- coordinator: this,
- monitor: this.Monitor,
- reflection: this.Reflection,
- jsonHelper: this.JsonHelper,
- onDisposing: this.OnDisposing
- );
- this.ContentManagers.Add(manager);
- return manager;
+ return this.ContentManagerLock.InWriteLock(() =>
+ {
+ ModContentManager manager = new ModContentManager(
+ name: name,
+ gameContentManager: gameContentManager,
+ serviceProvider: this.MainContentManager.ServiceProvider,
+ rootDirectory: rootDirectory,
+ currentCulture: this.MainContentManager.CurrentCulture,
+ coordinator: this,
+ monitor: this.Monitor,
+ reflection: this.Reflection,
+ jsonHelper: this.JsonHelper,
+ onDisposing: this.OnDisposing
+ );
+ this.ContentManagers.Add(manager);
+ return manager;
+ });
}
/// <summary>Get the current content locale.</summary>
@@ -132,8 +144,11 @@ namespace StardewModdingAPI.Framework
/// <summary>Perform any cleanup needed when the locale changes.</summary>
public void OnLocaleChanged()
{
- foreach (IContentManager contentManager in this.ContentManagers)
- contentManager.OnLocaleChanged();
+ this.ContentManagerLock.InReadLock(() =>
+ {
+ foreach (IContentManager contentManager in this.ContentManagers)
+ contentManager.OnLocaleChanged();
+ });
}
/// <summary>Get whether this asset is mapped to a mod folder.</summary>
@@ -180,7 +195,9 @@ namespace StardewModdingAPI.Framework
public T LoadManagedAsset<T>(string contentManagerID, string relativePath)
{
// get content manager
- IContentManager contentManager = this.ContentManagers.FirstOrDefault(p => p.IsNamespaced && p.Name == contentManagerID);
+ IContentManager contentManager = this.ContentManagerLock.InReadLock(() =>
+ this.ContentManagers.FirstOrDefault(p => p.IsNamespaced && p.Name == contentManagerID)
+ );
if (contentManager == null)
throw new InvalidOperationException($"The '{contentManagerID}' prefix isn't handled by any mod.");
@@ -210,15 +227,18 @@ namespace StardewModdingAPI.Framework
{
// invalidate cache & track removed assets
IDictionary<string, ISet<object>> removedAssets = new Dictionary<string, ISet<object>>(StringComparer.InvariantCultureIgnoreCase);
- foreach (IContentManager contentManager in this.ContentManagers)
+ this.ContentManagerLock.InReadLock(() =>
{
- foreach (var entry in contentManager.InvalidateCache(predicate, dispose))
+ foreach (IContentManager contentManager in this.ContentManagers)
{
- if (!removedAssets.TryGetValue(entry.Key, out ISet<object> assets))
- removedAssets[entry.Key] = assets = new HashSet<object>(new ObjectReferenceComparer<object>());
- assets.Add(entry.Value);
+ foreach (var entry in contentManager.InvalidateCache(predicate, dispose))
+ {
+ if (!removedAssets.TryGetValue(entry.Key, out ISet<object> assets))
+ removedAssets[entry.Key] = assets = new HashSet<object>(new ObjectReferenceComparer<object>());
+ assets.Add(entry.Value);
+ }
}
- }
+ });
// reload core game assets
if (removedAssets.Any())
@@ -232,6 +252,23 @@ namespace StardewModdingAPI.Framework
return removedAssets.Keys;
}
+ /// <summary>Get all loaded instances of an asset name.</summary>
+ /// <param name="assetName">The asset name.</param>
+ [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "This method is provided for Content Patcher.")]
+ public IEnumerable<object> GetLoadedValues(string assetName)
+ {
+ return this.ContentManagerLock.InReadLock(() =>
+ {
+ List<object> values = new List<object>();
+ foreach (IContentManager content in this.ContentManagers.Where(p => !p.IsNamespaced && p.IsLoaded(assetName)))
+ {
+ object value = content.Load<object>(assetName, this.Language, useCache: true);
+ values.Add(value);
+ }
+ return values;
+ });
+ }
+
/// <summary>Dispose held resources.</summary>
public void Dispose()
{
@@ -244,6 +281,8 @@ namespace StardewModdingAPI.Framework
contentManager.Dispose();
this.ContentManagers.Clear();
this.MainContentManager = null;
+
+ this.ContentManagerLock.Dispose();
}
@@ -257,7 +296,9 @@ namespace StardewModdingAPI.Framework
if (this.IsDisposed)
return;
- this.ContentManagers.Remove(contentManager);
+ this.ContentManagerLock.InWriteLock(() =>
+ this.ContentManagers.Remove(contentManager)
+ );
}
}
}
diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
index 8930267d..eecdda74 100644
--- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
@@ -21,10 +21,10 @@ namespace StardewModdingAPI.Framework.ContentManagers
private readonly ContextHash<string> AssetsBeingLoaded = new ContextHash<string>();
/// <summary>Interceptors which provide the initial versions of matching assets.</summary>
- private IDictionary<IModMetadata, IList<IAssetLoader>> Loaders => this.Coordinator.Loaders;
+ private IList<ModLinked<IAssetLoader>> Loaders => this.Coordinator.Loaders;
/// <summary>Interceptors which edit matching assets after they're loaded.</summary>
- private IDictionary<IModMetadata, IList<IAssetEditor>> Editors => this.Coordinator.Editors;
+ private IList<ModLinked<IAssetEditor>> Editors => this.Coordinator.Editors;
/// <summary>A lookup which indicates whether the asset is localizable (i.e. the filename contains the locale), if previously loaded.</summary>
private readonly IDictionary<string, bool> IsLocalizableLookup;
@@ -278,16 +278,16 @@ namespace StardewModdingAPI.Framework.ContentManagers
private IAssetData ApplyLoader<T>(IAssetInfo info)
{
// find matching loaders
- var loaders = this.GetInterceptors(this.Loaders)
+ var loaders = this.Loaders
.Where(entry =>
{
try
{
- return entry.Value.CanLoad<T>(info);
+ return entry.Data.CanLoad<T>(info);
}
catch (Exception ex)
{
- 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);
+ entry.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
return false;
}
})
@@ -298,14 +298,14 @@ namespace StardewModdingAPI.Framework.ContentManagers
return null;
if (loaders.Length > 1)
{
- string[] loaderNames = loaders.Select(p => p.Key.DisplayName).ToArray();
+ string[] loaderNames = loaders.Select(p => p.Mod.DisplayName).ToArray();
this.Monitor.Log($"Multiple mods want to provide the '{info.AssetName}' asset ({string.Join(", ", loaderNames)}), but an asset can't be loaded multiple times. SMAPI will use the default asset instead; uninstall one of the mods to fix this. (Message for modders: you should usually use {typeof(IAssetEditor)} instead to avoid conflicts.)", LogLevel.Warn);
return null;
}
// fetch asset from loader
- IModMetadata mod = loaders[0].Key;
- IAssetLoader loader = loaders[0].Value;
+ IModMetadata mod = loaders[0].Mod;
+ IAssetLoader loader = loaders[0].Data;
T data;
try
{
@@ -338,11 +338,11 @@ namespace StardewModdingAPI.Framework.ContentManagers
IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName);
// edit asset
- foreach (var entry in this.GetInterceptors(this.Editors))
+ foreach (var entry in this.Editors)
{
// check for match
- IModMetadata mod = entry.Key;
- IAssetEditor editor = entry.Value;
+ IModMetadata mod = entry.Mod;
+ IAssetEditor editor = entry.Data;
try
{
if (!editor.CanEdit<T>(info))
@@ -382,19 +382,5 @@ namespace StardewModdingAPI.Framework.ContentManagers
// return result
return asset;
}
-
- /// <summary>Get all registered interceptors from a list.</summary>
- private IEnumerable<KeyValuePair<IModMetadata, T>> GetInterceptors<T>(IDictionary<IModMetadata, IList<T>> entries)
- {
- foreach (var entry in entries)
- {
- IModMetadata mod = entry.Key;
- IList<T> interceptors = entry.Value;
-
- // registered editors
- foreach (T interceptor in interceptors)
- yield return new KeyValuePair<IModMetadata, T>(mod, interceptor);
- }
- }
}
}
diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
index fdf76b24..0a526fc8 100644
--- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
@@ -154,6 +154,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
// unpacked map
case ".tbin":
+ case ".tmx":
{
// validate
if (typeof(T) != typeof(Map))
diff --git a/src/SMAPI/Framework/Events/EventManager.cs b/src/SMAPI/Framework/Events/EventManager.cs
index 892cbc7b..a9dfda97 100644
--- a/src/SMAPI/Framework/Events/EventManager.cs
+++ b/src/SMAPI/Framework/Events/EventManager.cs
@@ -1,5 +1,8 @@
+using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
using StardewModdingAPI.Events;
+using StardewModdingAPI.Framework.PerformanceMonitoring;
namespace StardewModdingAPI.Framework.Events
{
@@ -173,28 +176,32 @@ namespace StardewModdingAPI.Framework.Events
/// <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)
+ /// <param name="performanceMonitor">Tracks performance metrics.</param>
+ public EventManager(IMonitor monitor, ModRegistry modRegistry, PerformanceMonitor performanceMonitor)
{
// create shortcut initializers
- ManagedEvent<TEventArgs> ManageEventOf<TEventArgs>(string typeName, string eventName) => new ManagedEvent<TEventArgs>($"{typeName}.{eventName}", monitor, modRegistry);
+ ManagedEvent<TEventArgs> ManageEventOf<TEventArgs>(string typeName, string eventName, bool isPerformanceCritical = false)
+ {
+ return new ManagedEvent<TEventArgs>($"{typeName}.{eventName}", monitor, modRegistry, performanceMonitor, isPerformanceCritical);
+ }
// init events (new)
this.MenuChanged = ManageEventOf<MenuChangedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.MenuChanged));
- this.Rendering = ManageEventOf<RenderingEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendering));
- this.Rendered = ManageEventOf<RenderedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendered));
- this.RenderingWorld = ManageEventOf<RenderingWorldEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingWorld));
- this.RenderedWorld = ManageEventOf<RenderedWorldEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedWorld));
- this.RenderingActiveMenu = ManageEventOf<RenderingActiveMenuEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingActiveMenu));
- this.RenderedActiveMenu = ManageEventOf<RenderedActiveMenuEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedActiveMenu));
- this.RenderingHud = ManageEventOf<RenderingHudEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingHud));
- this.RenderedHud = ManageEventOf<RenderedHudEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedHud));
+ this.Rendering = ManageEventOf<RenderingEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendering), isPerformanceCritical: true);
+ this.Rendered = ManageEventOf<RenderedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendered), isPerformanceCritical: true);
+ this.RenderingWorld = ManageEventOf<RenderingWorldEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingWorld), isPerformanceCritical: true);
+ this.RenderedWorld = ManageEventOf<RenderedWorldEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedWorld), isPerformanceCritical: true);
+ this.RenderingActiveMenu = ManageEventOf<RenderingActiveMenuEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingActiveMenu), isPerformanceCritical: true);
+ this.RenderedActiveMenu = ManageEventOf<RenderedActiveMenuEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedActiveMenu), isPerformanceCritical: true);
+ this.RenderingHud = ManageEventOf<RenderingHudEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingHud), isPerformanceCritical: true);
+ this.RenderedHud = ManageEventOf<RenderedHudEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedHud), isPerformanceCritical: true);
this.WindowResized = ManageEventOf<WindowResizedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.WindowResized));
this.GameLaunched = ManageEventOf<GameLaunchedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.GameLaunched));
- this.UpdateTicking = ManageEventOf<UpdateTickingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicking));
- this.UpdateTicked = ManageEventOf<UpdateTickedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicked));
- this.OneSecondUpdateTicking = ManageEventOf<OneSecondUpdateTickingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.OneSecondUpdateTicking));
- this.OneSecondUpdateTicked = ManageEventOf<OneSecondUpdateTickedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.OneSecondUpdateTicked));
+ this.UpdateTicking = ManageEventOf<UpdateTickingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicking), isPerformanceCritical: true);
+ this.UpdateTicked = ManageEventOf<UpdateTickedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicked), isPerformanceCritical: true);
+ this.OneSecondUpdateTicking = ManageEventOf<OneSecondUpdateTickingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.OneSecondUpdateTicking), isPerformanceCritical: true);
+ this.OneSecondUpdateTicked = ManageEventOf<OneSecondUpdateTickedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.OneSecondUpdateTicked), isPerformanceCritical: true);
this.SaveCreating = ManageEventOf<SaveCreatingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.SaveCreating));
this.SaveCreated = ManageEventOf<SaveCreatedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.SaveCreated));
this.Saving = ManageEventOf<SavingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.Saving));
@@ -207,7 +214,7 @@ namespace StardewModdingAPI.Framework.Events
this.ButtonPressed = ManageEventOf<ButtonPressedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonPressed));
this.ButtonReleased = ManageEventOf<ButtonReleasedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonReleased));
- this.CursorMoved = ManageEventOf<CursorMovedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.CursorMoved));
+ this.CursorMoved = ManageEventOf<CursorMovedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.CursorMoved), isPerformanceCritical: true);
this.MouseWheelScrolled = ManageEventOf<MouseWheelScrolledEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.MouseWheelScrolled));
this.PeerContextReceived = ManageEventOf<PeerContextReceivedEventArgs>(nameof(IModEvents.Multiplayer), nameof(IMultiplayerEvents.PeerContextReceived));
@@ -228,8 +235,15 @@ namespace StardewModdingAPI.Framework.Events
this.TerrainFeatureListChanged = ManageEventOf<TerrainFeatureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.TerrainFeatureListChanged));
this.LoadStageChanged = ManageEventOf<LoadStageChangedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.LoadStageChanged));
- this.UnvalidatedUpdateTicking = ManageEventOf<UnvalidatedUpdateTickingEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicking));
- this.UnvalidatedUpdateTicked = ManageEventOf<UnvalidatedUpdateTickedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicked));
+ this.UnvalidatedUpdateTicking = ManageEventOf<UnvalidatedUpdateTickingEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicking), isPerformanceCritical: true);
+ this.UnvalidatedUpdateTicked = ManageEventOf<UnvalidatedUpdateTickedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicked), isPerformanceCritical: true);
+ }
+
+ /// <summary>Get all managed events.</summary>
+ public IEnumerable<IManagedEvent> GetAllEvents()
+ {
+ foreach (FieldInfo field in this.GetType().GetFields())
+ yield return (IManagedEvent)field.GetValue(this);
}
}
}
diff --git a/src/SMAPI/Framework/Events/IManagedEvent.cs b/src/SMAPI/Framework/Events/IManagedEvent.cs
new file mode 100644
index 00000000..e4e3ca08
--- /dev/null
+++ b/src/SMAPI/Framework/Events/IManagedEvent.cs
@@ -0,0 +1,15 @@
+namespace StardewModdingAPI.Framework.Events
+{
+ /// <summary>Metadata for an event raised by SMAPI.</summary>
+ internal interface IManagedEvent
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>A human-readable name for the event.</summary>
+ string EventName { get; }
+
+ /// <summary>Whether the event is typically called at least once per second.</summary>
+ bool IsPerformanceCritical { get; }
+ }
+}
diff --git a/src/SMAPI/Framework/Events/ManagedEvent.cs b/src/SMAPI/Framework/Events/ManagedEvent.cs
index 2afe7a03..118b73ac 100644
--- a/src/SMAPI/Framework/Events/ManagedEvent.cs
+++ b/src/SMAPI/Framework/Events/ManagedEvent.cs
@@ -1,12 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using StardewModdingAPI.Framework.PerformanceMonitoring;
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>
+ internal class ManagedEvent<TEventArgs> : IManagedEvent
{
/*********
** Fields
@@ -14,9 +15,6 @@ namespace StardewModdingAPI.Framework.Events
/// <summary>The underlying event.</summary>
private event EventHandler<TEventArgs> Event;
- /// <summary>A human-readable name for the event.</summary>
- private readonly string EventName;
-
/// <summary>Writes messages to the log.</summary>
private readonly IMonitor Monitor;
@@ -29,6 +27,19 @@ namespace StardewModdingAPI.Framework.Events
/// <summary>The cached invocation list.</summary>
private EventHandler<TEventArgs>[] CachedInvocationList;
+ /// <summary>Tracks performance metrics.</summary>
+ private readonly PerformanceMonitor PerformanceMonitor;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>A human-readable name for the event.</summary>
+ public string EventName { get; }
+
+ /// <summary>Whether the event is typically called at least once per second.</summary>
+ public bool IsPerformanceCritical { get; }
+
/*********
** Public methods
@@ -37,11 +48,15 @@ namespace StardewModdingAPI.Framework.Events
/// <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)
+ /// <param name="performanceMonitor">Tracks performance metrics.</param>
+ /// <param name="isPerformanceCritical">Whether the event is typically called at least once per second.</param>
+ public ManagedEvent(string eventName, IMonitor monitor, ModRegistry modRegistry, PerformanceMonitor performanceMonitor, bool isPerformanceCritical = false)
{
this.EventName = eventName;
this.Monitor = monitor;
this.ModRegistry = modRegistry;
+ this.PerformanceMonitor = performanceMonitor;
+ this.IsPerformanceCritical = isPerformanceCritical;
}
/// <summary>Get whether anything is listening to the event.</summary>
@@ -81,17 +96,21 @@ namespace StardewModdingAPI.Framework.Events
if (this.Event == null)
return;
- foreach (EventHandler<TEventArgs> handler in this.CachedInvocationList)
+
+ this.PerformanceMonitor.Track(this.EventName, () =>
{
- try
+ foreach (EventHandler<TEventArgs> handler in this.CachedInvocationList)
{
- handler.Invoke(null, args);
- }
- catch (Exception ex)
- {
- this.LogError(handler, ex);
+ try
+ {
+ this.PerformanceMonitor.Track(this.EventName, this.GetModNameForPerformanceCounters(handler), () => handler.Invoke(null, args));
+ }
+ catch (Exception ex)
+ {
+ this.LogError(handler, ex);
+ }
}
- }
+ });
}
/// <summary>Raise the event and notify all handlers.</summary>
@@ -122,6 +141,19 @@ namespace StardewModdingAPI.Framework.Events
/*********
** Private methods
*********/
+ /// <summary>Get the mod name for a given event handler to display in performance monitoring reports.</summary>
+ /// <param name="handler">The event handler.</param>
+ private string GetModNameForPerformanceCounters(EventHandler<TEventArgs> handler)
+ {
+ IModMetadata mod = this.GetSourceMod(handler);
+ if (mod == null)
+ return Constants.GamePerformanceCounterName;
+
+ return mod.HasManifest()
+ ? mod.Manifest.UniqueID
+ : mod.DisplayName;
+ }
+
/// <summary>Track an event handler.</summary>
/// <param name="mod">The mod which added the handler.</param>
/// <param name="handler">The event handler.</param>
diff --git a/src/SMAPI/Framework/GameVersion.cs b/src/SMAPI/Framework/GameVersion.cs
index 29cfbc39..07957624 100644
--- a/src/SMAPI/Framework/GameVersion.cs
+++ b/src/SMAPI/Framework/GameVersion.cs
@@ -3,8 +3,8 @@ using System.Collections.Generic;
namespace StardewModdingAPI.Framework
{
- /// <summary>An implementation of <see cref="ISemanticVersion"/> that correctly handles the non-semantic versions used by older Stardew Valley releases.</summary>
- internal class GameVersion : SemanticVersion
+ /// <summary>An extension of <see cref="ISemanticVersion"/> that correctly handles non-semantic versions used by Stardew Valley.</summary>
+ internal class GameVersion : Toolkit.SemanticVersion
{
/*********
** Private methods
@@ -18,11 +18,11 @@ namespace StardewModdingAPI.Framework
["1.03"] = "1.0.3",
["1.04"] = "1.0.4",
["1.05"] = "1.0.5",
- ["1.051"] = "1.0.6-prerelease1", // not a very good mapping, but good enough for SMAPI's purposes.
- ["1.051b"] = "1.0.6-prerelease2",
+ ["1.051"] = "1.0.5.1",
+ ["1.051b"] = "1.0.5.2",
["1.06"] = "1.0.6",
["1.07"] = "1.0.7",
- ["1.07a"] = "1.0.8-prerelease1",
+ ["1.07a"] = "1.0.7.1",
["1.08"] = "1.0.8",
["1.1"] = "1.1.0",
["1.2"] = "1.2.0",
@@ -36,7 +36,7 @@ namespace StardewModdingAPI.Framework
/// <summary>Construct an instance.</summary>
/// <param name="version">The game version string.</param>
public GameVersion(string version)
- : base(GameVersion.GetSemanticVersionString(version)) { }
+ : base(GameVersion.GetSemanticVersionString(version), allowNonStandard: true) { }
/// <summary>Get a string representation of the version.</summary>
public override string ToString()
@@ -53,33 +53,21 @@ namespace StardewModdingAPI.Framework
private static string GetSemanticVersionString(string gameVersion)
{
// mapped version
- if (GameVersion.VersionMap.TryGetValue(gameVersion, out string semanticVersion))
- return semanticVersion;
-
- // special case: four-part versions
- string[] parts = gameVersion.Split('.');
- if (parts.Length == 4)
- return $"{parts[0]}.{parts[1]}.{parts[2]}+{parts[3]}";
-
- return gameVersion;
+ return GameVersion.VersionMap.TryGetValue(gameVersion, out string semanticVersion)
+ ? semanticVersion
+ : gameVersion;
}
/// <summary>Convert a semantic version string to the equivalent game version string.</summary>
/// <param name="semanticVersion">The semantic version string.</param>
private static string GetGameVersionString(string semanticVersion)
{
- // mapped versions
foreach (var mapping in GameVersion.VersionMap)
{
if (mapping.Value.Equals(semanticVersion, StringComparison.InvariantCultureIgnoreCase))
return mapping.Key;
}
- // special case: four-part versions
- string[] parts = semanticVersion.Split('.', '+');
- if (parts.Length == 4)
- return $"{parts[0]}.{parts[1]}.{parts[2]}.{parts[3]}";
-
return semanticVersion;
}
}
diff --git a/src/SMAPI/Framework/InternalExtensions.cs b/src/SMAPI/Framework/InternalExtensions.cs
index c3155b1c..8b45e196 100644
--- a/src/SMAPI/Framework/InternalExtensions.cs
+++ b/src/SMAPI/Framework/InternalExtensions.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Reflection;
+using System.Threading;
using Microsoft.Xna.Framework.Graphics;
using StardewModdingAPI.Framework.Events;
using StardewModdingAPI.Framework.Reflection;
@@ -84,6 +85,75 @@ namespace StardewModdingAPI.Framework
}
/****
+ ** ReaderWriterLockSlim
+ ****/
+ /// <summary>Run code within a read lock.</summary>
+ /// <param name="lock">The lock to set.</param>
+ /// <param name="action">The action to perform.</param>
+ public static void InReadLock(this ReaderWriterLockSlim @lock, Action action)
+ {
+ @lock.EnterReadLock();
+ try
+ {
+ action();
+ }
+ finally
+ {
+ @lock.ExitReadLock();
+ }
+ }
+
+ /// <summary>Run code within a read lock.</summary>
+ /// <typeparam name="TReturn">The action's return value.</typeparam>
+ /// <param name="lock">The lock to set.</param>
+ /// <param name="action">The action to perform.</param>
+ public static TReturn InReadLock<TReturn>(this ReaderWriterLockSlim @lock, Func<TReturn> action)
+ {
+ @lock.EnterReadLock();
+ try
+ {
+ return action();
+ }
+ finally
+ {
+ @lock.ExitReadLock();
+ }
+ }
+
+ /// <summary>Run code within a write lock.</summary>
+ /// <param name="lock">The lock to set.</param>
+ /// <param name="action">The action to perform.</param>
+ public static void InWriteLock(this ReaderWriterLockSlim @lock, Action action)
+ {
+ @lock.EnterWriteLock();
+ try
+ {
+ action();
+ }
+ finally
+ {
+ @lock.ExitWriteLock();
+ }
+ }
+
+ /// <summary>Run code within a write lock.</summary>
+ /// <typeparam name="TReturn">The action's return value.</typeparam>
+ /// <param name="lock">The lock to set.</param>
+ /// <param name="action">The action to perform.</param>
+ public static TReturn InWriteLock<TReturn>(this ReaderWriterLockSlim @lock, Func<TReturn> action)
+ {
+ @lock.EnterWriteLock();
+ try
+ {
+ return action();
+ }
+ finally
+ {
+ @lock.ExitWriteLock();
+ }
+ }
+
+ /****
** Sprite batch
****/
/// <summary>Get whether the sprite batch is between a begin and end pair.</summary>
diff --git a/src/SMAPI/Framework/ModHelpers/DataHelper.cs b/src/SMAPI/Framework/ModHelpers/DataHelper.cs
index 3d43c539..6cde849c 100644
--- a/src/SMAPI/Framework/ModHelpers/DataHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/DataHelper.cs
@@ -177,7 +177,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
private string GetGlobalDataPath(string key)
{
this.AssertSlug(key, nameof(key));
- return Path.Combine(Constants.SavesPath, ".smapi", "mod-data", this.ModID.ToLower(), $"{key}.json".ToLower());
+ return Path.Combine(Constants.DataPath, ".smapi", "mod-data", this.ModID.ToLower(), $"{key}.json".ToLower());
}
/// <summary>Assert that a key contains only characters that are safe in all contexts.</summary>
diff --git a/src/SMAPI/Framework/ModLinked.cs b/src/SMAPI/Framework/ModLinked.cs
new file mode 100644
index 00000000..8cfe6f5f
--- /dev/null
+++ b/src/SMAPI/Framework/ModLinked.cs
@@ -0,0 +1,29 @@
+namespace StardewModdingAPI.Framework
+{
+ /// <summary>A generic tuple which links something to a mod.</summary>
+ /// <typeparam name="T">The interceptor type.</typeparam>
+ internal class ModLinked<T>
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod metadata.</summary>
+ public IModMetadata Mod { get; }
+
+ /// <summary>The instance linked to the mod.</summary>
+ public T Data { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="mod">The mod metadata.</param>
+ /// <param name="data">The instance linked to the mod.</param>
+ public ModLinked(IModMetadata mod, T data)
+ {
+ this.Mod = mod;
+ this.Data = data;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/PerformanceMonitoring/AlertContext.cs b/src/SMAPI/Framework/PerformanceMonitoring/AlertContext.cs
new file mode 100644
index 00000000..01197f74
--- /dev/null
+++ b/src/SMAPI/Framework/PerformanceMonitoring/AlertContext.cs
@@ -0,0 +1,34 @@
+namespace StardewModdingAPI.Framework.PerformanceMonitoring
+{
+ /// <summary>The context for an alert.</summary>
+ internal struct AlertContext
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The source which triggered the alert.</summary>
+ public string Source { get; }
+
+ /// <summary>The elapsed milliseconds.</summary>
+ public double Elapsed { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="source">The source which triggered the alert.</param>
+ /// <param name="elapsed">The elapsed milliseconds.</param>
+ public AlertContext(string source, double elapsed)
+ {
+ this.Source = source;
+ this.Elapsed = elapsed;
+ }
+
+ /// <summary>Get a human-readable text form of this instance.</summary>
+ public override string ToString()
+ {
+ return $"{this.Source}: {this.Elapsed:F2}ms";
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/PerformanceMonitoring/AlertEntry.cs b/src/SMAPI/Framework/PerformanceMonitoring/AlertEntry.cs
new file mode 100644
index 00000000..f5b80189
--- /dev/null
+++ b/src/SMAPI/Framework/PerformanceMonitoring/AlertEntry.cs
@@ -0,0 +1,38 @@
+namespace StardewModdingAPI.Framework.PerformanceMonitoring
+{
+ /// <summary>A single alert entry.</summary>
+ internal struct AlertEntry
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The collection in which the alert occurred.</summary>
+ public PerformanceCounterCollection Collection { get; }
+
+ /// <summary>The actual execution time in milliseconds.</summary>
+ public double ExecutionTimeMilliseconds { get; }
+
+ /// <summary>The configured alert threshold in milliseconds.</summary>
+ public double ThresholdMilliseconds { get; }
+
+ /// <summary>The sources involved in exceeding the threshold.</summary>
+ public AlertContext[] Context { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="collection">The collection in which the alert occurred.</param>
+ /// <param name="executionTimeMilliseconds">The actual execution time in milliseconds.</param>
+ /// <param name="thresholdMilliseconds">The configured alert threshold in milliseconds.</param>
+ /// <param name="context">The sources involved in exceeding the threshold.</param>
+ public AlertEntry(PerformanceCounterCollection collection, double executionTimeMilliseconds, double thresholdMilliseconds, AlertContext[] context)
+ {
+ this.Collection = collection;
+ this.ExecutionTimeMilliseconds = executionTimeMilliseconds;
+ this.ThresholdMilliseconds = thresholdMilliseconds;
+ this.Context = context;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/PerformanceMonitoring/PeakEntry.cs b/src/SMAPI/Framework/PerformanceMonitoring/PeakEntry.cs
new file mode 100644
index 00000000..cff502ad
--- /dev/null
+++ b/src/SMAPI/Framework/PerformanceMonitoring/PeakEntry.cs
@@ -0,0 +1,35 @@
+using System;
+
+namespace StardewModdingAPI.Framework.PerformanceMonitoring
+{
+ /// <summary>A peak invocation time.</summary>
+ internal struct PeakEntry
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The actual execution time in milliseconds.</summary>
+ public double ExecutionTimeMilliseconds { get; }
+
+ /// <summary>When the entry occurred.</summary>
+ public DateTime EventTime { get; }
+
+ /// <summary>The sources involved in exceeding the threshold.</summary>
+ public AlertContext[] Context { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="executionTimeMilliseconds">The actual execution time in milliseconds.</param>
+ /// <param name="eventTime">When the entry occurred.</param>
+ /// <param name="context">The sources involved in exceeding the threshold.</param>
+ public PeakEntry(double executionTimeMilliseconds, DateTime eventTime, AlertContext[] context)
+ {
+ this.ExecutionTimeMilliseconds = executionTimeMilliseconds;
+ this.EventTime = eventTime;
+ this.Context = context;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounter.cs b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounter.cs
new file mode 100644
index 00000000..3cf668ee
--- /dev/null
+++ b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounter.cs
@@ -0,0 +1,125 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Harmony;
+
+namespace StardewModdingAPI.Framework.PerformanceMonitoring
+{
+ /// <summary>Tracks metadata about a particular code event.</summary>
+ internal class PerformanceCounter
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The size of the ring buffer.</summary>
+ private readonly int MaxEntries = 16384;
+
+ /// <summary>The collection to which this performance counter belongs.</summary>
+ private readonly PerformanceCounterCollection ParentCollection;
+
+ /// <summary>The performance counter entries.</summary>
+ private readonly Stack<PerformanceCounterEntry> Entries;
+
+ /// <summary>The entry with the highest execution time.</summary>
+ private PerformanceCounterEntry? PeakPerformanceCounterEntry;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The name of the source.</summary>
+ public string Source { get; }
+
+ /// <summary>The alert threshold in milliseconds</summary>
+ public double AlertThresholdMilliseconds { get; set; }
+
+ /// <summary>If alerting is enabled or not</summary>
+ public bool EnableAlerts { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="parentCollection">The collection to which this performance counter belongs.</param>
+ /// <param name="source">The name of the source.</param>
+ public PerformanceCounter(PerformanceCounterCollection parentCollection, string source)
+ {
+ this.ParentCollection = parentCollection;
+ this.Source = source;
+ this.Entries = new Stack<PerformanceCounterEntry>(this.MaxEntries);
+ }
+
+ /// <summary>Add a performance counter entry to the list, update monitoring, and raise alerts if needed.</summary>
+ /// <param name="entry">The entry to add.</param>
+ public void Add(PerformanceCounterEntry entry)
+ {
+ // add entry
+ if (this.Entries.Count > this.MaxEntries)
+ this.Entries.Pop();
+ this.Entries.Add(entry);
+
+ // update metrics
+ if (this.PeakPerformanceCounterEntry == null || entry.ElapsedMilliseconds > this.PeakPerformanceCounterEntry.Value.ElapsedMilliseconds)
+ this.PeakPerformanceCounterEntry = entry;
+
+ // raise alert
+ if (this.EnableAlerts && entry.ElapsedMilliseconds > this.AlertThresholdMilliseconds)
+ this.ParentCollection.AddAlert(entry.ElapsedMilliseconds, this.AlertThresholdMilliseconds, new AlertContext(this.Source, entry.ElapsedMilliseconds));
+ }
+
+ /// <summary>Clear all performance counter entries and monitoring.</summary>
+ public void Reset()
+ {
+ this.Entries.Clear();
+ this.PeakPerformanceCounterEntry = null;
+ }
+
+ /// <summary>Get the peak entry.</summary>
+ public PerformanceCounterEntry? GetPeak()
+ {
+ return this.PeakPerformanceCounterEntry;
+ }
+
+ /// <summary>Get the entry with the highest execution time.</summary>
+ /// <param name="range">The time range to search.</param>
+ /// <param name="endTime">The end time for the <paramref name="range"/>, or null for the current time.</param>
+ public PerformanceCounterEntry? GetPeak(TimeSpan range, DateTime? endTime = null)
+ {
+ endTime ??= DateTime.UtcNow;
+ DateTime startTime = endTime.Value.Subtract(range);
+
+ return this.Entries
+ .Where(entry => entry.EventTime >= startTime && entry.EventTime <= endTime)
+ .OrderByDescending(x => x.ElapsedMilliseconds)
+ .FirstOrDefault();
+ }
+
+ /// <summary>Get the last entry added to the list.</summary>
+ public PerformanceCounterEntry? GetLastEntry()
+ {
+ if (this.Entries.Count == 0)
+ return null;
+
+ return this.Entries.Peek();
+ }
+
+ /// <summary>Get the average over a given time span.</summary>
+ /// <param name="range">The time range to search.</param>
+ /// <param name="endTime">The end time for the <paramref name="range"/>, or null for the current time.</param>
+ public double GetAverage(TimeSpan range, DateTime? endTime = null)
+ {
+ endTime ??= DateTime.UtcNow;
+ DateTime startTime = endTime.Value.Subtract(range);
+
+ double[] entries = this.Entries
+ .Where(entry => entry.EventTime >= startTime && entry.EventTime <= endTime)
+ .Select(p => p.ElapsedMilliseconds)
+ .ToArray();
+
+ return entries.Length > 0
+ ? entries.Average()
+ : 0;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterCollection.cs b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterCollection.cs
new file mode 100644
index 00000000..0bb78c74
--- /dev/null
+++ b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterCollection.cs
@@ -0,0 +1,205 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+
+namespace StardewModdingAPI.Framework.PerformanceMonitoring
+{
+ internal class PerformanceCounterCollection
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The number of peak invocations to keep.</summary>
+ private readonly int MaxEntries = 16384;
+
+ /// <summary>The sources involved in exceeding alert thresholds.</summary>
+ private readonly List<AlertContext> TriggeredPerformanceCounters = new List<AlertContext>();
+
+ /// <summary>The stopwatch used to track the invocation time.</summary>
+ private readonly Stopwatch InvocationStopwatch = new Stopwatch();
+
+ /// <summary>The performance counter manager.</summary>
+ private readonly PerformanceMonitor PerformanceMonitor;
+
+ /// <summary>The time to calculate average calls per second.</summary>
+ private DateTime CallsPerSecondStart = DateTime.UtcNow;
+
+ /// <summary>The number of invocations.</summary>
+ private long CallCount;
+
+ /// <summary>The peak invocations.</summary>
+ private readonly Stack<PeakEntry> PeakInvocations;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The associated performance counters.</summary>
+ public IDictionary<string, PerformanceCounter> PerformanceCounters { get; } = new Dictionary<string, PerformanceCounter>();
+
+ /// <summary>The name of this collection.</summary>
+ public string Name { get; }
+
+ /// <summary>Whether the source is typically invoked at least once per second.</summary>
+ public bool IsPerformanceCritical { get; }
+
+ /// <summary>The alert threshold in milliseconds.</summary>
+ public double AlertThresholdMilliseconds { get; set; }
+
+ /// <summary>Whether alerts are enabled.</summary>
+ public bool EnableAlerts { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="performanceMonitor">The performance counter manager.</param>
+ /// <param name="name">The name of this collection.</param>
+ /// <param name="isPerformanceCritical">Whether the source is typically invoked at least once per second.</param>
+ public PerformanceCounterCollection(PerformanceMonitor performanceMonitor, string name, bool isPerformanceCritical = false)
+ {
+ this.PeakInvocations = new Stack<PeakEntry>(this.MaxEntries);
+ this.Name = name;
+ this.PerformanceMonitor = performanceMonitor;
+ this.IsPerformanceCritical = isPerformanceCritical;
+ }
+
+ /// <summary>Track a single invocation for a named source.</summary>
+ /// <param name="source">The name of the source.</param>
+ /// <param name="entry">The entry.</param>
+ public void Track(string source, PerformanceCounterEntry entry)
+ {
+ // add entry
+ if (!this.PerformanceCounters.ContainsKey(source))
+ this.PerformanceCounters.Add(source, new PerformanceCounter(this, source));
+ this.PerformanceCounters[source].Add(entry);
+
+ // raise alert
+ if (this.EnableAlerts)
+ this.TriggeredPerformanceCounters.Add(new AlertContext(source, entry.ElapsedMilliseconds));
+ }
+
+ /// <summary>Get the average execution time for all non-game internal sources in milliseconds.</summary>
+ /// <param name="interval">The interval for which to get the average, relative to now</param>
+ public double GetModsAverageExecutionTime(TimeSpan interval)
+ {
+ return this.PerformanceCounters
+ .Where(entry => entry.Key != Constants.GamePerformanceCounterName)
+ .Sum(entry => entry.Value.GetAverage(interval));
+ }
+
+ /// <summary>Get the overall average execution time in milliseconds.</summary>
+ /// <param name="interval">The interval for which to get the average, relative to now</param>
+ public double GetAverageExecutionTime(TimeSpan interval)
+ {
+ return this.PerformanceCounters
+ .Sum(entry => entry.Value.GetAverage(interval));
+ }
+
+ /// <summary>Get the average execution time for game-internal sources in milliseconds.</summary>
+ public double GetGameAverageExecutionTime(TimeSpan interval)
+ {
+ return this.PerformanceCounters.TryGetValue(Constants.GamePerformanceCounterName, out PerformanceCounter gameExecTime)
+ ? gameExecTime.GetAverage(interval)
+ : 0;
+ }
+
+ /// <summary>Get the peak execution time in milliseconds.</summary>
+ /// <param name="range">The time range to search.</param>
+ /// <param name="endTime">The end time for the <paramref name="range"/>, or null for the current time.</param>
+ public double GetPeakExecutionTime(TimeSpan range, DateTime? endTime = null)
+ {
+ if (this.PeakInvocations.Count == 0)
+ return 0;
+
+ endTime ??= DateTime.UtcNow;
+ DateTime startTime = endTime.Value.Subtract(range);
+
+ return this.PeakInvocations
+ .Where(entry => entry.EventTime >= startTime && entry.EventTime <= endTime)
+ .OrderByDescending(x => x.ExecutionTimeMilliseconds)
+ .Select(p => p.ExecutionTimeMilliseconds)
+ .FirstOrDefault();
+ }
+
+ /// <summary>Start tracking the invocation of this collection.</summary>
+ public void BeginTrackInvocation()
+ {
+ this.TriggeredPerformanceCounters.Clear();
+ this.InvocationStopwatch.Reset();
+ this.InvocationStopwatch.Start();
+
+ this.CallCount++;
+ }
+
+ /// <summary>End tracking the invocation of this collection, and raise an alert if needed.</summary>
+ public void EndTrackInvocation()
+ {
+ this.InvocationStopwatch.Stop();
+
+ // add invocation
+ if (this.PeakInvocations.Count >= this.MaxEntries)
+ this.PeakInvocations.Pop();
+ this.PeakInvocations.Push(new PeakEntry(this.InvocationStopwatch.Elapsed.TotalMilliseconds, DateTime.UtcNow, this.TriggeredPerformanceCounters.ToArray()));
+
+ // raise alert
+ if (this.EnableAlerts && this.InvocationStopwatch.Elapsed.TotalMilliseconds >= this.AlertThresholdMilliseconds)
+ this.AddAlert(this.InvocationStopwatch.Elapsed.TotalMilliseconds, this.AlertThresholdMilliseconds, this.TriggeredPerformanceCounters.ToArray());
+ }
+
+ /// <summary>Add an alert.</summary>
+ /// <param name="executionTimeMilliseconds">The execution time in milliseconds.</param>
+ /// <param name="thresholdMilliseconds">The configured threshold.</param>
+ /// <param name="alerts">The sources involved in exceeding the threshold.</param>
+ public void AddAlert(double executionTimeMilliseconds, double thresholdMilliseconds, AlertContext[] alerts)
+ {
+ this.PerformanceMonitor.AddAlert(
+ new AlertEntry(this, executionTimeMilliseconds, thresholdMilliseconds, alerts)
+ );
+ }
+
+ /// <summary>Add an alert.</summary>
+ /// <param name="executionTimeMilliseconds">The execution time in milliseconds.</param>
+ /// <param name="thresholdMilliseconds">The configured threshold.</param>
+ /// <param name="alert">The source involved in exceeding the threshold.</param>
+ public void AddAlert(double executionTimeMilliseconds, double thresholdMilliseconds, AlertContext alert)
+ {
+ this.AddAlert(executionTimeMilliseconds, thresholdMilliseconds, new[] { alert });
+ }
+
+ /// <summary>Reset the calls per second counter.</summary>
+ public void ResetCallsPerSecond()
+ {
+ this.CallCount = 0;
+ this.CallsPerSecondStart = DateTime.UtcNow;
+ }
+
+ /// <summary>Reset all performance counters in this collection.</summary>
+ public void Reset()
+ {
+ this.PeakInvocations.Clear();
+ foreach (var counter in this.PerformanceCounters)
+ counter.Value.Reset();
+ }
+
+ /// <summary>Reset the performance counter for a specific source.</summary>
+ /// <param name="source">The source name.</param>
+ public void ResetSource(string source)
+ {
+ foreach (var i in this.PerformanceCounters)
+ if (i.Value.Source.Equals(source, StringComparison.InvariantCultureIgnoreCase))
+ i.Value.Reset();
+ }
+
+ /// <summary>Get the average calls per second.</summary>
+ public long GetAverageCallsPerSecond()
+ {
+ long runtimeInSeconds = (long)DateTime.UtcNow.Subtract(this.CallsPerSecondStart).TotalSeconds;
+ return runtimeInSeconds > 0
+ ? this.CallCount / runtimeInSeconds
+ : 0;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterEntry.cs b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterEntry.cs
new file mode 100644
index 00000000..8adbd88d
--- /dev/null
+++ b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterEntry.cs
@@ -0,0 +1,30 @@
+using System;
+
+namespace StardewModdingAPI.Framework.PerformanceMonitoring
+{
+ /// <summary>A single performance counter entry.</summary>
+ internal struct PerformanceCounterEntry
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>When the entry occurred.</summary>
+ public DateTime EventTime { get; }
+
+ /// <summary>The elapsed milliseconds.</summary>
+ public double ElapsedMilliseconds { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="eventTime">When the entry occurred.</param>
+ /// <param name="elapsedMilliseconds">The elapsed milliseconds.</param>
+ public PerformanceCounterEntry(DateTime eventTime, double elapsedMilliseconds)
+ {
+ this.EventTime = eventTime;
+ this.ElapsedMilliseconds = elapsedMilliseconds;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/PerformanceMonitoring/PerformanceMonitor.cs b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceMonitor.cs
new file mode 100644
index 00000000..dfc4f31a
--- /dev/null
+++ b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceMonitor.cs
@@ -0,0 +1,184 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Text;
+using StardewModdingAPI.Framework.Events;
+
+namespace StardewModdingAPI.Framework.PerformanceMonitoring
+{
+ /// <summary>Tracks performance metrics.</summary>
+ internal class PerformanceMonitor
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The recorded alerts.</summary>
+ private readonly IList<AlertEntry> Alerts = new List<AlertEntry>();
+
+ /// <summary>The monitor for output logging.</summary>
+ private readonly IMonitor Monitor;
+
+ /// <summary>The invocation stopwatch.</summary>
+ private readonly Stopwatch InvocationStopwatch = new Stopwatch();
+
+ /// <summary>The underlying performance counter collections.</summary>
+ private readonly IDictionary<string, PerformanceCounterCollection> Collections = new Dictionary<string, PerformanceCounterCollection>(StringComparer.InvariantCultureIgnoreCase);
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Whether alerts are paused.</summary>
+ public bool PauseAlerts { get; set; }
+
+ /// <summary>Whether performance counter tracking is enabled.</summary>
+ public bool EnableTracking { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="monitor">The monitor for output logging.</param>
+ public PerformanceMonitor(IMonitor monitor)
+ {
+ this.Monitor = monitor;
+ }
+
+ /// <summary>Reset all performance counters in all collections.</summary>
+ public void Reset()
+ {
+ foreach (PerformanceCounterCollection collection in this.Collections.Values)
+ collection.Reset();
+ }
+
+ /// <summary>Track the invocation time for a collection.</summary>
+ /// <param name="collectionName">The name of the collection.</param>
+ /// <param name="action">The action to execute and track.</param>
+ public void Track(string collectionName, Action action)
+ {
+ if (!this.EnableTracking)
+ {
+ action();
+ return;
+ }
+
+ PerformanceCounterCollection collection = this.GetOrCreateCollectionByName(collectionName);
+ collection.BeginTrackInvocation();
+ try
+ {
+ action();
+ }
+ finally
+ {
+ collection.EndTrackInvocation();
+ }
+ }
+
+ /// <summary>Track a single performance counter invocation in a specific collection.</summary>
+ /// <param name="collectionName">The name of the collection.</param>
+ /// <param name="sourceName">The name of the source.</param>
+ /// <param name="action">The action to execute and track.</param>
+ public void Track(string collectionName, string sourceName, Action action)
+ {
+ if (!this.EnableTracking)
+ {
+ action();
+ return;
+ }
+
+ PerformanceCounterCollection collection = this.GetOrCreateCollectionByName(collectionName);
+ DateTime eventTime = DateTime.UtcNow;
+ this.InvocationStopwatch.Reset();
+ this.InvocationStopwatch.Start();
+
+ try
+ {
+ action();
+ }
+ finally
+ {
+ this.InvocationStopwatch.Stop();
+ collection.Track(sourceName, new PerformanceCounterEntry(eventTime, this.InvocationStopwatch.Elapsed.TotalMilliseconds));
+ }
+ }
+
+ /// <summary>Reset the performance counters for a specific collection.</summary>
+ /// <param name="name">The collection name.</param>
+ public void ResetCollection(string name)
+ {
+ if (this.Collections.TryGetValue(name, out PerformanceCounterCollection collection))
+ {
+ collection.ResetCallsPerSecond();
+ collection.Reset();
+ }
+ }
+
+ /// <summary>Reset performance counters for a specific source.</summary>
+ /// <param name="name">The name of the source.</param>
+ public void ResetSource(string name)
+ {
+ foreach (PerformanceCounterCollection performanceCounterCollection in this.Collections.Values)
+ performanceCounterCollection.ResetSource(name);
+ }
+
+ /// <summary>Print any queued alerts.</summary>
+ public void PrintQueuedAlerts()
+ {
+ if (this.Alerts.Count == 0)
+ return;
+
+ StringBuilder report = new StringBuilder();
+
+ foreach (AlertEntry alert in this.Alerts)
+ {
+ report.AppendLine($"{alert.Collection.Name} took {alert.ExecutionTimeMilliseconds:F2}ms (exceeded threshold of {alert.ThresholdMilliseconds:F2}ms)");
+
+ foreach (AlertContext context in alert.Context.OrderByDescending(p => p.Elapsed))
+ report.AppendLine(context.ToString());
+ }
+
+ this.Alerts.Clear();
+ this.Monitor.Log(report.ToString(), LogLevel.Error);
+ }
+
+ /// <summary>Add an alert to the queue.</summary>
+ /// <param name="entry">The alert to add.</param>
+ public void AddAlert(AlertEntry entry)
+ {
+ if (!this.PauseAlerts)
+ this.Alerts.Add(entry);
+ }
+
+ /// <summary>Initialize the default performance counter collections.</summary>
+ /// <param name="eventManager">The event manager.</param>
+ public void InitializePerformanceCounterCollections(EventManager eventManager)
+ {
+ foreach (IManagedEvent @event in eventManager.GetAllEvents())
+ this.Collections[@event.EventName] = new PerformanceCounterCollection(this, @event.EventName, @event.IsPerformanceCritical);
+ }
+
+ /// <summary>Get the underlying performance counters.</summary>
+ public IEnumerable<PerformanceCounterCollection> GetCollections()
+ {
+ return this.Collections.Values;
+ }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Get a collection by name and creates it if it doesn't exist.</summary>
+ /// <param name="name">The name of the collection.</param>
+ private PerformanceCounterCollection GetOrCreateCollectionByName(string name)
+ {
+ if (!this.Collections.TryGetValue(name, out PerformanceCounterCollection collection))
+ {
+ collection = new PerformanceCounterCollection(this, name);
+ this.Collections[name] = collection;
+ }
+ return collection;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs
index dfd77e16..50e6ea1c 100644
--- a/src/SMAPI/Framework/SCore.cs
+++ b/src/SMAPI/Framework/SCore.cs
@@ -23,6 +23,7 @@ using StardewModdingAPI.Framework.Models;
using StardewModdingAPI.Framework.ModHelpers;
using StardewModdingAPI.Framework.ModLoading;
using StardewModdingAPI.Framework.Patching;
+using StardewModdingAPI.Framework.PerformanceMonitoring;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.Serialization;
using StardewModdingAPI.Patches;
@@ -109,7 +110,7 @@ namespace StardewModdingAPI.Framework
"Oops! Steam achievements won't work because Steam isn't loaded. You can launch the game through Steam to fix that.",
#endif
logLevel: LogLevel.Error
- ),
+ ),
// save file not found error
new ReplaceLogPattern(
@@ -133,6 +134,10 @@ namespace StardewModdingAPI.Framework
/// <remarks>This is initialized after the game starts. This is accessed directly because it's not part of the normal class model.</remarks>
internal static DeprecationManager DeprecationManager { get; private set; }
+ /// <summary>Manages performance counters.</summary>
+ /// <remarks>This is initialized after the game starts. This is non-private for use by Console Commands.</remarks>
+ internal static PerformanceMonitor PerformanceMonitor { get; private set; }
+
/*********
** Public methods
@@ -153,6 +158,9 @@ namespace StardewModdingAPI.Framework
// init basics
this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath));
+ if (File.Exists(Constants.ApiUserConfigPath))
+ JsonConvert.PopulateObject(File.ReadAllText(Constants.ApiUserConfigPath), this.Settings);
+
this.LogFile = new LogFileManager(logPath);
this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.Settings.ConsoleColors, this.Settings.VerboseLogging)
{
@@ -161,7 +169,11 @@ namespace StardewModdingAPI.Framework
ShowFullStampInConsole = this.Settings.DeveloperMode
};
this.MonitorForGame = this.GetSecondaryMonitor("game");
- this.EventManager = new EventManager(this.Monitor, this.ModRegistry);
+
+ SCore.PerformanceMonitor = new PerformanceMonitor(this.Monitor);
+ this.EventManager = new EventManager(this.Monitor, this.ModRegistry, SCore.PerformanceMonitor);
+ SCore.PerformanceMonitor.InitializePerformanceCounterCollections(this.EventManager);
+
SCore.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry);
// redirect direct console output
@@ -193,7 +205,7 @@ namespace StardewModdingAPI.Framework
#else
if (Constants.Platform == Platform.Windows)
{
- this.Monitor.Log("Oops! You're running {Constants.Platform}, but this version of SMAPI is for Windows. Please reinstall SMAPI to fix this.", LogLevel.Error);
+ this.Monitor.Log($"Oops! You're running {Constants.Platform}, but this version of SMAPI is for Windows. Please reinstall SMAPI to fix this.", LogLevel.Error);
this.PressAnyKeyToExit();
return;
}
@@ -210,6 +222,7 @@ namespace StardewModdingAPI.Framework
JsonConverter[] converters = {
new ColorConverter(),
new PointConverter(),
+ new Vector2Converter(),
new RectangleConverter()
};
foreach (JsonConverter converter in converters)
@@ -222,7 +235,7 @@ namespace StardewModdingAPI.Framework
#endif
AppDomain.CurrentDomain.UnhandledException += (sender, e) => this.Monitor.Log($"Critical app domain exception: {e.ExceptionObject}", LogLevel.Error);
- // add more lenient assembly resolvers
+ // add more lenient assembly resolver
AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => AssemblyLoader.ResolveAssembly(e.Name);
// hook locale event
@@ -239,6 +252,7 @@ namespace StardewModdingAPI.Framework
jsonHelper: this.Toolkit.JsonHelper,
modRegistry: this.ModRegistry,
deprecationManager: SCore.DeprecationManager,
+ performanceMonitor: SCore.PerformanceMonitor,
onGameInitialized: this.InitializeAfterGameStart,
onGameExiting: this.Dispose,
cancellationToken: this.CancellationToken,
@@ -253,7 +267,8 @@ namespace StardewModdingAPI.Framework
new DialogueErrorPatch(this.MonitorForGame, this.Reflection),
new ObjectErrorPatch(),
new LoadContextPatch(this.Reflection, this.GameInstance.OnLoadStageChanged),
- new LoadErrorPatch(this.Monitor, this.GameInstance.OnSaveContentRemoved)
+ new LoadErrorPatch(this.Monitor, this.GameInstance.OnSaveContentRemoved),
+ new ScheduleErrorPatch(this.MonitorForGame)
);
// add exit handler
@@ -307,7 +322,7 @@ namespace StardewModdingAPI.Framework
// show details if game crashed during last session
if (File.Exists(Constants.FatalCrashMarker))
{
- this.Monitor.Log("The game crashed last time you played. That can be due to bugs in the game, but if it happens repeatedly you can ask for help here: https://community.playstarbound.com/threads/108375/.", LogLevel.Error);
+ this.Monitor.Log("The game crashed last time you played. If it happens repeatedly, see 'get help' on https://smapi.io.", LogLevel.Error);
this.Monitor.Log("If you ask for help, make sure to share your SMAPI log: https://smapi.io/log.", LogLevel.Error);
this.Monitor.Log("Press any key to delete the crash data and continue playing.", LogLevel.Info);
Console.ReadKey();
@@ -406,6 +421,17 @@ namespace StardewModdingAPI.Framework
return;
}
+ // init TMX support
+ try
+ {
+ xTile.Format.FormatManager.Instance.RegisterMapFormat(new TMXTile.TMXFormat(Game1.tileSize / Game1.pixelZoom, Game1.tileSize / Game1.pixelZoom, Game1.pixelZoom, Game1.pixelZoom));
+ }
+ catch (Exception ex)
+ {
+ this.Monitor.Log("SMAPI couldn't load TMX support. Some mods may not work correctly.", LogLevel.Warn);
+ this.Monitor.Log($"Technical details: {ex.GetLogSummary()}", LogLevel.Trace);
+ }
+
// load mod data
ModToolkit toolkit = new ModToolkit();
ModDatabase modDatabase = toolkit.GetModDatabase(Constants.ApiMetadataPath);
@@ -589,6 +615,8 @@ namespace StardewModdingAPI.Framework
else
this.Monitor.Log(" SMAPI okay.", LogLevel.Trace);
+ updateFound = response.SuggestedUpdate?.Version;
+
// show errors
if (response.Errors.Any())
{
@@ -791,13 +819,13 @@ namespace StardewModdingAPI.Framework
{
// ReSharper disable SuspiciousTypeConversion.Global
if (metadata.Mod is IAssetEditor editor)
- helper.ObservableAssetEditors.Add(editor);
+ this.ContentCore.Editors.Add(new ModLinked<IAssetEditor>(metadata, editor));
if (metadata.Mod is IAssetLoader loader)
- helper.ObservableAssetLoaders.Add(loader);
+ this.ContentCore.Loaders.Add(new ModLinked<IAssetLoader>(metadata, loader));
// ReSharper restore SuspiciousTypeConversion.Global
- this.ContentCore.Editors[metadata] = helper.ObservableAssetEditors;
- this.ContentCore.Loaders[metadata] = helper.ObservableAssetLoaders;
+ helper.ObservableAssetEditors.CollectionChanged += (sender, e) => this.OnInterceptorsChanged(metadata, e.NewItems?.Cast<IAssetEditor>(), e.OldItems?.Cast<IAssetEditor>(), this.ContentCore.Editors);
+ helper.ObservableAssetLoaders.CollectionChanged += (sender, e) => this.OnInterceptorsChanged(metadata, e.NewItems?.Cast<IAssetLoader>(), e.OldItems?.Cast<IAssetLoader>(), this.ContentCore.Loaders);
}
// call entry method
@@ -846,6 +874,24 @@ namespace StardewModdingAPI.Framework
this.ModRegistry.AreAllModsInitialized = true;
}
+ /// <summary>Handle a mod adding or removing asset interceptors.</summary>
+ /// <typeparam name="T">The asset interceptor type (one of <see cref="IAssetEditor"/> or <see cref="IAssetLoader"/>).</typeparam>
+ /// <param name="mod">The mod metadata.</param>
+ /// <param name="added">The interceptors that were added.</param>
+ /// <param name="removed">The interceptors that were removed.</param>
+ /// <param name="list">The list to update.</param>
+ private void OnInterceptorsChanged<T>(IModMetadata mod, IEnumerable<T> added, IEnumerable<T> removed, IList<ModLinked<T>> list)
+ {
+ foreach (T interceptor in added ?? new T[0])
+ list.Add(new ModLinked<T>(mod, interceptor));
+
+ foreach (T interceptor in removed ?? new T[0])
+ {
+ foreach (ModLinked<T> entry in list.Where(p => p.Mod == mod && object.ReferenceEquals(p.Data, interceptor)).ToArray())
+ list.Remove(entry);
+ }
+ }
+
/// <summary>Load a given mod.</summary>
/// <param name="mod">The mod to load.</param>
/// <param name="mods">The mods being loaded.</param>
diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs
index d6c3b836..4b346059 100644
--- a/src/SMAPI/Framework/SGame.cs
+++ b/src/SMAPI/Framework/SGame.cs
@@ -17,6 +17,7 @@ using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.Events;
using StardewModdingAPI.Framework.Input;
using StardewModdingAPI.Framework.Networking;
+using StardewModdingAPI.Framework.PerformanceMonitoring;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.StateTracking.Comparers;
using StardewModdingAPI.Framework.StateTracking.Snapshots;
@@ -58,6 +59,9 @@ namespace StardewModdingAPI.Framework
/// <summary>Manages deprecation warnings.</summary>
private readonly DeprecationManager DeprecationManager;
+ /// <summary>Tracks performance metrics.</summary>
+ private readonly PerformanceMonitor PerformanceMonitor;
+
/// <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
@@ -152,11 +156,12 @@ namespace StardewModdingAPI.Framework
/// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param>
/// <param name="modRegistry">Tracks the installed mods.</param>
/// <param name="deprecationManager">Manages deprecation warnings.</param>
+ /// <param name="performanceMonitor">Tracks performance metrics.</param>
/// <param name="onGameInitialized">A callback to invoke after the game finishes initializing.</param>
/// <param name="onGameExiting">A callback to invoke when the game exits.</param>
/// <param name="cancellationToken">Propagates notification that SMAPI should exit.</param>
/// <param name="logNetworkTraffic">Whether to log network traffic.</param>
- internal SGame(Monitor monitor, IMonitor monitorForGame, Reflector reflection, Translator translator, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, DeprecationManager deprecationManager, Action onGameInitialized, Action onGameExiting, CancellationTokenSource cancellationToken, bool logNetworkTraffic)
+ internal SGame(Monitor monitor, IMonitor monitorForGame, Reflector reflection, Translator translator, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, DeprecationManager deprecationManager, PerformanceMonitor performanceMonitor, Action onGameInitialized, Action onGameExiting, CancellationTokenSource cancellationToken, bool logNetworkTraffic)
{
this.OnLoadingFirstAsset = SGame.ConstructorHack.OnLoadingFirstAsset;
SGame.ConstructorHack = null;
@@ -176,6 +181,7 @@ namespace StardewModdingAPI.Framework
this.Reflection = reflection;
this.Translator = translator;
this.DeprecationManager = deprecationManager;
+ this.PerformanceMonitor = performanceMonitor;
this.OnGameInitialized = onGameInitialized;
this.OnGameExiting = onGameExiting;
Game1.input = new SInputState();
@@ -307,6 +313,7 @@ namespace StardewModdingAPI.Framework
try
{
this.DeprecationManager.PrintQueued();
+ this.PerformanceMonitor.PrintQueuedAlerts();
/*********
** First-tick initialization
@@ -382,7 +389,7 @@ namespace StardewModdingAPI.Framework
// state while mods are running their code. This is risky, because data changes can
// conflict (e.g. collection changed during enumeration errors) and data may change
// unexpectedly from one mod instruction to the next.
- //
+ //
// Therefore we can just run Game1.Update here without raising any SMAPI events. There's
// a small chance that the task will finish after we defer but before the game checks,
// which means technically events should be raised, but the effects of missing one
diff --git a/src/SMAPI/Framework/Serialization/PointConverter.cs b/src/SMAPI/Framework/Serialization/PointConverter.cs
index 8c2f3396..3481c9b2 100644
--- a/src/SMAPI/Framework/Serialization/PointConverter.cs
+++ b/src/SMAPI/Framework/Serialization/PointConverter.cs
@@ -6,7 +6,7 @@ using StardewModdingAPI.Toolkit.Serialization.Converters;
namespace StardewModdingAPI.Framework.Serialization
{
- /// <summary>Handles deserialization of <see cref="PointConverter"/> for crossplatform compatibility.</summary>
+ /// <summary>Handles deserialization of <see cref="Point"/> for crossplatform compatibility.</summary>
/// <remarks>
/// - Linux/Mac format: { "X": 1, "Y": 2 }
/// - Windows format: "1, 2"
diff --git a/src/SMAPI/Framework/Serialization/Vector2Converter.cs b/src/SMAPI/Framework/Serialization/Vector2Converter.cs
new file mode 100644
index 00000000..1d9b08e0
--- /dev/null
+++ b/src/SMAPI/Framework/Serialization/Vector2Converter.cs
@@ -0,0 +1,43 @@
+using System;
+using Microsoft.Xna.Framework;
+using Newtonsoft.Json.Linq;
+using StardewModdingAPI.Toolkit.Serialization;
+using StardewModdingAPI.Toolkit.Serialization.Converters;
+
+namespace StardewModdingAPI.Framework.Serialization
+{
+ /// <summary>Handles deserialization of <see cref="Vector2"/> for crossplatform compatibility.</summary>
+ /// <remarks>
+ /// - Linux/Mac format: { "X": 1, "Y": 2 }
+ /// - Windows format: "1, 2"
+ /// </remarks>
+ internal class Vector2Converter : SimpleReadOnlyConverter<Vector2>
+ {
+ /*********
+ ** Protected methods
+ *********/
+ /// <summary>Read a JSON object.</summary>
+ /// <param name="obj">The JSON object to read.</param>
+ /// <param name="path">The path to the current JSON node.</param>
+ protected override Vector2 ReadObject(JObject obj, string path)
+ {
+ float x = obj.ValueIgnoreCase<float>(nameof(Vector2.X));
+ float y = obj.ValueIgnoreCase<float>(nameof(Vector2.Y));
+ return new Vector2(x, y);
+ }
+
+ /// <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 Vector2 ReadString(string str, string path)
+ {
+ string[] parts = str.Split(',');
+ if (parts.Length != 2)
+ throw new SParseException($"Can't parse {typeof(Vector2).Name} from invalid value '{str}' (path: {path}).");
+
+ float x = Convert.ToSingle(parts[0]);
+ float y = Convert.ToSingle(parts[1]);
+ return new Vector2(x, y);
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/SnapshotListDiff.cs b/src/SMAPI/Framework/SnapshotListDiff.cs
index d4d5df50..2d0efa0d 100644
--- a/src/SMAPI/Framework/SnapshotListDiff.cs
+++ b/src/SMAPI/Framework/SnapshotListDiff.cs
@@ -42,10 +42,12 @@ namespace StardewModdingAPI.Framework
this.IsChanged = isChanged;
this.RemovedImpl.Clear();
- this.RemovedImpl.AddRange(removed);
+ if (removed != null)
+ this.RemovedImpl.AddRange(removed);
this.AddedImpl.Clear();
- this.AddedImpl.AddRange(added);
+ if (added != null)
+ this.AddedImpl.AddRange(added);
}
/// <summary>Update the snapshot.</summary>
diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs
index b86a6790..7a58d52c 100644
--- a/src/SMAPI/Metadata/CoreAssetPropagator.cs
+++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs
@@ -190,17 +190,9 @@ namespace StardewModdingAPI.Metadata
case "characters\\farmer\\farmer_base": // Farmer
case "characters\\farmer\\farmer_base_bald":
- if (Game1.player == null || !Game1.player.IsMale)
- return false;
- Game1.player.FarmerRenderer = new FarmerRenderer(key, Game1.player);
- return true;
-
- case "characters\\farmer\\farmer_girl_base": // Farmer
+ case "characters\\farmer\\farmer_girl_base":
case "characters\\farmer\\farmer_girl_base_bald":
- if (Game1.player == null || Game1.player.IsMale)
- return false;
- Game1.player.FarmerRenderer = new FarmerRenderer(key, Game1.player);
- return true;
+ return this.ReloadPlayerSprites(key);
case "characters\\farmer\\hairstyles": // Game1.LoadContent
FarmerRenderer.hairStylesTexture = content.Load<Texture2D>(key);
@@ -835,6 +827,27 @@ namespace StardewModdingAPI.Metadata
}
}
+ /// <summary>Reload the sprites for matching players.</summary>
+ /// <param name="key">The asset key to reload.</param>
+ private bool ReloadPlayerSprites(string key)
+ {
+ Farmer[] players =
+ (
+ from player in Game1.getOnlineFarmers()
+ where key == this.NormalizeAssetNameIgnoringEmpty(player.getTexture())
+ select player
+ )
+ .ToArray();
+
+ foreach (Farmer player in players)
+ {
+ this.Reflection.GetField<Dictionary<string, Dictionary<int, List<int>>>>(typeof(FarmerRenderer), "_recolorOffsets").GetValue().Remove(player.getTexture());
+ player.FarmerRenderer.MarkSpriteDirty();
+ }
+
+ return players.Any();
+ }
+
/// <summary>Reload tree textures.</summary>
/// <param name="content">The content manager through which to reload the asset.</param>
/// <param name="key">The asset key to reload.</param>
@@ -874,7 +887,11 @@ namespace StardewModdingAPI.Metadata
// update dialogue
foreach (NPC villager in villagers)
+ {
villager.resetSeasonalDialogue(); // doesn't only affect seasonal dialogue
+ villager.resetCurrentDialogue();
+ }
+
return true;
}
@@ -896,18 +913,16 @@ namespace StardewModdingAPI.Metadata
this.Reflection.GetField<bool>(villager, "_hasLoadedMasterScheduleData").SetValue(false);
this.Reflection.GetField<Dictionary<string, string>>(villager, "_masterScheduleData").SetValue(null);
villager.Schedule = villager.getSchedule(Game1.dayOfMonth);
- if (villager.Schedule == null)
- {
- this.Monitor.Log($"A mod set an invalid schedule for {villager.Name ?? key}, so the NPC may not behave correctly.", LogLevel.Warn);
- return true;
- }
// switch to new schedule if needed
- int lastScheduleTime = villager.Schedule.Keys.Where(p => p <= Game1.timeOfDay).OrderByDescending(p => p).FirstOrDefault();
- if (lastScheduleTime != 0)
+ if (villager.Schedule != null)
{
- villager.scheduleTimeToTry = NPC.NO_TRY; // use time that's passed in to checkSchedule
- villager.checkSchedule(lastScheduleTime);
+ int lastScheduleTime = villager.Schedule.Keys.Where(p => p <= Game1.timeOfDay).OrderByDescending(p => p).FirstOrDefault();
+ if (lastScheduleTime != 0)
+ {
+ villager.scheduleTimeToTry = NPC.NO_TRY; // use time that's passed in to checkSchedule
+ villager.checkSchedule(lastScheduleTime);
+ }
}
}
return true;
diff --git a/src/SMAPI/Patches/LoadErrorPatch.cs b/src/SMAPI/Patches/LoadErrorPatch.cs
index eedb4164..c16ca7cc 100644
--- a/src/SMAPI/Patches/LoadErrorPatch.cs
+++ b/src/SMAPI/Patches/LoadErrorPatch.cs
@@ -3,8 +3,10 @@ using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Harmony;
+using StardewModdingAPI.Framework.Exceptions;
using StardewModdingAPI.Framework.Patching;
using StardewValley;
+using StardewValley.Buildings;
using StardewValley.Locations;
namespace StardewModdingAPI.Patches
@@ -64,10 +66,24 @@ namespace StardewModdingAPI.Patches
/// <returns>Returns whether to execute the original method.</returns>
private static bool Before_SaveGame_LoadDataToLocations(List<GameLocation> gamelocations)
{
+ bool removedAny =
+ LoadErrorPatch.RemoveInvalidLocations(gamelocations)
+ | LoadErrorPatch.RemoveBrokenBuildings(gamelocations)
+ | LoadErrorPatch.RemoveInvalidNpcs(gamelocations);
+
+ if (removedAny)
+ LoadErrorPatch.OnContentRemoved();
+
+ return true;
+ }
+
+ /// <summary>Remove locations which don't exist in-game.</summary>
+ /// <param name="locations">The current game locations.</param>
+ private static bool RemoveInvalidLocations(List<GameLocation> locations)
+ {
bool removedAny = false;
- // remove invalid locations
- foreach (GameLocation location in gamelocations.ToArray())
+ foreach (GameLocation location in locations.ToArray())
{
if (location is Cellar)
continue; // missing cellars will be added by the game code
@@ -75,23 +91,48 @@ namespace StardewModdingAPI.Patches
if (Game1.getLocationFromName(location.name) == null)
{
LoadErrorPatch.Monitor.Log($"Removed invalid location '{location.Name}' to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom location mod?)", LogLevel.Warn);
- gamelocations.Remove(location);
+ locations.Remove(location);
removedAny = true;
}
}
- // get building interiors
- var interiors =
- (
- from location in gamelocations.OfType<BuildableGameLocation>()
- from building in location.buildings
- where building.indoors.Value != null
- select building.indoors.Value
- );
+ return removedAny;
+ }
+
+ /// <summary>Remove buildings which don't exist in the game data.</summary>
+ /// <param name="locations">The current game locations.</param>
+ private static bool RemoveBrokenBuildings(IEnumerable<GameLocation> locations)
+ {
+ bool removedAny = false;
+
+ foreach (BuildableGameLocation location in locations.OfType<BuildableGameLocation>())
+ {
+ foreach (Building building in location.buildings.ToArray())
+ {
+ try
+ {
+ BluePrint _ = new BluePrint(building.buildingType.Value);
+ }
+ catch (SContentLoadException)
+ {
+ LoadErrorPatch.Monitor.Log($"Removed invalid building type '{building.buildingType.Value}' in {location.Name} ({building.tileX}, {building.tileY}) to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom building mod?)", LogLevel.Warn);
+ location.buildings.Remove(building);
+ removedAny = true;
+ }
+ }
+ }
+
+ return removedAny;
+ }
+
+ /// <summary>Remove NPCs which don't exist in the game data.</summary>
+ /// <param name="locations">The current game locations.</param>
+ private static bool RemoveInvalidNpcs(IEnumerable<GameLocation> locations)
+ {
+ bool removedAny = false;
- // remove custom NPCs which no longer exist
IDictionary<string, string> data = Game1.content.Load<Dictionary<string, string>>("Data\\NPCDispositions");
- foreach (GameLocation location in gamelocations.Concat(interiors))
+ foreach (GameLocation location in LoadErrorPatch.GetAllLocations(locations))
{
foreach (NPC npc in location.characters.ToArray())
{
@@ -103,7 +144,7 @@ namespace StardewModdingAPI.Patches
}
catch
{
- LoadErrorPatch.Monitor.Log($"Removed invalid villager '{npc.Name}' to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom NPC mod?)", LogLevel.Warn);
+ LoadErrorPatch.Monitor.Log($"Removed invalid villager '{npc.Name}' in {location.Name} ({npc.getTileLocation()}) to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom NPC mod?)", LogLevel.Warn);
location.characters.Remove(npc);
removedAny = true;
}
@@ -111,10 +152,22 @@ namespace StardewModdingAPI.Patches
}
}
- if (removedAny)
- LoadErrorPatch.OnContentRemoved();
+ return removedAny;
+ }
- return true;
+ /// <summary>Get all locations, including building interiors.</summary>
+ /// <param name="locations">The main game locations.</param>
+ private static IEnumerable<GameLocation> GetAllLocations(IEnumerable<GameLocation> locations)
+ {
+ foreach (GameLocation location in locations)
+ {
+ yield return location;
+ if (location is BuildableGameLocation buildableLocation)
+ {
+ foreach (GameLocation interior in buildableLocation.buildings.Select(p => p.indoors.Value).Where(p => p != null))
+ yield return interior;
+ }
+ }
}
}
}
diff --git a/src/SMAPI/Patches/ScheduleErrorPatch.cs b/src/SMAPI/Patches/ScheduleErrorPatch.cs
new file mode 100644
index 00000000..a23aa645
--- /dev/null
+++ b/src/SMAPI/Patches/ScheduleErrorPatch.cs
@@ -0,0 +1,86 @@
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+using Harmony;
+using StardewModdingAPI.Framework.Patching;
+using StardewValley;
+
+namespace StardewModdingAPI.Patches
+{
+ /// <summary>A Harmony patch for <see cref="NPC.parseMasterSchedule"/> which intercepts crashes due to invalid schedule data.</summary>
+ /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks>
+ [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")]
+ [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")]
+ internal class ScheduleErrorPatch : IHarmonyPatch
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>Writes messages to the console and log file on behalf of the game.</summary>
+ private static IMonitor MonitorForGame;
+
+ /// <summary>Whether the target is currently being intercepted.</summary>
+ private static bool IsIntercepting;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>A unique name for this patch.</summary>
+ public string Name => nameof(ScheduleErrorPatch);
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="monitorForGame">Writes messages to the console and log file on behalf of the game.</param>
+ public ScheduleErrorPatch(IMonitor monitorForGame)
+ {
+ ScheduleErrorPatch.MonitorForGame = monitorForGame;
+ }
+
+ /// <summary>Apply the Harmony patch.</summary>
+ /// <param name="harmony">The Harmony instance.</param>
+ public void Apply(HarmonyInstance harmony)
+ {
+ harmony.Patch(
+ original: AccessTools.Method(typeof(NPC), "parseMasterSchedule"),
+ prefix: new HarmonyMethod(this.GetType(), nameof(ScheduleErrorPatch.Before_NPC_parseMasterSchedule))
+ );
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>The method to call instead of <see cref="NPC.parseMasterSchedule"/>.</summary>
+ /// <param name="rawData">The raw schedule data to parse.</param>
+ /// <param name="__instance">The instance being patched.</param>
+ /// <param name="__result">The patched method's return value.</param>
+ /// <param name="__originalMethod">The method being wrapped.</param>
+ /// <returns>Returns whether to execute the original method.</returns>
+ private static bool Before_NPC_parseMasterSchedule(string rawData, NPC __instance, ref Dictionary<int, SchedulePathDescription> __result, MethodInfo __originalMethod)
+ {
+ if (ScheduleErrorPatch.IsIntercepting)
+ return true;
+
+ try
+ {
+ ScheduleErrorPatch.IsIntercepting = true;
+ __result = (Dictionary<int, SchedulePathDescription>)__originalMethod.Invoke(__instance, new object[] { rawData });
+ return false;
+ }
+ catch (TargetInvocationException ex)
+ {
+ ScheduleErrorPatch.MonitorForGame.Log($"Failed parsing schedule for NPC {__instance.Name}:\n{rawData}\n{ex.InnerException ?? ex}", LogLevel.Error);
+ __result = new Dictionary<int, SchedulePathDescription>();
+ return false;
+ }
+ finally
+ {
+ ScheduleErrorPatch.IsIntercepting = false;
+ }
+ }
+ }
+}
diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs
index 6bacf564..c26ae29a 100644
--- a/src/SMAPI/Program.cs
+++ b/src/SMAPI/Program.cs
@@ -11,6 +11,7 @@ using StardewModdingAPI.Framework;
using StardewModdingAPI.Toolkit.Utilities;
[assembly: InternalsVisibleTo("SMAPI.Tests")]
+[assembly: InternalsVisibleTo("ConsoleCommands")] // for performance monitoring commands
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // Moq for unit testing
namespace StardewModdingAPI
{
diff --git a/src/SMAPI/SMAPI.config.json b/src/SMAPI/SMAPI.config.json
index 824bb783..57b4f885 100644
--- a/src/SMAPI/SMAPI.config.json
+++ b/src/SMAPI/SMAPI.config.json
@@ -6,6 +6,12 @@ This file contains advanced configuration for SMAPI. You generally shouldn't cha
The default values are mirrored in StardewModdingAPI.Framework.Models.SConfig to log custom changes.
+This file is overwritten each time you update or reinstall SMAPI. To avoid losing custom settings,
+create a 'config.user.json' file in the same folder with *only* the settings you want to change.
+That file won't be overwritten, and any settings in it will override the default options. Don't
+copy all the settings, or you may cause bugs due to overridden changes in future SMAPI versions.
+
+
*/
{
diff --git a/src/SMAPI/SMAPI.csproj b/src/SMAPI/SMAPI.csproj
index 936c420d..c5d0f247 100644
--- a/src/SMAPI/SMAPI.csproj
+++ b/src/SMAPI/SMAPI.csproj
@@ -8,8 +8,7 @@
<LangVersion>latest</LangVersion>
<PlatformTarget>x86</PlatformTarget>
<OutputType>Exe</OutputType>
- <OutputPath>$(SolutionDir)\..\bin\$(Configuration)\SMAPI</OutputPath>
- <DocumentationFile>$(SolutionDir)\..\bin\$(Configuration)\SMAPI\StardewModdingAPI.xml</DocumentationFile>
+ <DocumentationFile>bin\$(Configuration)\StardewModdingAPI.xml</DocumentationFile>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<LargeAddressAware Condition="'$(OS)' == 'Windows_NT'">true</LargeAddressAware>
<ApplicationIcon>icon.ico</ApplicationIcon>
@@ -20,6 +19,7 @@
<PackageReference Include="Lib.Harmony" Version="1.2.0.1" />
<PackageReference Include="Mono.Cecil" Version="0.11.1" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
+ <PackageReference Include="Platonymous.TMXTile" Version="1.0.2" />
</ItemGroup>
<ItemGroup>
@@ -99,28 +99,7 @@
<Link>SMAPI.metadata.json</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
- <None Update="i18n\de.json">
- <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
- </None>
- <None Update="i18n\es.json">
- <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
- </None>
- <None Update="i18n\ja.json">
- <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
- </None>
- <None Update="i18n\default.json">
- <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
- </None>
- <None Update="i18n\pt.json">
- <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
- </None>
- <None Update="i18n\ru.json">
- <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
- </None>
- <None Update="i18n\tr.json">
- <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
- </None>
- <None Update="i18n\zh.json">
+ <None Update="i18n\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="steam_appid.txt">
diff --git a/src/SMAPI/SemanticVersion.cs b/src/SMAPI/SemanticVersion.cs
index 2a33ecef..4a175efe 100644
--- a/src/SMAPI/SemanticVersion.cs
+++ b/src/SMAPI/SemanticVersion.cs
@@ -39,18 +39,36 @@ namespace StardewModdingAPI
/// <param name="majorVersion">The major version incremented for major API changes.</param>
/// <param name="minorVersion">The minor version incremented for backwards-compatible changes.</param>
/// <param name="patchVersion">The patch version for backwards-compatible bug fixes.</param>
- /// <param name="prerelease">An optional prerelease tag.</param>
- /// <param name="build">Optional build metadata. This is ignored when determining version precedence.</param>
+ /// <param name="prereleaseTag">An optional prerelease tag.</param>
+ /// <param name="buildMetadata">Optional build metadata. This is ignored when determining version precedence.</param>
+ public SemanticVersion(int majorVersion, int minorVersion, int patchVersion, string prereleaseTag = null, string buildMetadata = null)
+ : this(majorVersion, minorVersion, patchVersion, 0, prereleaseTag, buildMetadata) { }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="majorVersion">The major version incremented for major API changes.</param>
+ /// <param name="minorVersion">The minor version incremented for backwards-compatible changes.</param>
+ /// <param name="patchVersion">The patch version for backwards-compatible bug fixes.</param>
+ /// <param name="prereleaseTag">An optional prerelease tag.</param>
+ /// <param name="platformRelease">The platform-specific version (if applicable).</param>
+ /// <param name="buildMetadata">Optional build metadata. This is ignored when determining version precedence.</param>
[JsonConstructor]
- public SemanticVersion(int majorVersion, int minorVersion, int patchVersion, string prerelease = null, string build = null)
- : this(new Toolkit.SemanticVersion(majorVersion, minorVersion, patchVersion, prerelease, build)) { }
+ internal SemanticVersion(int majorVersion, int minorVersion, int patchVersion, int platformRelease, string prereleaseTag = null, string buildMetadata = null)
+ : this(new Toolkit.SemanticVersion(majorVersion, minorVersion, patchVersion, platformRelease, prereleaseTag, buildMetadata)) { }
/// <summary>Construct an instance.</summary>
/// <param name="version">The semantic version string.</param>
/// <exception cref="ArgumentNullException">The <paramref name="version"/> is null.</exception>
/// <exception cref="FormatException">The <paramref name="version"/> is not a valid semantic version.</exception>
public SemanticVersion(string version)
- : this(new Toolkit.SemanticVersion(version)) { }
+ : this(version, allowNonStandard: false) { }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="version">The semantic version string.</param>
+ /// <param name="allowNonStandard">Whether to recognize non-standard semver extensions.</param>
+ /// <exception cref="ArgumentNullException">The <paramref name="version"/> is null.</exception>
+ /// <exception cref="FormatException">The <paramref name="version"/> is not a valid semantic version.</exception>
+ internal SemanticVersion(string version, bool allowNonStandard)
+ : this(new Toolkit.SemanticVersion(version, allowNonStandard)) { }
/// <summary>Construct an instance.</summary>
/// <param name="version">The assembly version.</param>
@@ -141,6 +159,12 @@ namespace StardewModdingAPI
return this.Version.ToString();
}
+ /// <summary>Whether the version uses non-standard extensions, like four-part game versions on some platforms.</summary>
+ public bool IsNonStandard()
+ {
+ return this.Version.IsNonStandard();
+ }
+
/// <summary>Parse a version string without throwing an exception if it fails.</summary>
/// <param name="version">The version string.</param>
/// <param name="parsed">The parsed representation.</param>
diff --git a/src/SMAPI/i18n/fr.json b/src/SMAPI/i18n/fr.json
new file mode 100644
index 00000000..6d051025
--- /dev/null
+++ b/src/SMAPI/i18n/fr.json
@@ -0,0 +1,3 @@
+{
+ "warn.invalid-content-removed": "Le contenu non valide a été supprimé afin d'éviter un plantage (voir la console de SMAPI pour plus d'informations)."
+}