Merge branch 'develop' into stable

This commit is contained in:
Jesse Plamondon-Willard 2021-01-08 21:18:15 -05:00
commit bdb7b04b3e
No known key found for this signature in database
GPG Key ID: CF8B1456B3E29F49
27 changed files with 268 additions and 1484 deletions

View File

@ -4,7 +4,7 @@
<!--set properties --> <!--set properties -->
<PropertyGroup> <PropertyGroup>
<Version>3.8.2</Version> <Version>3.8.3</Version>
<Product>SMAPI</Product> <Product>SMAPI</Product>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>

View File

@ -7,6 +7,21 @@
* Migrated to Harmony 2.0 (see [_migrate to Harmony 2.0_](https://stardewvalleywiki.com/Modding:Migrate_to_Harmony_2.0) for more info). * Migrated to Harmony 2.0 (see [_migrate to Harmony 2.0_](https://stardewvalleywiki.com/Modding:Migrate_to_Harmony_2.0) for more info).
--> -->
## 3.8.3
Released 08 January 2021 for Stardew Valley 1.5.2 or later.
* For players:
* Updated for Stardew Valley 1.5.2.
* Reduced memory usage.
* You can now enter console commands for a specific screen in split-screen mode by adding `screen=ID` to the command.
* Typing `help` in the SMAPI console is now more helpful.
* For modders:
* Simplified tilesheet order warning added in SMAPI 3.8.2.
* For the Console Commands mod:
* Removed experimental `performance` command. Unfortunately this impacted SMAPI's memory usage and performance, and the data was often misinterpreted. This may be replaced with more automatic performance alerts in a future version.
## 3.8.2 ## 3.8.2
Released 03 January 2021 for Stardew Valley 1.5.1 or later. Released 03 January 2021 for Stardew Valley 1.5.1 or later.

View File

@ -1,7 +1,6 @@
using System; using System;
using System.Collections; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq; using System.Linq;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
@ -107,38 +106,6 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
return true; return true;
} }
/// <summary>Try to read a decimal argument.</summary>
/// <param name="index">The argument index.</param>
/// <param name="name">The argument name for error messages.</param>
/// <param name="value">The parsed value.</param>
/// <param name="required">Whether to show an error if the argument is missing.</param>
/// <param name="min">The minimum value allowed.</param>
/// <param name="max">The maximum value allowed.</param>
public bool TryGetDecimal(int index, string name, out decimal value, bool required = true, decimal? min = null, decimal? max = null)
{
value = 0;
// get argument
if (!this.TryGet(index, name, out string raw, required))
return false;
// parse
if (!decimal.TryParse(raw, NumberStyles.Number, CultureInfo.InvariantCulture, out value))
{
this.LogDecimalFormatError(index, name, min, max);
return false;
}
// validate
if ((min.HasValue && value < min) || (max.HasValue && value > max))
{
this.LogDecimalFormatError(index, name, min, max);
return false;
}
return true;
}
/// <summary>Returns an enumerator that iterates through the collection.</summary> /// <summary>Returns an enumerator that iterates through the collection.</summary>
/// <returns>An enumerator that can be used to iterate through the collection.</returns> /// <returns>An enumerator that can be used to iterate through the collection.</returns>
public IEnumerator<string> GetEnumerator() public IEnumerator<string> GetEnumerator()
@ -180,22 +147,5 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
else else
this.LogError($"Argument {index} ({name}) must be an integer."); this.LogError($"Argument {index} ({name}) must be an integer.");
} }
/// <summary>Print an error for an invalid decimal argument.</summary>
/// <param name="index">The argument index.</param>
/// <param name="name">The argument name for error messages.</param>
/// <param name="min">The minimum value allowed.</param>
/// <param name="max">The maximum value allowed.</param>
private void LogDecimalFormatError(int index, string name, decimal? min, decimal? max)
{
if (min.HasValue && max.HasValue)
this.LogError($"Argument {index} ({name}) must be a decimal between {min} and {max}.");
else if (min.HasValue)
this.LogError($"Argument {index} ({name}) must be a decimal and at least {min}.");
else if (max.HasValue)
this.LogError($"Argument {index} ({name}) must be a decimal and at most {max}.");
else
this.LogError($"Argument {index} ({name}) must be a decimal.");
}
} }
} }

View File

