using System; using System.Collections.Generic; using System.Linq; using System.Text; using StardewModdingAPI.Framework; using StardewModdingAPI.Framework.PerformanceCounter; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other { // ReSharper disable once UnusedType.Global internal class PerformanceCounterCommand : TrainerCommand { /// The command names and aliases private readonly Dictionary SubCommandNames = new Dictionary() { {SubCommand.Summary, new[] {"summary", "sum", "s"}}, {SubCommand.Detail, new[] {"detail", "d"}}, {SubCommand.Reset, new[] {"reset", "r"}}, {SubCommand.Trigger, new[] {"trigger"}}, {SubCommand.Examples, new[] {"examples"}}, {SubCommand.Concepts, new[] {"concepts"}}, {SubCommand.Help, new[] {"help"}}, }; /// The available commands enum private enum SubCommand { Summary, Detail, Reset, Trigger, Examples, Help, Concepts, None } /// Construct an instance. public PerformanceCounterCommand() : base("pc", PerformanceCounterCommand.GetDescription()) { } /// Handle the command. /// Writes messages to the console and log file. /// The command name. /// The command arguments. public override void Handle(IMonitor monitor, string command, ArgumentParser args) { if (args.TryGet(0, "command", out string subCommandString, false)) { SubCommand subSubCommand = this.ParseCommandString(subCommandString); switch (subSubCommand) { 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.Examples: break; case SubCommand.Concepts: this.OutputHelp(monitor, SubCommand.Concepts); break; case SubCommand.Help: if (args.TryGet(1, "command", out string commandString)) this.OutputHelp(monitor, this.ParseCommandString(commandString)); break; default: this.LogUsageError(monitor, $"Unknown command {subCommandString}"); break; } } else this.HandleSummarySubCommand(monitor, args); } /// Handles the summary sub command. /// Writes messages to the console and log file. /// The command arguments. private void HandleSummarySubCommand(IMonitor monitor, ArgumentParser args) { IEnumerable data; if (!args.TryGet(1, "mode", out string mode, false)) { mode = "important"; } switch (mode) { case null: case "important": data = SCore.PerformanceCounterManager.PerformanceCounterCollections.Where(p => p.IsImportant); break; case "all": data = SCore.PerformanceCounterManager.PerformanceCounterCollections; break; default: data = SCore.PerformanceCounterManager.PerformanceCounterCollections.Where(p => p.Name.ToLowerInvariant().Contains(mode.ToLowerInvariant())); break; } double? threshold = null; if (args.TryGetDecimal(2, "threshold", out decimal t, false)) { threshold = (double?) t; } StringBuilder sb = new StringBuilder(); sb.AppendLine("Summary:"); sb.AppendLine(this.GetTableString( data: data, header: new[] {"Collection", "Avg Calls/s", "Avg Execution Time (Game)", "Avg Execution Time (Mods)", "Avg Execution Time (Game+Mods)"}, getRow: item => new[] { item.Name, item.GetAverageCallsPerSecond().ToString(), this.FormatMilliseconds(item.GetGameAverageExecutionTime(), threshold), this.FormatMilliseconds(item.GetModsAverageExecutionTime(), threshold), this.FormatMilliseconds(item.GetAverageExecutionTime(), threshold) }, true )); monitor.Log(sb.ToString(), LogLevel.Info); } /// Handles the detail sub command. /// Writes messages to the console and log file. /// The command arguments. private void HandleDetailSubCommand(IMonitor monitor, ArgumentParser args) { var collections = new List(); TimeSpan averageInterval = TimeSpan.FromSeconds(60); double? thresholdMilliseconds = null; string sourceFilter = null; if (args.TryGet(1, "collection", out string collectionName)) { collections.AddRange(SCore.PerformanceCounterManager.PerformanceCounterCollections.Where( collection => collection.Name.ToLowerInvariant().Contains(collectionName.ToLowerInvariant()))); if (args.IsDecimal(2) && args.TryGetDecimal(2, "threshold", out decimal value, false)) { thresholdMilliseconds = (double?) value; } else { if (args.TryGet(2, "source", out string sourceName, false)) { sourceFilter = sourceName; } } } foreach (PerformanceCounterCollection c in collections) { this.OutputPerformanceCollectionDetail(monitor, c, averageInterval, thresholdMilliseconds, sourceFilter); } } /// Handles the trigger sub command. /// Writes messages to the console and log file. /// The command arguments. private void HandleTriggerSubCommand(IMonitor monitor, ArgumentParser args) { 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, false)) { this.ConfigureAlertTrigger(monitor, collectionName, source, threshold); } else { this.ConfigureAlertTrigger(monitor, collectionName, null, threshold); } } } break; case "pause": SCore.PerformanceCounterManager.PauseAlerts = true; monitor.Log($"Alerts are now paused.", LogLevel.Info); break; case "resume": SCore.PerformanceCounterManager.PauseAlerts = false; monitor.Log($"Alerts are now resumed.", LogLevel.Info); break; case "clear": this.ClearAlertTriggers(monitor); break; default: this.LogUsageError(monitor, $"Unknown mode {mode}. See 'pc help trigger' for usage."); break; } } else { this.OutputAlertTriggers(monitor); } } /// Sets up an an alert trigger. /// Writes messages to the console and log file. /// The name of the collection. /// The name of the source, or null for all sources. /// The trigger threshold, or 0 to remove. private void ConfigureAlertTrigger(IMonitor monitor, string collectionName, string sourceName, decimal threshold) { foreach (PerformanceCounterCollection collection in SCore.PerformanceCounterManager.PerformanceCounterCollections) { 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); } /// Clears alert triggering for all collections. /// Writes messages to the console and log file. private void ClearAlertTriggers(IMonitor monitor) { int clearedTriggers = 0; foreach (PerformanceCounterCollection collection in SCore.PerformanceCounterManager.PerformanceCounterCollections) { 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); } /// Lists all configured alert triggers. /// Writes messages to the console and log file. private void OutputAlertTriggers(IMonitor monitor) { StringBuilder sb = new StringBuilder(); sb.AppendLine("Configured triggers:"); sb.AppendLine(); var collectionTriggers = new List<(string collectionName, double threshold)>(); var sourceTriggers = new List<(string collectionName, string sourceName, double threshold)>(); foreach (PerformanceCounterCollection collection in SCore.PerformanceCounterManager.PerformanceCounterCollections) { if (collection.EnableAlerts) { collectionTriggers.Add((collection.Name, collection.AlertThresholdMilliseconds)); } sourceTriggers.AddRange(from performanceCounter in collection.PerformanceCounters where performanceCounter.Value.EnableAlerts select (collection.Name, performanceCounter.Value.Source, performanceCounter.Value.AlertThresholdMilliseconds)); } if (collectionTriggers.Count > 0) { sb.AppendLine("Collection Triggers:"); sb.AppendLine(); sb.AppendLine(this.GetTableString( data: collectionTriggers, header: new[] {"Collection", "Threshold"}, getRow: item => new[] { item.collectionName, this.FormatMilliseconds(item.threshold) }, true )); sb.AppendLine(); } else { sb.AppendLine("No collection triggers."); } if (sourceTriggers.Count > 0) { sb.AppendLine("Source Triggers:"); sb.AppendLine(); sb.AppendLine(this.GetTableString( data: sourceTriggers, header: new[] {"Collection", "Source", "Threshold"}, getRow: item => new[] { item.collectionName, item.sourceName, this.FormatMilliseconds(item.threshold) }, true )); sb.AppendLine(); } else { sb.AppendLine("No source triggers."); } monitor.Log(sb.ToString(), LogLevel.Info); } /// Handles the reset sub command. /// Writes messages to the console and log file. /// The command arguments. private void HandleResetSubCommand(IMonitor monitor, ArgumentParser args) { if (args.TryGet(1, "type", out string type, false, new []{"category", "source"})) { args.TryGet(2, "name", out string name); switch (type) { case "category": SCore.PerformanceCounterManager.ResetCollection(name); monitor.Log($"All performance counters for category {name} are now cleared.", LogLevel.Info); break; case "source": SCore.PerformanceCounterManager.ResetSource(name); monitor.Log($"All performance counters for source {name} are now cleared.", LogLevel.Info); break; } } else { SCore.PerformanceCounterManager.Reset(); monitor.Log("All performance counters are now cleared.", LogLevel.Info); } } /// Outputs the details for a collection. /// Writes messages to the console and log file. /// The collection. /// The interval over which to calculate the averages. /// The threshold. /// The source filter. private void OutputPerformanceCollectionDetail(IMonitor monitor, PerformanceCounterCollection collection, TimeSpan averageInterval, double? thresholdMilliseconds, string sourceFilter = null) { StringBuilder sb = new StringBuilder($"Performance Counter for {collection.Name}:\n\n"); List> data = collection.PerformanceCounters.ToList(); if (sourceFilter != null) { data = collection.PerformanceCounters.Where(p => p.Value.Source.ToLowerInvariant().Contains(sourceFilter.ToLowerInvariant())).ToList(); } if (thresholdMilliseconds != null) { data = data.Where(p => p.Value.GetAverage(averageInterval) >= thresholdMilliseconds).ToList(); } if (data.Any()) { sb.AppendLine(this.GetTableString( data: data, header: new[] {"Mod", $"Avg Execution Time (last {(int) averageInterval.TotalSeconds}s)", "Last Execution Time", "Peak Execution Time"}, getRow: item => new[] { item.Key, this.FormatMilliseconds(item.Value.GetAverage(averageInterval), thresholdMilliseconds), this.FormatMilliseconds(item.Value.GetLastEntry()?.ElapsedMilliseconds), this.FormatMilliseconds(item.Value.GetPeak()?.ElapsedMilliseconds) }, true )); } else { sb.Clear(); sb.AppendLine($"Performance Counter for {collection.Name}: none."); } monitor.Log(sb.ToString(), LogLevel.Info); } /// Parses a command string and returns the associated command. /// The command string /// The parsed command. private SubCommand ParseCommandString(string commandString) { foreach (var i in this.SubCommandNames.Where(i => i.Value.Any(str => str.Equals(commandString, StringComparison.InvariantCultureIgnoreCase)))) { return i.Key; } return SubCommand.None; } /// Formats the given milliseconds value into a string format. Optionally /// allows a threshold to return "-" if the value is less than the threshold. /// The milliseconds to format. Returns "-" if null /// The threshold. Any value below this is returned as "-". /// The formatted milliseconds. private string FormatMilliseconds(double? milliseconds, double? thresholdMilliseconds = null) { if (milliseconds == null || (thresholdMilliseconds != null && milliseconds < thresholdMilliseconds)) { return "-"; } return ((double) milliseconds).ToString("F2"); } /// Shows detailed help for a specific sub command. /// The output monitor /// The sub command private void OutputHelp(IMonitor monitor, SubCommand subCommand) { StringBuilder sb = new StringBuilder(); sb.AppendLine(); switch (subCommand) { case SubCommand.Concepts: sb.AppendLine("A performance counter is a metric which measures execution time. Each performance"); sb.AppendLine("counter consists of:"); sb.AppendLine(); sb.AppendLine(" - A source, which typically is a mod or the game itself."); sb.AppendLine(" - A ring buffer which stores the data points (execution time and time when it was executed)"); sb.AppendLine(); sb.AppendLine("A set of performance counters is organized in a collection to group various areas."); sb.AppendLine("Per default, collections for all game events [1] are created."); sb.AppendLine(); sb.AppendLine("Example:"); sb.AppendLine(); sb.AppendLine("The performance counter collection named 'Display.Rendered' contains one performance"); sb.AppendLine("counters when the game executes the 'Display.Rendered' event, and one additional"); sb.AppendLine("performance counter for each mod which handles the 'Display.Rendered' event."); sb.AppendLine(); sb.AppendLine("[1] https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Events"); break; case SubCommand.Detail: sb.AppendLine("Usage: pc detail "); sb.AppendLine(" pc detail "); sb.AppendLine(); sb.AppendLine("Displays details for a specific collection."); sb.AppendLine(); sb.AppendLine("Arguments:"); sb.AppendLine(" Required. The full or partial name of the collection to display."); sb.AppendLine(" Optional. The full or partial name of the source."); sb.AppendLine(" Optional. The threshold in milliseconds. Any average execution time below that"); sb.AppendLine(" threshold is not reported."); sb.AppendLine(); sb.AppendLine("Examples:"); sb.AppendLine("pc detail Display.Rendering Displays all performance counters for the 'Display.Rendering' collection"); sb.AppendLine("pc detail Display.Rendering Pathoschild.ChestsAnywhere Displays the 'Display.Rendering' performance counter for 'Pathoschild.ChestsAnywhere'"); sb.AppendLine("pc detail Display.Rendering 5 Displays the 'Display.Rendering' performance counters exceeding an average of 5ms"); break; case SubCommand.Summary: sb.AppendLine("Usage: pc summary "); sb.AppendLine(); sb.AppendLine("Displays the performance counter summary."); sb.AppendLine(); sb.AppendLine("Arguments:"); sb.AppendLine(" Optional. Defaults to 'important' if omitted. Specifies one of these modes:"); sb.AppendLine(" - all Displays performance counters from all collections"); sb.AppendLine(" - important Displays only important performance counter collections"); sb.AppendLine(); sb.AppendLine(" Optional. Only shows performance counter collections matching the given name"); sb.AppendLine(" Optional. Hides the actual execution time if it is below this threshold"); sb.AppendLine(); sb.AppendLine("Examples:"); sb.AppendLine("pc summary all Shows all events"); sb.AppendLine("pc summary all 5 Shows all events"); sb.AppendLine("pc summary Display.Rendering Shows only the 'Display.Rendering' collection"); break; case SubCommand.Trigger: sb.AppendLine("Usage: pc trigger "); sb.AppendLine("Usage: pc trigger collection "); sb.AppendLine("Usage: pc trigger collection "); sb.AppendLine(); sb.AppendLine("Manages alert triggers."); sb.AppendLine(); sb.AppendLine("Arguments:"); sb.AppendLine(" Optional. Specifies if a specific source or a specific collection should be triggered."); sb.AppendLine(" - list Lists current triggers"); sb.AppendLine(" - collection Sets up a trigger for a collection"); sb.AppendLine(" - clear Clears all trigger entries"); sb.AppendLine(" - pause Pauses triggering of alerts"); sb.AppendLine(" - resume Resumes triggering of alerts"); sb.AppendLine(" Defaults to 'list' if not specified."); sb.AppendLine(); sb.AppendLine(" Required if the mode 'collection' is specified."); sb.AppendLine(" Specifies the name of the collection to be triggered. Must be an exact match."); sb.AppendLine(); sb.AppendLine(" Optional. Specifies the name of a specific source. Must be an exact match."); sb.AppendLine(); sb.AppendLine(" Required if the mode 'collection' is specified."); sb.AppendLine(" Specifies the threshold in milliseconds (fractions allowed)."); sb.AppendLine(" Specify '0' to remove the threshold."); sb.AppendLine(); sb.AppendLine("Examples:"); sb.AppendLine(); sb.AppendLine("pc trigger collection Display.Rendering 10"); sb.AppendLine(" Sets up an alert trigger which writes on the console if the execution time of all performance counters in"); sb.AppendLine(" the 'Display.Rendering' collection exceed 10 milliseconds."); sb.AppendLine(); sb.AppendLine("pc trigger collection Display.Rendering 5 Pathoschild.ChestsAnywhere"); sb.AppendLine(" Sets up an alert trigger to write on the console if the execution time of Pathoschild.ChestsAnywhere in"); sb.AppendLine(" the 'Display.Rendering' collection exceed 5 milliseconds."); sb.AppendLine(); sb.AppendLine("pc trigger collection Display.Rendering 0"); sb.AppendLine(" Removes the threshold previously defined from the collection. Note that source-specific thresholds are left intact."); sb.AppendLine(); sb.AppendLine("pc trigger clear"); sb.AppendLine(" Clears all previously setup alert triggers."); break; case SubCommand.Reset: sb.AppendLine("Usage: pc reset "); sb.AppendLine(); sb.AppendLine("Resets performance counters."); sb.AppendLine(); sb.AppendLine("Arguments:"); sb.AppendLine(" Optional. Specifies if a collection or source should be reset."); sb.AppendLine(" If omitted, all performance counters are reset."); sb.AppendLine(); sb.AppendLine(" - source Clears performance counters for a specific source"); sb.AppendLine(" - collection Clears performance counters for a specific collection"); sb.AppendLine(); sb.AppendLine(" Required if a is given. Specifies the name of either the collection"); sb.AppendLine(" or the source. The name must be an exact match."); sb.AppendLine(); sb.AppendLine("Examples:"); sb.AppendLine("pc reset Resets all performance counters"); sb.AppendLine("pc reset source Pathoschild.ChestsAnywhere Resets all performance for the source named Pathoschild.ChestsAnywhere"); sb.AppendLine("pc reset collection Display.Rendering Resets all performance for the collection named Display.Rendering"); break; } sb.AppendLine(); monitor.Log(sb.ToString(), LogLevel.Info); } /// Get the command description. private static string GetDescription() { StringBuilder sb = new StringBuilder(); sb.AppendLine("Displays and configures performance counters."); sb.AppendLine(); sb.AppendLine("A performance counter records the invocation time of in-game events being"); sb.AppendLine("processed by mods or the game itself. See 'concepts' for a detailed explanation."); sb.AppendLine(); sb.AppendLine("Usage: pc "); sb.AppendLine(); sb.AppendLine("Commands:"); sb.AppendLine(); sb.AppendLine(" summary|sum|s Displays a summary of important or all collections"); sb.AppendLine(" detail|d Shows performance counter information for a given collection"); sb.AppendLine(" reset|r Resets the performance counters"); sb.AppendLine(" trigger Configures alert triggers"); sb.AppendLine(" examples Displays various examples"); sb.AppendLine(" concepts Displays an explanation of the performance counter concepts"); sb.AppendLine(" help Displays verbose help for the available commands"); sb.AppendLine(); sb.AppendLine("To get help for a specific command, use 'pc help ', for example:"); sb.AppendLine("pc help summary"); sb.AppendLine(); sb.AppendLine("Defaults to summary if no command is given."); sb.AppendLine(); return sb.ToString(); } } }