@ -1,647 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using StardewModdingAPI.Framework;
using StardewModdingAPI.Framework.PerformanceMonitoring;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other
{
/// <summary>A set of commands which displays or configures performance monitoring.</summary>
internal class PerformanceCounterCommand : TrainerCommand
{
/*********
** Fields
*********/
/// <summary>The name of the command.</summary>
private const string CommandName = "performance";
/// <summary>The available commands.</summary>
private enum SubCommand
{
Summary,
Detail,
Reset,
Trigger,
Enable,
Disable,
Help
}
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
public PerformanceCounterCommand()
: base(CommandName, PerformanceCounterCommand.GetDescription()) { }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
/// <param name="command">The command name.</param>
/// <param name="args">The command arguments.</param>
public override void Handle(IMonitor monitor, string command, ArgumentParser args)
{
// parse args
SubCommand subcommand = SubCommand.Summary;
{
if (args.TryGet(0, "command", out string subcommandStr, false) && !Enum.TryParse(subcommandStr, ignoreCase: true, out subcommand))
{
this.LogUsageError(monitor, $"Unknown command {subcommandStr}");
return;
}
}
// handle
switch (subcommand)
{
case SubCommand.Summary:
this.HandleSummarySubCommand(monitor, args);
break;
case SubCommand.Detail:
this.HandleDetailSubCommand(monitor, args);
break;
case SubCommand.Reset:
this.HandleResetSubCommand(monitor, args);
break;
case SubCommand.Trigger:
this.HandleTriggerSubCommand(monitor, args);
break;
case SubCommand.Enable:
SCore.PerformanceMonitor.EnableTracking = true;
monitor.Log("Performance counter tracking is now enabled", LogLevel.Info);
break;
case SubCommand.Disable:
SCore.PerformanceMonitor.EnableTracking = false;
monitor.Log("Performance counter tracking is now disabled", LogLevel.Info);
break;
case SubCommand.Help:
this.OutputHelp(monitor, args.TryGet(1, "command", out _) ? subcommand : null as SubCommand?);
break;
default:
this.LogUsageError(monitor, $"Unknown command {subcommand}");
break;
}
}
/*********
** Private methods
*********/
/// <summary>Handles the summary sub command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
/// <param name="args">The command arguments.</param>
private void HandleSummarySubCommand(IMonitor monitor, ArgumentParser args)
{
if (!this.AssertEnabled(monitor))
return;
IEnumerable<PerformanceCounterCollection> data = SCore.PerformanceMonitor.GetCollections();
double? threshold = null;
if (args.TryGetDecimal(1, "threshold", out decimal t, required: false))
threshold = (double?)t;
TimeSpan interval = TimeSpan.FromSeconds(60);
StringBuilder report = new StringBuilder();
report.AppendLine($"Summary over the last {interval.TotalSeconds} seconds:");
report.AppendLine(this.GetTableString(
data: data,
header: new[] { "Collection", "Avg Calls/s", "Avg Exec Time (Game)", "Avg Exec Time (Mods)", "Avg Exec Time (Game+Mods)", "Peak Exec Time" },
getRow: item => new[]
{
item.Name,
item.GetAverageCallsPerSecond().ToString(),
this.FormatMilliseconds(item.GetGameAverageExecutionTime(interval), threshold),
this.FormatMilliseconds(item.GetModsAverageExecutionTime(interval), threshold),
this.FormatMilliseconds(item.GetAverageExecutionTime(interval), threshold),
this.FormatMilliseconds(item.GetPeakExecutionTime(interval), threshold)
},
true
));
monitor.Log(report.ToString(), LogLevel.Info);
}
/// <summary>Handles the detail sub command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
/// <param name="args">The command arguments.</param>
private void HandleDetailSubCommand(IMonitor monitor, ArgumentParser args)
{
if (!this.AssertEnabled(monitor))
return;
// parse args
double thresholdMilliseconds = 0;
if (args.TryGetDecimal(1, "threshold", out decimal t, required: false))
thresholdMilliseconds = (double)t;
// get collections
var collections = SCore.PerformanceMonitor.GetCollections();
// render
TimeSpan averageInterval = TimeSpan.FromSeconds(60);
StringBuilder report = new StringBuilder($"Showing details for performance counters of {thresholdMilliseconds}+ milliseconds:\n\n");
bool anyShown = false;
foreach (PerformanceCounterCollection collection in collections)
{
KeyValuePair<string, PerformanceCounter>[] data = collection.PerformanceCounters
.Where(p => p.Value.GetAverage(averageInterval) >= thresholdMilliseconds)
.ToArray();
if (data.Any())
{
anyShown = true;
report.AppendLine($"{collection.Name}:");
report.AppendLine(this.GetTableString(
data: data,
header: new[] { "Mod", $"Avg Exec Time (last {(int)averageInterval.TotalSeconds}s)", "Last Exec Time", "Peak Exec Time", $"Peak Exec Time (last {(int)averageInterval.TotalSeconds}s)" },
getRow: item => new[]
{
item.Key,
this.FormatMilliseconds(item.Value.GetAverage(averageInterval), thresholdMilliseconds),
this.FormatMilliseconds(item.Value.GetLastEntry()?.ElapsedMilliseconds),
this.FormatMilliseconds(item.Value.GetPeak()?.ElapsedMilliseconds),
this.FormatMilliseconds(item.Value.GetPeak(averageInterval)?.ElapsedMilliseconds)
},
true
));
}
}
if (!anyShown)
report.AppendLine("No performance counters found.");
monitor.Log(report.ToString(), LogLevel.Info);
}
/// <summary>Handles the trigger sub command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
/// <param name="args">The command arguments.</param>
private void HandleTriggerSubCommand(IMonitor monitor, ArgumentParser args)
{
if (!this.AssertEnabled(monitor))
return;
if (args.TryGet(1, "mode", out string mode, false))
{
switch (mode)
{
case "list":
this.OutputAlertTriggers(monitor);
break;
case "collection":
if (args.TryGet(2, "name", out string collectionName))
{
if (args.TryGetDecimal(3, "threshold", out decimal threshold))
{
if (!args.TryGet(4, "source", out string source, required: false))
source = null;
this.ConfigureAlertTrigger(monitor, collectionName, source, threshold);
}
}
break;
case "pause":
SCore.PerformanceMonitor.PauseAlerts = true;
monitor.Log("Alerts are now paused.", LogLevel.Info);
break;
case "resume":
SCore.PerformanceMonitor.PauseAlerts = false;
monitor.Log("Alerts are now resumed.", LogLevel.Info);
break;
case "dump":
this.OutputAlertTriggers(monitor, true);
break;
case "clear":
this.ClearAlertTriggers(monitor);
break;
default:
this.LogUsageError(monitor, $"Unknown mode {mode}. See '{CommandName} help trigger' for usage.");
break;
}
}
else
this.OutputAlertTriggers(monitor);
}
/// <summary>Sets up an an alert trigger.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
/// <param name="collectionName">The name of the collection.</param>
/// <param name="sourceName">The name of the source, or null for all sources.</param>
/// <param name="threshold">The trigger threshold, or 0 to remove.</param>
private void ConfigureAlertTrigger(IMonitor monitor, string collectionName, string sourceName, decimal threshold)
{
foreach (PerformanceCounterCollection collection in SCore.PerformanceMonitor.GetCollections())
{
if (collection.Name.ToLowerInvariant().Equals(collectionName.ToLowerInvariant()))
{
if (sourceName == null)
{
if (threshold != 0)
{
collection.EnableAlerts = true;
collection.AlertThresholdMilliseconds = (double)threshold;
monitor.Log($"Set up alert triggering for '{collectionName}' with '{this.FormatMilliseconds((double?)threshold)}'", LogLevel.Info);
}
else
{
collection.EnableAlerts = false;
monitor.Log($"Cleared alert triggering for '{collection}'.");
}
return;
}
else
{
foreach (var performanceCounter in collection.PerformanceCounters)
{
if (performanceCounter.Value.Source.ToLowerInvariant().Equals(sourceName.ToLowerInvariant()))
{
if (threshold != 0)
{
performanceCounter.Value.EnableAlerts = true;
performanceCounter.Value.AlertThresholdMilliseconds = (double)threshold;
monitor.Log($"Set up alert triggering for '{sourceName}' in collection '{collectionName}' with '{this.FormatMilliseconds((double?)threshold)}", LogLevel.Info);
}
else
performanceCounter.Value.EnableAlerts = false;
return;
}
}
monitor.Log($"Could not find the source '{sourceName}' in collection '{collectionName}'", LogLevel.Warn);
return;
}
}
}
monitor.Log($"Could not find the collection '{collectionName}'", LogLevel.Warn);
}
/// <summary>Clears alert triggering for all collections.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
private void ClearAlertTriggers(IMonitor monitor)
{
int clearedTriggers = 0;
foreach (PerformanceCounterCollection collection in SCore.PerformanceMonitor.GetCollections())
{
if (collection.EnableAlerts)
{
collection.EnableAlerts = false;
clearedTriggers++;
}
foreach (var performanceCounter in collection.PerformanceCounters)
{
if (performanceCounter.Value.EnableAlerts)
{
performanceCounter.Value.EnableAlerts = false;
clearedTriggers++;
}
}
}
monitor.Log($"Cleared {clearedTriggers} alert triggers.", LogLevel.Info);
}
/// <summary>Lists all configured alert triggers.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
/// <param name="asDump">True to dump the triggers as commands.</param>
private void OutputAlertTriggers(IMonitor monitor, bool asDump = false)
{
StringBuilder report = new StringBuilder();
report.AppendLine("Configured triggers:");
report.AppendLine();
var collectionTriggers = new List<CollectionTrigger>();
var sourceTriggers = new List<SourceTrigger>();
foreach (PerformanceCounterCollection collection in SCore.PerformanceMonitor.GetCollections())
{
if (collection.EnableAlerts)
collectionTriggers.Add(new CollectionTrigger(collection.Name, collection.AlertThresholdMilliseconds));
sourceTriggers.AddRange(
from counter in collection.PerformanceCounters
where counter.Value.EnableAlerts
select new SourceTrigger(collection.Name, counter.Value.Source, counter.Value.AlertThresholdMilliseconds)
);
}
if (collectionTriggers.Count > 0)
{
report.AppendLine("Collection Triggers:");
report.AppendLine();
if (asDump)
{
foreach (var item in collectionTriggers)
report.AppendLine($"{CommandName} trigger {item.CollectionName} {item.Threshold}");
}
else
{
report.AppendLine(this.GetTableString(
data: collectionTriggers,
header: new[] { "Collection", "Threshold" },
getRow: item => new[] { item.CollectionName, this.FormatMilliseconds(item.Threshold) },
true
));
}
report.AppendLine();
}
else
report.AppendLine("No collection triggers.");
if (sourceTriggers.Count > 0)
{
report.AppendLine("Source Triggers:");
report.AppendLine();
if (asDump)
{
foreach (SourceTrigger item in sourceTriggers)
report.AppendLine($"{CommandName} trigger {item.CollectionName} {item.Threshold} {item.SourceName}");
}
else
{
report.AppendLine(this.GetTableString(
data: sourceTriggers,
header: new[] { "Collection", "Source", "Threshold" },
getRow: item => new[] { item.CollectionName, item.SourceName, this.FormatMilliseconds(item.Threshold) },
true
));
}
report.AppendLine();
}
else
report.AppendLine("No source triggers.");
monitor.Log(report.ToString(), LogLevel.Info);
}
/// <summary>Handles the reset sub command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
/// <param name="args">The command arguments.</param>
private void HandleResetSubCommand(IMonitor monitor, ArgumentParser args)
{
if (!this.AssertEnabled(monitor))
return;
if (args.TryGet(1, "type", out string type, false, new[] { "category", "source" }))
{
args.TryGet(2, "name", out string name);
switch (type)
{
case "category":
SCore.PerformanceMonitor.ResetCollection(name);
monitor.Log($"All performance counters for category {name} are now cleared.", LogLevel.Info);
break;
case "source":
SCore.PerformanceMonitor.ResetSource(name);
monitor.Log($"All performance counters for source {name} are now cleared.", LogLevel.Info);
break;
}
}
else
{
SCore.PerformanceMonitor.Reset();
monitor.Log("All performance counters are now cleared.", LogLevel.Info);
}
}
/// <summary>Formats the given milliseconds value into a string format. Optionally
/// allows a threshold to return "-" if the value is less than the threshold.</summary>
/// <param name="milliseconds">The milliseconds to format. Returns "-" if null</param>
/// <param name="thresholdMilliseconds">The threshold. Any value below this is returned as "-".</param>
/// <returns>The formatted milliseconds.</returns>
private string FormatMilliseconds(double? milliseconds, double? thresholdMilliseconds = null)
{
thresholdMilliseconds ??= 1;
return milliseconds != null && milliseconds >= thresholdMilliseconds
? ((double)milliseconds).ToString("F2")
: "-";
}
/// <summary>Shows detailed help for a specific sub command.</summary>
/// <param name="monitor">The output monitor.</param>
/// <param name="subcommand">The subcommand.</param>
private void OutputHelp(IMonitor monitor, SubCommand? subcommand)
{
StringBuilder report = new StringBuilder();
report.AppendLine();
switch (subcommand)
{
case SubCommand.Detail:
report.AppendLine($" {CommandName} detail <threshold>");
report.AppendLine();
report.AppendLine("Displays details for a specific collection.");
report.AppendLine();
report.AppendLine("Arguments:");
report.AppendLine(" <threshold> Optional. The threshold in milliseconds. Any average execution time below that");
report.AppendLine(" threshold is not reported.");
report.AppendLine();
report.AppendLine("Examples:");
report.AppendLine($"{CommandName} detail 5 Show counters exceeding an average of 5ms");
break;
case SubCommand.Summary:
report.AppendLine($"Usage: {CommandName} summary <threshold>");
report.AppendLine();
report.AppendLine("Displays the performance counter summary.");
report.AppendLine();
report.AppendLine("Arguments:");
report.AppendLine(" <threshold> Optional. Hides the actual execution time if it's below this threshold");
report.AppendLine();
report.AppendLine("Examples:");
report.AppendLine($"{CommandName} summary Show all events");
report.AppendLine($"{CommandName} summary 5 Shows events exceeding an average of 5ms");
break;
case SubCommand.Trigger:
report.AppendLine($"Usage: {CommandName} trigger <mode>");
report.AppendLine($"Usage: {CommandName} trigger collection <collectionName> <threshold>");
report.AppendLine($"Usage: {CommandName} trigger collection <collectionName> <threshold> <sourceName>");
report.AppendLine();
report.AppendLine("Manages alert triggers.");
report.AppendLine();
report.AppendLine("Arguments:");
report.AppendLine(" <mode> Optional. Specifies if a specific source or a specific collection should be triggered.");
report.AppendLine(" - list Lists current triggers");
report.AppendLine(" - collection Sets up a trigger for a collection");
report.AppendLine(" - clear Clears all trigger entries");
report.AppendLine(" - pause Pauses triggering of alerts");
report.AppendLine(" - resume Resumes triggering of alerts");
report.AppendLine(" - dump Dumps all triggers as commands for copy and paste");
report.AppendLine(" Defaults to 'list' if not specified.");
report.AppendLine();
report.AppendLine(" <collectionName> Required if the mode 'collection' is specified.");
report.AppendLine(" Specifies the name of the collection to be triggered. Must be an exact match.");
report.AppendLine();
report.AppendLine(" <sourceName> Optional. Specifies the name of a specific source. Must be an exact match.");
report.AppendLine();
report.AppendLine(" <threshold> Required if the mode 'collection' is specified.");
report.AppendLine(" Specifies the threshold in milliseconds (fractions allowed).");
report.AppendLine(" Specify '0' to remove the threshold.");
report.AppendLine();
report.AppendLine("Examples:");
report.AppendLine();
report.AppendLine($"{CommandName} trigger collection Display.Rendering 10");
report.AppendLine(" Sets up an alert trigger which writes on the console if the execution time of all performance counters in");
report.AppendLine(" the 'Display.Rendering' collection exceed 10 milliseconds.");
report.AppendLine();
report.AppendLine($"{CommandName} trigger collection Display.Rendering 5 Pathoschild.ChestsAnywhere");
report.AppendLine(" Sets up an alert trigger to write on the console if the execution time of Pathoschild.ChestsAnywhere in");
report.AppendLine(" the 'Display.Rendering' collection exceed 5 milliseconds.");
report.AppendLine();
report.AppendLine($"{CommandName} trigger collection Display.Rendering 0");
report.AppendLine(" Removes the threshold previously defined from the collection. Note that source-specific thresholds are left intact.");
report.AppendLine();
report.AppendLine($"{CommandName} trigger clear");
report.AppendLine(" Clears all previously setup alert triggers.");
break;
case SubCommand.Reset:
report.AppendLine($"Usage: {CommandName} reset <type> <name>");
report.AppendLine();
report.AppendLine("Resets performance counters.");
report.AppendLine();
report.AppendLine("Arguments:");
report.AppendLine(" <type> Optional. Specifies if a collection or source should be reset.");
report.AppendLine(" If omitted, all performance counters are reset.");
report.AppendLine();
report.AppendLine(" - source Clears performance counters for a specific source");
report.AppendLine(" - collection Clears performance counters for a specific collection");
report.AppendLine();
report.AppendLine(" <name> Required if a <type> is given. Specifies the name of either the collection");
report.AppendLine(" or the source. The name must be an exact match.");
report.AppendLine();
report.AppendLine("Examples:");
report.AppendLine($"{CommandName} reset Resets all performance counters");
report.AppendLine($"{CommandName} reset source Pathoschild.ChestsAnywhere Resets all performance for the source named Pathoschild.ChestsAnywhere");
report.AppendLine($"{CommandName} reset collection Display.Rendering Resets all performance for the collection named Display.Rendering");
break;
}
report.AppendLine();
monitor.Log(report.ToString(), LogLevel.Info);
}
/// <summary>Get the command description.</summary>
private static string GetDescription()
{
StringBuilder report = new StringBuilder();
report.AppendLine("Displays or configures performance monitoring to diagnose issues. Performance monitoring is disabled by default.");
report.AppendLine();
report.AppendLine("For example, the counter collection named 'Display.Rendered' contains one performance");
report.AppendLine("counter when the game executes the 'Display.Rendered' event, and another counter for each mod which handles it.");
report.AppendLine();
report.AppendLine($"Usage: {CommandName} <command> <action>");
report.AppendLine();
report.AppendLine("Commands:");
report.AppendLine();
report.AppendLine(" summary Show a summary of collections.");
report.AppendLine(" detail Show a summary for a given collection.");
report.AppendLine(" reset Reset all performance counters.");
report.AppendLine(" trigger Configure alert triggers.");
report.AppendLine(" enable Enable performance counter recording.");
report.AppendLine(" disable Disable performance counter recording.");
report.AppendLine(" help Show verbose help for the available commands.");
report.AppendLine();
report.AppendLine($"To get help for a specific command, use '{CommandName} help <command>', for example:");
report.AppendLine($"{CommandName} help summary");
report.AppendLine();
report.AppendLine("Defaults to summary if no command is given.");
report.AppendLine();
return report.ToString();
}
/// <summary>Log a warning if performance monitoring isn't enabled.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
/// <returns>Returns whether performance monitoring is enabled.</returns>
private bool AssertEnabled(IMonitor monitor)
{
if (!SCore.PerformanceMonitor.EnableTracking)
{
monitor.Log($"Performance monitoring is currently disabled; enter '{CommandName} enable' to enable it.", LogLevel.Warn);
return false;
}
return true;
}
/*********
** Private models
*********/
/// <summary>An alert trigger for a collection.</summary>
private class CollectionTrigger
{
/*********
** Accessors
*********/
/// <summary>The collection name.</summary>
public string CollectionName { get; }
/// <summary>The trigger threshold.</summary>
public double Threshold { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="collectionName">The collection name.</param>
/// <param name="threshold">The trigger threshold.</param>
public CollectionTrigger(string collectionName, double threshold)
{
this.CollectionName = collectionName;
this.Threshold = threshold;
}
}
/// <summary>An alert triggered for a source.</summary>
private class SourceTrigger : CollectionTrigger
{
/*********
** Accessors
*********/
/// <summary>The source name.</summary>
public string SourceName { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="collectionName">The collection name.</param>
/// <param name="sourceName">The source name.</param>
/// <param name="threshold">The trigger threshold.</param>
public SourceTrigger(string collectionName, string sourceName, double threshold)
: base(collectionName, threshold)
{
this.SourceName = sourceName;
}
}
}
}

View File

@ -78,8 +78,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
/// <param name="data">The data to display.</param> /// <param name="data">The data to display.</param>
/// <param name="header">The table header.</param> /// <param name="header">The table header.</param>
/// <param name="getRow">Returns a set of fields for a data value.</param> /// <param name="getRow">Returns a set of fields for a data value.</param>
/// <param name="rightAlign">Whether to right-align the data.</param> protected string GetTableString<T>(IEnumerable<T> data, string[] header, Func<T, string[]> getRow)
protected string GetTableString<T>(IEnumerable<T> data, string[] header, Func<T, string[]> getRow, bool rightAlign = false)
{ {
// get table data // get table data
int[] widths = header.Select(p => p.Length).ToArray(); int[] widths = header.Select(p => p.Length).ToArray();
@ -108,7 +107,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
return string.Join( return string.Join(
Environment.NewLine, Environment.NewLine,
lines.Select(line => string.Join(" | ", lines.Select(line => string.Join(" | ",
line.Select((field, i) => rightAlign ? field.PadRight(widths[i], ' ') : field.PadLeft(widths[i], ' ')) line.Select((field, i) => field.PadLeft(widths[i], ' '))
)) ))
); );
} }

View File

@ -1,9 +1,9 @@
{ {
"Name": "Console Commands", "Name": "Console Commands",
"Author": "SMAPI", "Author": "SMAPI",
"Version": "3.8.2", "Version": "3.8.3",
"Description": "Adds SMAPI console commands that let you manipulate the game.", "Description": "Adds SMAPI console commands that let you manipulate the game.",
"UniqueID": "SMAPI.ConsoleCommands", "UniqueID": "SMAPI.ConsoleCommands",
"EntryDll": "ConsoleCommands.dll", "EntryDll": "ConsoleCommands.dll",
"MinimumApiVersion": "3.8.2" "MinimumApiVersion": "3.8.3"
} }

View File

@ -1,9 +1,9 @@
{ {
"Name": "Save Backup", "Name": "Save Backup",
"Author": "SMAPI", "Author": "SMAPI",
"Version": "3.8.2", "Version": "3.8.3",
"Description": "Automatically backs up all your saves once per day into its folder.", "Description": "Automatically backs up all your saves once per day into its folder.",
"UniqueID": "SMAPI.SaveBackup", "UniqueID": "SMAPI.SaveBackup",
"EntryDll": "SaveBackup.dll", "EntryDll": "SaveBackup.dll",
"MinimumApiVersion": "3.8.2" "MinimumApiVersion": "3.8.3"
} }

View File

@ -210,6 +210,7 @@ namespace StardewModdingAPI.Web
[@"^/community\.?$"] = "https://stardewvalleywiki.com/Modding:Community", [@"^/community\.?$"] = "https://stardewvalleywiki.com/Modding:Community",
[@"^/compat\.?$"] = "https://smapi.io/mods", [@"^/compat\.?$"] = "https://smapi.io/mods",
[@"^/docs\.?$"] = "https://stardewvalleywiki.com/Modding:Index", [@"^/docs\.?$"] = "https://stardewvalleywiki.com/Modding:Index",
[@"^/help\.?$"] = "https://stardewvalleywiki.com/Modding:Help",
[@"^/install\.?$"] = "https://stardewvalleywiki.com/Modding:Player_Guide/Getting_Started#Install_SMAPI", [@"^/install\.?$"] = "https://stardewvalleywiki.com/Modding:Player_Guide/Getting_Started#Install_SMAPI",
[@"^/troubleshoot(.*)$"] = "https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting$1", [@"^/troubleshoot(.*)$"] = "https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting$1",
[@"^/xnb\.?$"] = "https://stardewvalleywiki.com/Modding:Using_XNB_mods" [@"^/xnb\.?$"] = "https://stardewvalleywiki.com/Modding:Using_XNB_mods"

View File

@ -54,10 +54,10 @@ namespace StardewModdingAPI
** Public ** Public
****/ ****/
/// <summary>SMAPI's current semantic version.</summary> /// <summary>SMAPI's current semantic version.</summary>
public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.8.2"); public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.8.3");
/// <summary>The minimum supported version of Stardew Valley.</summary> /// <summary>The minimum supported version of Stardew Valley.</summary>
public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.5.1"); public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.5.2");
/// <summary>The maximum supported version of Stardew Valley.</summary> /// <summary>The maximum supported version of Stardew Valley.</summary>
public static ISemanticVersion MaximumGameVersion { get; } = null; public static ISemanticVersion MaximumGameVersion { get; } = null;
@ -97,9 +97,6 @@ namespace StardewModdingAPI
/// <summary>The URL of the SMAPI home page.</summary> /// <summary>The URL of the SMAPI home page.</summary>
internal const string HomePageUrl = "https://smapi.io"; 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> /// <summary>The absolute path to the folder containing SMAPI's internal files.</summary>
internal static readonly string InternalFilesPath = EarlyConstants.InternalFilesPath; internal static readonly string InternalFilesPath = EarlyConstants.InternalFilesPath;

View File

@ -15,10 +15,20 @@ namespace StardewModdingAPI.Framework
/// <summary>The commands registered with SMAPI.</summary> /// <summary>The commands registered with SMAPI.</summary>
private readonly IDictionary<string, Command> Commands = new Dictionary<string, Command>(StringComparer.OrdinalIgnoreCase); private readonly IDictionary<string, Command> Commands = new Dictionary<string, Command>(StringComparer.OrdinalIgnoreCase);
/// <summary>Writes messages to the console.</summary>
private readonly IMonitor Monitor;
/********* /*********
** Public methods ** Public methods
*********/ *********/
/// <summary>Construct an instance.</summary>
/// <param name="monitor">Writes messages to the console.</param>
public CommandManager(IMonitor monitor)
{
this.Monitor = monitor;
}
/// <summary>Add a console command.</summary> /// <summary>Add a console command.</summary>
/// <param name="mod">The mod adding the command (or <c>null</c> for a SMAPI command).</param> /// <param name="mod">The mod adding the command (or <c>null</c> for a SMAPI command).</param>
/// <param name="name">The command name, which the user must type to trigger it.</param> /// <param name="name">The command name, which the user must type to trigger it.</param>
@ -81,8 +91,9 @@ namespace StardewModdingAPI.Framework
/// <param name="name">The parsed command name.</param> /// <param name="name">The parsed command name.</param>
/// <param name="args">The parsed command arguments.</param> /// <param name="args">The parsed command arguments.</param>
/// <param name="command">The command which can handle the input.</param> /// <param name="command">The command which can handle the input.</param>
/// <param name="screenId">The screen ID on which to run the command.</param>
/// <returns>Returns true if the input was successfully parsed and matched to a command; else false.</returns> /// <returns>Returns true if the input was successfully parsed and matched to a command; else false.</returns>
public bool TryParse(string input, out string name, out string[] args, out Command command) public bool TryParse(string input, out string name, out string[] args, out Command command, out int screenId)
{ {
// ignore if blank // ignore if blank
if (string.IsNullOrWhiteSpace(input)) if (string.IsNullOrWhiteSpace(input))
@ -90,6 +101,7 @@ namespace StardewModdingAPI.Framework
name = null; name = null;
args = null; args = null;
command = null; command = null;
screenId = 0;
return false; return false;
} }
@ -98,6 +110,27 @@ namespace StardewModdingAPI.Framework
name = this.GetNormalizedName(args[0]); name = this.GetNormalizedName(args[0]);
args = args.Skip(1).ToArray(); args = args.Skip(1).ToArray();
// get screen ID argument
screenId = 0;
for (int i = 0; i < args.Length; i++)
{
// consume arg & set screen ID
if (this.TryParseScreenId(args[i], out int rawScreenId, out string error))
{
args = args.Take(i).Concat(args.Skip(i + 1)).ToArray();
screenId = rawScreenId;
continue;
}
// invalid screen arg
if (error != null)
{
this.Monitor.Log(error, LogLevel.Error);
command = null;
return false;
}
}
// get command // get command
return this.Commands.TryGetValue(name, out command); return this.Commands.TryGetValue(name, out command);
} }
@ -152,6 +185,38 @@ namespace StardewModdingAPI.Framework
return args.Where(item => !string.IsNullOrWhiteSpace(item)).ToArray(); return args.Where(item => !string.IsNullOrWhiteSpace(item)).ToArray();
} }
/// <summary>Try to parse a 'screen=X' command argument, which specifies the screen that should receive the command.</summary>
/// <param name="arg">The raw argument to parse.</param>
/// <param name="screen">The parsed screen ID, if any.</param>
/// <param name="error">The error which indicates an invalid screen ID, if applicable.</param>
/// <returns>Returns whether the screen ID was parsed successfully.</returns>
private bool TryParseScreenId(string arg, out int screen, out string error)
{
screen = -1;
error = null;
// skip non-screen arg
if (!arg.StartsWith("screen="))
return false;
// get screen ID
string rawScreen = arg.Substring("screen=".Length);
if (!int.TryParse(rawScreen, out screen))
{
error = $"invalid screen ID format: {rawScreen}";
return false;
}
// validate ID
if (!Context.HasScreenId(screen))
{
error = $"there's no active screen with ID {screen}. Active screen IDs: {string.Join(", ", Context.ActiveScreenIds)}.";
return false;
}
return true;
}
/// <summary>Get a normalized command name.</summary> /// <summary>Get a normalized command name.</summary>
/// <param name="name">The command name.</param> /// <param name="name">The command name.</param>
private string GetNormalizedName(string name) private string GetNormalizedName(string name)

View File

@ -41,13 +41,26 @@ namespace StardewModdingAPI.Framework.Commands
{ {
Command result = this.CommandManager.Get(args[0]); Command result = this.CommandManager.Get(args[0]);
if (result == null) if (result == null)
monitor.Log("There's no command with that name.", LogLevel.Error); monitor.Log("There's no command with that name. Type 'help' by itself for more info.", LogLevel.Error);
else else
monitor.Log($"{result.Name}: {result.Documentation}{(result.Mod != null ? $"\n(Added by {result.Mod.DisplayName}.)" : "")}", LogLevel.Info); monitor.Log($"{result.Name}: {result.Documentation}{(result.Mod != null ? $"\n(Added by {result.Mod.DisplayName}.)" : "")}", LogLevel.Info);
} }
else else
{ {
string message = "The following commands are registered:\n"; string message =
"\n\n"
+ "Need help with a SMAPI or mod issue?\n"
+ "------------------------------------\n"
+ "See https://smapi.io/help for the best places to ask.\n\n\n"
+ "How commands work\n"
+ "-----------------\n"
+ "Just enter a command directly to run it, just like you did for this help command. Commands may take optional arguments\n"
+ "which change what they do; for example, type 'help help' to see help about the help command. When playing in split-screen\n"
+ "mode, you can add screen=X to send the command to a specific screen instance.\n\n\n"
+ "Valid commands\n"
+ "--------------\n"
+ "The following commands are registered. For more info about a command, type 'help command_name'.\n\n";
IGrouping<string, string>[] groups = (from command in this.CommandManager.GetAll() orderby command.Mod?.DisplayName, command.Name group command.Name by command.Mod?.DisplayName).ToArray(); IGrouping<string, string>[] groups = (from command in this.CommandManager.GetAll() orderby command.Mod?.DisplayName, command.Name group command.Name by command.Mod?.DisplayName).ToArray();
foreach (var group in groups) foreach (var group in groups)
{ {
@ -55,7 +68,6 @@ namespace StardewModdingAPI.Framework.Commands
string[] commandNames = group.ToArray(); string[] commandNames = group.ToArray();
message += $"{modName}:\n {string.Join("\n ", commandNames)}\n\n"; message += $"{modName}:\n {string.Join("\n ", commandNames)}\n\n";
} }
message += "For more information about a command, type 'help command_name'.";
monitor.Log(message, LogLevel.Info); monitor.Log(message, LogLevel.Info);
} }

View File

@ -0,0 +1,33 @@
namespace StardewModdingAPI.Framework.Content
{
/// <summary>Basic metadata about a vanilla tilesheet.</summary>
internal class TilesheetReference
{
/*********
** Accessors
*********/
/// <summary>The tilesheet's index in the list.</summary>
public readonly int Index;
/// <summary>The tilesheet's unique ID in the map.</summary>
public readonly string Id;
/// <summary>The asset path for the tilesheet texture.</summary>
public readonly string ImageSource;
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="index">The tilesheet's index in the list.</param>
/// <param name="id">The tilesheet's unique ID in the map.</param>
/// <param name="imageSource">The asset path for the tilesheet texture.</param>
public TilesheetReference(int index, string id, string imageSource)
{
this.Index = index;
this.Id = id;
this.ImageSource = imageSource;
}
}
}

View File

@ -54,6 +54,9 @@ namespace StardewModdingAPI.Framework
/// <remarks>The game may adds content managers in asynchronous threads (e.g. when populating the load screen).</remarks> /// <remarks>The game may adds content managers in asynchronous threads (e.g. when populating the load screen).</remarks>
private readonly ReaderWriterLockSlim ContentManagerLock = new ReaderWriterLockSlim(); private readonly ReaderWriterLockSlim ContentManagerLock = new ReaderWriterLockSlim();
/// <summary>A cache of ordered tilesheet IDs used by vanilla maps.</summary>
private readonly IDictionary<string, TilesheetReference[]> VanillaTilesheets = new Dictionary<string, TilesheetReference[]>(StringComparer.OrdinalIgnoreCase);
/// <summary>An unmodified content manager which doesn't intercept assets, used to compare asset data.</summary> /// <summary>An unmodified content manager which doesn't intercept assets, used to compare asset data.</summary>
private readonly LocalizedContentManager VanillaContentManager; private readonly LocalizedContentManager VanillaContentManager;
@ -293,21 +296,21 @@ namespace StardewModdingAPI.Framework
}); });
} }
/// <summary>Get a vanilla asset without interception.</summary> /// <summary>Get the tilesheet ID order used by the unmodified version of a map asset.</summary>
/// <typeparam name="T">The type of asset to load.</typeparam>
/// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
public bool TryLoadVanillaAsset<T>(string assetName, out T asset) public TilesheetReference[] GetVanillaTilesheetIds(string assetName)
{ {
try if (!this.VanillaTilesheets.TryGetValue(assetName, out TilesheetReference[] tilesheets))
{ {
asset = this.VanillaContentManager.Load<T>(assetName); tilesheets = this.TryLoadVanillaAsset(assetName, out Map map)
return true; ? map.TileSheets.Select((sheet, index) => new TilesheetReference(index, sheet.Id, sheet.ImageSource)).ToArray()
} : null;
catch
{ this.VanillaTilesheets[assetName] = tilesheets;
asset = default; this.VanillaContentManager.Unload();
return false;
} }
return tilesheets ?? new TilesheetReference[0];
} }
/// <summary>Dispose held resources.</summary> /// <summary>Dispose held resources.</summary>
@ -341,5 +344,23 @@ namespace StardewModdingAPI.Framework
this.ContentManagers.Remove(contentManager) this.ContentManagers.Remove(contentManager)
); );
} }
/// <summary>Get a vanilla asset without interception.</summary>
/// <typeparam name="T">The type of asset to load.</typeparam>
/// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
/// <param name="asset">The loaded asset data.</param>
private bool TryLoadVanillaAsset<T>(string assetName, out T asset)
{
try
{
asset = this.VanillaContentManager.Load<T>(assetName);
return true;
}
catch
{
asset = default;
return false;
}
}
} }
} }

View File

@ -11,7 +11,6 @@ using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.Utilities; using StardewModdingAPI.Framework.Utilities;
using StardewValley; using StardewValley;
using xTile; using xTile;
using xTile.Tiles;
namespace StardewModdingAPI.Framework.ContentManagers namespace StardewModdingAPI.Framework.ContentManagers
{ {
@ -398,14 +397,13 @@ namespace StardewModdingAPI.Framework.ContentManagers
} }
// when replacing a map, the vanilla tilesheets must have the same order and IDs // when replacing a map, the vanilla tilesheets must have the same order and IDs
if (data is Map loadedMap && this.Coordinator.TryLoadVanillaAsset(info.AssetName, out Map vanillaMap)) if (data is Map loadedMap)
{ {
for (int i = 0; i < vanillaMap.TileSheets.Count; i++) TilesheetReference[] vanillaTilesheetRefs = this.Coordinator.GetVanillaTilesheetIds(info.AssetName);
foreach (TilesheetReference vanillaSheet in vanillaTilesheetRefs)
{ {
// check for match // skip if match
TileSheet vanillaSheet = vanillaMap.TileSheets[i]; if (loadedMap.TileSheets.Count > vanillaSheet.Index && loadedMap.TileSheets[vanillaSheet.Index].Id == vanillaSheet.Id)
bool found = this.TryFindTilesheet(loadedMap, vanillaSheet.Id, out int loadedIndex, out TileSheet loadedSheet);
if (found && loadedIndex == i)
continue; continue;
// handle mismatch // handle mismatch
@ -414,18 +412,18 @@ namespace StardewModdingAPI.Framework.ContentManagers
// This is temporary: mods shouldn't do this for any vanilla map, but these are the ones we know will crash. Showing a warning for others instead gives modders time to update their mods, while still simplifying troubleshooting. // This is temporary: mods shouldn't do this for any vanilla map, but these are the ones we know will crash. Showing a warning for others instead gives modders time to update their mods, while still simplifying troubleshooting.
bool isFarmMap = info.AssetNameEquals("Maps/Farm") || info.AssetNameEquals("Maps/Farm_Combat") || info.AssetNameEquals("Maps/Farm_Fishing") || info.AssetNameEquals("Maps/Farm_Foraging") || info.AssetNameEquals("Maps/Farm_FourCorners") || info.AssetNameEquals("Maps/Farm_Island") || info.AssetNameEquals("Maps/Farm_Mining"); bool isFarmMap = info.AssetNameEquals("Maps/Farm") || info.AssetNameEquals("Maps/Farm_Combat") || info.AssetNameEquals("Maps/Farm_Fishing") || info.AssetNameEquals("Maps/Farm_Foraging") || info.AssetNameEquals("Maps/Farm_FourCorners") || info.AssetNameEquals("Maps/Farm_Island") || info.AssetNameEquals("Maps/Farm_Mining");
int loadedIndex = this.TryFindTilesheet(loadedMap, vanillaSheet.Id);
string reason = found string reason = loadedIndex != -1
? $"mod reordered the original tilesheets, which {(isFarmMap ? "would cause a crash" : "often causes crashes")}.\n\nTechnical details for mod author:\nExpected order [{string.Join(", ", vanillaMap.TileSheets.Select(p => $"'{p.ImageSource}' (id: {p.Id})"))}], but found tilesheet '{vanillaSheet.Id}' at index {loadedIndex} instead of {i}. Make sure custom tilesheet IDs are prefixed with 'z_' to avoid reordering tilesheets." ? $"mod reordered the original tilesheets, which {(isFarmMap ? "would cause a crash" : "often causes crashes")}.\nTechnical details for mod author: Expected order: {string.Join(", ", vanillaTilesheetRefs.Select(p => p.Id))}. See https://stardewcommunitywiki.com/Modding:Maps#Tilesheet_order for help."
: $"mod has no tilesheet with ID '{vanillaSheet.Id}'. Map replacements must keep the original tilesheets to avoid errors or crashes."; : $"mod has no tilesheet with ID '{vanillaSheet.Id}'. Map replacements must keep the original tilesheets to avoid errors or crashes.";
SCore.DeprecationManager.PlaceholderWarn("3.8.2", DeprecationLevel.PendingRemoval); SCore.DeprecationManager.PlaceholderWarn("3.8.2", DeprecationLevel.PendingRemoval);
if (isFarmMap) if (isFarmMap)
{ {
mod.LogAsMod($"SMAPI blocked asset replacement for '{info.AssetName}': {reason}", LogLevel.Error); mod.LogAsMod($"SMAPI blocked '{info.AssetName}' map load: {reason}", LogLevel.Error);
return false; return false;
} }
mod.LogAsMod($"SMAPI detected a potential issue with asset replacement for '{info.AssetName}' map: {reason}", LogLevel.Warn); mod.LogAsMod($"SMAPI found an issue with '{info.AssetName}' map load: {reason}", LogLevel.Warn);
} }
} }
} }
@ -436,23 +434,15 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <summary>Find a map tilesheet by ID.</summary> /// <summary>Find a map tilesheet by ID.</summary>
/// <param name="map">The map whose tilesheets to search.</param> /// <param name="map">The map whose tilesheets to search.</param>
/// <param name="id">The tilesheet ID to match.</param> /// <param name="id">The tilesheet ID to match.</param>
/// <param name="index">The matched tilesheet index, if any.</param> private int TryFindTilesheet(Map map, string id)
/// <param name="tilesheet">The matched tilesheet, if any.</param>
private bool TryFindTilesheet(Map map, string id, out int index, out TileSheet tilesheet)
{ {
for (int i = 0; i < map.TileSheets.Count; i++) for (int i = 0; i < map.TileSheets.Count; i++)
{ {
if (map.TileSheets[i].Id == id) if (map.TileSheets[i].Id == id)
{ return i;
index = i;
tilesheet = map.TileSheets[i];
return true;
}
} }
index = -1; return -1;
tilesheet = null;
return false;
} }
} }
} }

View File

@ -2,7 +2,6 @@ using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Reflection; using System.Reflection;
using StardewModdingAPI.Events; using StardewModdingAPI.Events;
using StardewModdingAPI.Framework.PerformanceMonitoring;
namespace StardewModdingAPI.Framework.Events namespace StardewModdingAPI.Framework.Events
{ {
@ -178,13 +177,12 @@ namespace StardewModdingAPI.Framework.Events
*********/ *********/
/// <summary>Construct an instance.</summary> /// <summary>Construct an instance.</summary>
/// <param name="modRegistry">The mod registry with which to identify mods.</param> /// <param name="modRegistry">The mod registry with which to identify mods.</param>
/// <param name="performanceMonitor">Tracks performance metrics.</param> public EventManager(ModRegistry modRegistry)
public EventManager(ModRegistry modRegistry, PerformanceMonitor performanceMonitor)
{ {
// create shortcut initializers // create shortcut initializers
ManagedEvent<TEventArgs> ManageEventOf<TEventArgs>(string typeName, string eventName, bool isPerformanceCritical = false) ManagedEvent<TEventArgs> ManageEventOf<TEventArgs>(string typeName, string eventName, bool isPerformanceCritical = false)
{ {
return new ManagedEvent<TEventArgs>($"{typeName}.{eventName}", modRegistry, performanceMonitor, isPerformanceCritical); return new ManagedEvent<TEventArgs>($"{typeName}.{eventName}", modRegistry, isPerformanceCritical);
} }
// init events (new) // init events (new)

View File

@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using StardewModdingAPI.Events; using StardewModdingAPI.Events;
using StardewModdingAPI.Framework.PerformanceMonitoring;
namespace StardewModdingAPI.Framework.Events namespace StardewModdingAPI.Framework.Events
{ {
@ -17,9 +16,6 @@ namespace StardewModdingAPI.Framework.Events
/// <summary>The mod registry with which to identify mods.</summary> /// <summary>The mod registry with which to identify mods.</summary>
protected readonly ModRegistry ModRegistry; protected readonly ModRegistry ModRegistry;
/// <summary>Tracks performance metrics.</summary>
private readonly PerformanceMonitor PerformanceMonitor;
/// <summary>The underlying event handlers.</summary> /// <summary>The underlying event handlers.</summary>
private readonly List<ManagedEventHandler<TEventArgs>> Handlers = new List<ManagedEventHandler<TEventArgs>>(); private readonly List<ManagedEventHandler<TEventArgs>> Handlers = new List<ManagedEventHandler<TEventArgs>>();
@ -49,13 +45,11 @@ namespace StardewModdingAPI.Framework.Events
/// <summary>Construct an instance.</summary> /// <summary>Construct an instance.</summary>
/// <param name="eventName">A human-readable name for the event.</param> /// <param name="eventName">A human-readable name for the event.</param>
/// <param name="modRegistry">The mod registry with which to identify mods.</param> /// <param name="modRegistry">The mod registry with which to identify mods.</param>
/// <param name="performanceMonitor">Tracks performance metrics.</param>
/// <param name="isPerformanceCritical">Whether the event is typically called at least once per second.</param> /// <param name="isPerformanceCritical">Whether the event is typically called at least once per second.</param>
public ManagedEvent(string eventName, ModRegistry modRegistry, PerformanceMonitor performanceMonitor, bool isPerformanceCritical = false) public ManagedEvent(string eventName, ModRegistry modRegistry, bool isPerformanceCritical = false)
{ {
this.EventName = eventName; this.EventName = eventName;
this.ModRegistry = modRegistry; this.ModRegistry = modRegistry;
this.PerformanceMonitor = performanceMonitor;
this.IsPerformanceCritical = isPerformanceCritical; this.IsPerformanceCritical = isPerformanceCritical;
} }
@ -126,8 +120,6 @@ namespace StardewModdingAPI.Framework.Events
} }
// raise event // raise event
this.PerformanceMonitor.Track(this.EventName, () =>
{
foreach (ManagedEventHandler<TEventArgs> handler in handlers) foreach (ManagedEventHandler<TEventArgs> handler in handlers)
{ {
if (match != null && !match(handler.SourceMod)) if (match != null && !match(handler.SourceMod))
@ -135,31 +127,19 @@ namespace StardewModdingAPI.Framework.Events
try try
{ {
this.PerformanceMonitor.Track(this.EventName, this.GetModNameForPerformanceCounters(handler), () => handler.Handler.Invoke(null, args)); handler.Handler.Invoke(null, args);
} }
catch (Exception ex) catch (Exception ex)
{ {
this.LogError(handler, ex); this.LogError(handler, ex);
} }
} }
});
} }
/********* /*********
** Private methods ** 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(ManagedEventHandler<TEventArgs> handler)
{
IModMetadata mod = handler.SourceMod;
return mod.HasManifest()
? mod.Manifest.UniqueID
: mod.DisplayName;
}
/// <summary>Log an exception from an event handler.</summary> /// <summary>Log an exception from an event handler.</summary>
/// <param name="handler">The event handler instance.</param> /// <param name="handler">The event handler instance.</param>
/// <param name="ex">The exception that was raised.</param> /// <param name="ex">The exception that was raised.</param>

View File

@ -1,34 +0,0 @@
namespace StardewModdingAPI.Framework.PerformanceMonitoring
{
/// <summary>The context for an alert.</summary>
internal readonly 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";
}
}
}

View File

@ -1,38 +0,0 @@
namespace StardewModdingAPI.Framework.PerformanceMonitoring
{
/// <summary>A single alert entry.</summary>
internal readonly 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;
}
}
}

View File

@ -1,35 +0,0 @@
using System;
namespace StardewModdingAPI.Framework.PerformanceMonitoring
{
/// <summary>A peak invocation time.</summary>
internal readonly 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;
}
}
}

View File

@ -1,124 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
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.Push(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;
}
}
}

View File

@ -1,205 +0,0 @@
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.OrdinalIgnoreCase))
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;
}
}
}

View File

@ -1,30 +0,0 @@
using System;
namespace StardewModdingAPI.Framework.PerformanceMonitoring
{
/// <summary>A single performance counter entry.</summary>
internal readonly 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;
}
}
}

View File

@ -1,184 +0,0 @@
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.OrdinalIgnoreCase);
/*********
** 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;
}
}
}

View File

@ -28,7 +28,6 @@ using StardewModdingAPI.Framework.ModHelpers;
using StardewModdingAPI.Framework.ModLoading; using StardewModdingAPI.Framework.ModLoading;
using StardewModdingAPI.Framework.Networking; using StardewModdingAPI.Framework.Networking;
using StardewModdingAPI.Framework.Patching; using StardewModdingAPI.Framework.Patching;
using StardewModdingAPI.Framework.PerformanceMonitoring;
using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.Rendering; using StardewModdingAPI.Framework.Rendering;
using StardewModdingAPI.Framework.Serialization; using StardewModdingAPI.Framework.Serialization;
@ -82,7 +81,7 @@ namespace StardewModdingAPI.Framework
** Higher-level components ** Higher-level components
****/ ****/
/// <summary>Manages console commands.</summary> /// <summary>Manages console commands.</summary>
private readonly CommandManager CommandManager = new CommandManager(); private readonly CommandManager CommandManager;
/// <summary>The underlying game instance.</summary> /// <summary>The underlying game instance.</summary>
private SGameRunner Game; private SGameRunner Game;
@ -131,9 +130,12 @@ namespace StardewModdingAPI.Framework
/// <summary>Asset interceptors added or removed since the last tick.</summary> /// <summary>Asset interceptors added or removed since the last tick.</summary>
private readonly List<AssetInterceptorChange> ReloadAssetInterceptorsQueue = new List<AssetInterceptorChange>(); private readonly List<AssetInterceptorChange> ReloadAssetInterceptorsQueue = new List<AssetInterceptorChange>();
/// <summary>A list of queued commands to execute.</summary> /// <summary>A list of queued commands to parse and execute.</summary>
/// <remarks>This property must be thread-safe, since it's accessed from a separate console input thread.</remarks> /// <remarks>This property must be thread-safe, since it's accessed from a separate console input thread.</remarks>
public ConcurrentQueue<string> CommandQueue { get; } = new ConcurrentQueue<string>(); private readonly ConcurrentQueue<string> RawCommandQueue = new ConcurrentQueue<string>();
/// <summary>A list of commands to execute on each screen.</summary>
private readonly PerScreen<List<Tuple<Command, string, string[]>>> ScreenCommandQueue = new(() => new());
/********* /*********
@ -143,10 +145,6 @@ 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> /// <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; } 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; }
/// <summary>The number of update ticks which have already executed. This is similar to <see cref="Game1.ticks"/>, but incremented more consistently for every tick.</summary> /// <summary>The number of update ticks which have already executed. This is similar to <see cref="Game1.ticks"/>, but incremented more consistently for every tick.</summary>
internal static uint TicksElapsed { get; private set; } internal static uint TicksElapsed { get; private set; }
@ -174,13 +172,9 @@ namespace StardewModdingAPI.Framework
JsonConvert.PopulateObject(File.ReadAllText(Constants.ApiUserConfigPath), this.Settings); JsonConvert.PopulateObject(File.ReadAllText(Constants.ApiUserConfigPath), this.Settings);
this.LogManager = new LogManager(logPath: logPath, colorConfig: this.Settings.ConsoleColors, writeToConsole: writeToConsole, isVerbose: this.Settings.VerboseLogging, isDeveloperMode: this.Settings.DeveloperMode, getScreenIdForLog: this.GetScreenIdForLog); this.LogManager = new LogManager(logPath: logPath, colorConfig: this.Settings.ConsoleColors, writeToConsole: writeToConsole, isVerbose: this.Settings.VerboseLogging, isDeveloperMode: this.Settings.DeveloperMode, getScreenIdForLog: this.GetScreenIdForLog);
this.CommandManager = new CommandManager(this.Monitor);
SCore.PerformanceMonitor = new PerformanceMonitor(this.Monitor); this.EventManager = new EventManager(this.ModRegistry);
this.EventManager = new EventManager(this.ModRegistry, SCore.PerformanceMonitor);
SCore.PerformanceMonitor.InitializePerformanceCounterCollections(this.EventManager);
SCore.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry); SCore.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry);
SDate.Translations = this.Translator; SDate.Translations = this.Translator;
// log SMAPI/OS info // log SMAPI/OS info
@ -413,7 +407,7 @@ namespace StardewModdingAPI.Framework
() => this.LogManager.RunConsoleInputLoop( () => this.LogManager.RunConsoleInputLoop(
commandManager: this.CommandManager, commandManager: this.CommandManager,
reloadTranslations: this.ReloadTranslations, reloadTranslations: this.ReloadTranslations,
handleInput: input => this.CommandQueue.Enqueue(input), handleInput: input => this.RawCommandQueue.Enqueue(input),
continueWhile: () => this.IsGameRunning && !this.CancellationToken.IsCancellationRequested continueWhile: () => this.IsGameRunning && !this.CancellationToken.IsCancellationRequested
) )
).Start(); ).Start();
@ -443,7 +437,6 @@ namespace StardewModdingAPI.Framework
*********/ *********/
// print warnings/alerts // print warnings/alerts
SCore.DeprecationManager.PrintQueued(); SCore.DeprecationManager.PrintQueued();
SCore.PerformanceMonitor.PrintQueuedAlerts();
/********* /*********
** First-tick initialization ** First-tick initialization
@ -497,17 +490,18 @@ namespace StardewModdingAPI.Framework
} }
/********* /*********
** Execute commands ** Parse commands
*********/ *********/
while (this.CommandQueue.TryDequeue(out string rawInput)) while (this.RawCommandQueue.TryDequeue(out string rawInput))
{ {
// parse command // parse command
string name; string name;
string[] args; string[] args;
Command command; Command command;
int screenId;
try try
{ {
if (!this.CommandManager.TryParse(rawInput, out name, out args, out command)) if (!this.CommandManager.TryParse(rawInput, out name, out args, out command, out screenId))
{ {
this.Monitor.Log("Unknown command; type 'help' for a list of available commands.", LogLevel.Error); this.Monitor.Log("Unknown command; type 'help' for a list of available commands.", LogLevel.Error);
continue; continue;
@ -519,18 +513,8 @@ namespace StardewModdingAPI.Framework
continue; continue;
} }
// execute command // queue command for screen
try this.ScreenCommandQueue.GetValueForScreen(screenId).Add(Tuple.Create(command, name, args));
{
command.Callback.Invoke(name, args);
}
catch (Exception ex)
{
if (command.Mod != null)
command.Mod.LogAsMod($"Mod failed handling that command:\n{ex.GetLogSummary()}", LogLevel.Error);
else
this.Monitor.Log($"Failed handling that command:\n{ex.GetLogSummary()}", LogLevel.Error);
}
} }
/********* /*********
@ -578,7 +562,9 @@ namespace StardewModdingAPI.Framework
try try
{ {
// reapply overrides /*********
** Reapply overrides
*********/
if (this.JustReturnedToTitle) if (this.JustReturnedToTitle)
{ {
if (!(Game1.mapDisplayDevice is SDisplayDevice)) if (!(Game1.mapDisplayDevice is SDisplayDevice))
@ -587,6 +573,33 @@ namespace StardewModdingAPI.Framework
this.JustReturnedToTitle = false; this.JustReturnedToTitle = false;
} }
/*********
** Execute commands
*********/
{
var commandQueue = this.ScreenCommandQueue.Value;
foreach (var entry in commandQueue)
{
Command command = entry.Item1;
string name = entry.Item2;
string[] args = entry.Item3;
try
{
command.Callback.Invoke(name, args);
}
catch (Exception ex)
{
if (command.Mod != null)
command.Mod.LogAsMod($"Mod failed handling that command:\n{ex.GetLogSummary()}", LogLevel.Error);
else
this.Monitor.Log($"Failed handling that command:\n{ex.GetLogSummary()}", LogLevel.Error);
}
}
commandQueue.Clear();
}
/********* /*********
** Update input ** Update input
*********/ *********/

View File

@ -247,7 +247,7 @@ namespace StardewModdingAPI.Framework
} }
if (target_screen != null) if (target_screen != null)
{ {
base.GraphicsDevice.SetRenderTarget(target_screen); Game1.SetRenderTarget(target_screen);
} }
if (this.IsSaving) if (this.IsSaving)
{ {
@ -352,8 +352,7 @@ namespace StardewModdingAPI.Framework
events.Rendered.RaiseEmpty(); events.Rendered.RaiseEmpty();
Game1.spriteBatch.End(); Game1.spriteBatch.End();
} }
Game1.SetRenderTarget(target_screen);
base.GraphicsDevice.SetRenderTarget(target_screen);
return; return;
} }
if (Game1.showingEndOfNightStuff) if (Game1.showingEndOfNightStuff)
@ -426,7 +425,7 @@ namespace StardewModdingAPI.Framework
} }
if (Game1.drawLighting) if (Game1.drawLighting)
{ {
base.GraphicsDevice.SetRenderTarget(Game1.lightmap); Game1.SetRenderTarget(Game1.lightmap);
base.GraphicsDevice.Clear(Microsoft.Xna.Framework.Color.White * 0f); base.GraphicsDevice.Clear(Microsoft.Xna.Framework.Color.White * 0f);
Matrix lighting_matrix = Matrix.Identity; Matrix lighting_matrix = Matrix.Identity;
if (this.useUnscaledLighting) if (this.useUnscaledLighting)
@ -473,7 +472,7 @@ namespace StardewModdingAPI.Framework
} }
} }
Game1.spriteBatch.End(); Game1.spriteBatch.End();
base.GraphicsDevice.SetRenderTarget(target_screen); Game1.SetRenderTarget(target_screen);
} }
if (Game1.bloomDay && Game1.bloom != null) if (Game1.bloomDay && Game1.bloom != null)
{ {

View File

@ -1,5 +1,4 @@
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("SMAPI.Tests")] [assembly: InternalsVisibleTo("SMAPI.Tests")]
[assembly: InternalsVisibleTo("ConsoleCommands")] // for performance monitoring commands
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // Moq for unit testing [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // Moq for unit testing

View File

@ -28,18 +28,8 @@ namespace StardewModdingAPI.Utilities
/// <remarks>The value is initialized the first time it's requested for that player, unless it's set manually first.</remarks> /// <remarks>The value is initialized the first time it's requested for that player, unless it's set manually first.</remarks>
public T Value public T Value
{ {
get get => this.GetValueForScreen(Context.ScreenId);
{ set => this.SetValueForScreen(Context.ScreenId, value);
this.RemoveDeadPlayers();
return this.States.TryGetValue(Context.ScreenId, out T state)
? state
: this.States[Context.ScreenId] = this.CreateNewState();
}
set
{
this.RemoveDeadPlayers();
this.States[Context.ScreenId] = value;
}
} }
@ -57,6 +47,25 @@ namespace StardewModdingAPI.Utilities
this.CreateNewState = createNewState ?? (() => default); this.CreateNewState = createNewState ?? (() => default);
} }
/// <summary>Get the value for a given screen ID, creating it if needed.</summary>
/// <param name="screenId">The screen ID to check.</param>
internal T GetValueForScreen(int screenId)
{
this.RemoveDeadPlayers();
return this.States.TryGetValue(screenId, out T state)
? state
: this.States[screenId] = this.CreateNewState();
}
/// <summary>Set the value for a given screen ID, creating it if needed.</summary>
/// <param name="screenId">The screen ID whose value set.</param>
/// <param name="value">The value to set.</param>
internal void SetValueForScreen(int screenId, T value)
{
this.RemoveDeadPlayers();
this.States[screenId] = value;
}
/********* /*********
** Private methods ** Private methods