diff options
author | Drachenkaetzchen <felicia@drachenkatze.org> | 2020-01-11 15:45:45 +0100 |
---|---|---|
committer | Drachenkaetzchen <felicia@drachenkatze.org> | 2020-01-11 15:45:45 +0100 |
commit | 280dc911839f8996cddd9804f3f545cc38d20243 (patch) | |
tree | 20b351281ae16e1d43b761fd2eab3710a6fb8689 /src/SMAPI.Mods.ConsoleCommands | |
parent | 8a77373b18dbda77f268e8e7f772e950da60829f (diff) | |
download | SMAPI-280dc911839f8996cddd9804f3f545cc38d20243.tar.gz SMAPI-280dc911839f8996cddd9804f3f545cc38d20243.tar.bz2 SMAPI-280dc911839f8996cddd9804f3f545cc38d20243.zip |
Reworked the console implementation, added monitoring. Some internal refactoring.
Diffstat (limited to 'src/SMAPI.Mods.ConsoleCommands')
3 files changed, 598 insertions, 2 deletions
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ArgumentParser.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ArgumentParser.cs index 10007b42..40691a3e 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ArgumentParser.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ArgumentParser.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Globalization; using System.Linq; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands @@ -113,6 +114,51 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands return true; } + public bool IsDecimal(int index) + { + if (!this.TryGet(index, "", out string raw, false)) + return false; + + if (!decimal.TryParse(raw, NumberStyles.Number, CultureInfo.InvariantCulture, out decimal value)) + { + return false; + } + + 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> /// <returns>An enumerator that can be used to iterate through the collection.</returns> public IEnumerator<string> GetEnumerator() @@ -154,5 +200,22 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands else 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."); + } } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/PerformanceCounterCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/PerformanceCounterCommand.cs index b7e56359..84b9504e 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/PerformanceCounterCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/PerformanceCounterCommand.cs @@ -1,14 +1,543 @@ +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 { - internal class PerformanceCounterCommand: TrainerCommand + internal class PerformanceCounterCommand : TrainerCommand { - public PerformanceCounterCommand(string name, string description) : base("performance_counters", "Displays performance counters") + private readonly Dictionary<Command, string[]> CommandNames = new Dictionary<Command, string[]>() + { + {Command.Summary, new[] {"summary", "sum", "s"}}, + {Command.Detail, new[] {"detail", "d"}}, + {Command.Reset, new[] {"reset", "r"}}, + {Command.Monitor, new[] {"monitor"}}, + {Command.Examples, new[] {"examples"}}, + {Command.Concepts, new[] {"concepts"}}, + {Command.Help, new[] {"help"}}, + }; + + private enum Command + { + Summary, + Detail, + Reset, + Monitor, + Examples, + Help, + Concepts, + None + } + + public PerformanceCounterCommand() : base("pc", PerformanceCounterCommand.GetDescription()) { } public override void Handle(IMonitor monitor, string command, ArgumentParser args) { + if (args.TryGet(0, "command", out string subCommandString, false)) + { + Command subCommand = this.ParseCommandString(subCommandString); + + switch (subCommand) + { + case Command.Summary: + this.DisplayPerformanceCounterSummary(monitor, args); + break; + case Command.Detail: + this.DisplayPerformanceCounterDetail(monitor, args); + break; + case Command.Reset: + this.ResetCounter(monitor, args); + break; + case Command.Monitor: + this.HandleMonitor(monitor, args); + break; + case Command.Examples: + break; + case Command.Concepts: + this.ShowHelp(monitor, Command.Concepts); + break; + case Command.Help: + args.TryGet(1, "command", out string commandString, true); + + var helpCommand = this.ParseCommandString(commandString); + this.ShowHelp(monitor, helpCommand); + break; + default: + this.LogUsageError(monitor, $"Unknown command {subCommandString}"); + break; + } + } + else + { + this.DisplayPerformanceCounterSummary(monitor, args); + } + } + + private Command ParseCommandString(string command) + { + foreach (var i in this.CommandNames.Where(i => i.Value.Any(str => str.Equals(command, StringComparison.InvariantCultureIgnoreCase)))) + { + return i.Key; + } + + return Command.None; + } + + private void HandleMonitor(IMonitor monitor, ArgumentParser args) + { + if (args.TryGet(1, "mode", out string mode, false)) + { + switch (mode) + { + case "list": + this.ListMonitors(monitor); + break; + case "collection": + args.TryGet(2, "name", out string collectionName); + decimal threshold = 0; + if (args.IsDecimal(3) && args.TryGetDecimal(3, "threshold", out threshold, false)) + { + this.SetCollectionMonitor(monitor, collectionName, null, (double)threshold); + } else if (args.TryGet(3, "source", out string source)) + { + if (args.TryGetDecimal(4, "threshold", out threshold)) + { + this.SetCollectionMonitor(monitor, collectionName, source, (double) threshold); + } + } + break; + case "clear": + this.ClearMonitors(monitor); + break; + default: + monitor.Log($"Unknown mode {mode}. See 'pc help monitor' for usage."); + break; + } + + } + else + { + this.ListMonitors(monitor); + } + } + + private void SetCollectionMonitor(IMonitor monitor, string collectionName, string sourceName, double threshold) + { + foreach (PerformanceCounterCollection collection in SCore.PerformanceCounterManager.PerformanceCounterCollections) + { + if (collection.Name.ToLowerInvariant().Equals(collectionName.ToLowerInvariant())) + { + if (sourceName == null) + { + collection.Monitor = true; + collection.MonitorThresholdMilliseconds = threshold; + monitor.Log($"Set up monitor for '{collectionName}' with '{this.FormatMilliseconds(threshold)}'", LogLevel.Info); + return; + } + else + { + foreach (var performanceCounter in collection.PerformanceCounters) + { + if (performanceCounter.Value.Source.ToLowerInvariant().Equals(sourceName.ToLowerInvariant())) + { + performanceCounter.Value.Monitor = true; + performanceCounter.Value.MonitorThresholdMilliseconds = threshold; + monitor.Log($"Set up monitor for '{sourceName}' in collection '{collectionName}' with '{this.FormatMilliseconds(threshold)}", LogLevel.Info); + 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); + } + + + private void ClearMonitors(IMonitor monitor) + { + int clearedCounters = 0; + foreach (PerformanceCounterCollection collection in SCore.PerformanceCounterManager.PerformanceCounterCollections) + { + if (collection.Monitor) + { + collection.Monitor = false; + clearedCounters++; + } + + foreach (var performanceCounter in collection.PerformanceCounters) + { + if (performanceCounter.Value.Monitor) + { + performanceCounter.Value.Monitor = false; + clearedCounters++; + } + } + + } + + monitor.Log($"Cleared {clearedCounters} counters.", LogLevel.Info); + } + + private void ListMonitors(IMonitor monitor) + { + StringBuilder sb = new StringBuilder(); + sb.AppendLine(); + sb.AppendLine(); + var collectionMonitors = new List<(string collectionName, double threshold)>(); + var sourceMonitors = new List<(string collectionName, string sourceName, double threshold)>(); + + foreach (PerformanceCounterCollection collection in SCore.PerformanceCounterManager.PerformanceCounterCollections) + { + if (collection.Monitor) + { + collectionMonitors.Add((collection.Name, collection.MonitorThresholdMilliseconds)); + } + + sourceMonitors.AddRange(from performanceCounter in + collection.PerformanceCounters where performanceCounter.Value.Monitor + select (collection.Name, performanceCounter.Value.Source, performanceCounter.Value.MonitorThresholdMilliseconds)); + } + + if (collectionMonitors.Count > 0) + { + sb.AppendLine("Collection Monitors:"); + sb.AppendLine(); + sb.AppendLine(this.GetTableString( + data: collectionMonitors, + header: new[] {"Collection", "Threshold"}, + getRow: item => new[] + { + item.collectionName, + this.FormatMilliseconds(item.threshold) + } + )); + + sb.AppendLine(); + + + } + + if (sourceMonitors.Count > 0) + { + sb.AppendLine("Source Monitors:"); + sb.AppendLine(); + sb.AppendLine(this.GetTableString( + data: sourceMonitors, + header: new[] {"Collection", "Source", "Threshold"}, + getRow: item => new[] + { + item.collectionName, + item.sourceName, + this.FormatMilliseconds(item.threshold) + } + )); + + sb.AppendLine(); + } + + monitor.Log(sb.ToString(), LogLevel.Info); + } + + private void ShowHelp(IMonitor monitor, Command command) + { + StringBuilder sb = new StringBuilder(); + sb.AppendLine(); + switch (command) + { + case Command.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 Command.Detail: + sb.AppendLine("Usage: pc detail <collection> <source>"); + sb.AppendLine(" pc detail <collection> <threshold>"); + sb.AppendLine(); + sb.AppendLine("Displays details for a specific collection."); + sb.AppendLine(); + sb.AppendLine("Arguments:"); + sb.AppendLine(" <collection> Required. The full or partial name of the collection to display."); + sb.AppendLine(" <source> Optional. The full or partial name of the source."); + sb.AppendLine(" <threshold> 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 Command.Summary: + sb.AppendLine("Usage: pc summary <mode|name>"); + sb.AppendLine(); + sb.AppendLine("Displays the performance counter summary."); + sb.AppendLine(); + sb.AppendLine("Arguments:"); + sb.AppendLine(" <mode> 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(" <name> Optional. Only shows performance counter collections matching the given name"); + sb.AppendLine(); + sb.AppendLine("Examples:"); + sb.AppendLine("pc summary all Shows all events"); + sb.AppendLine("pc summary Display.Rendering Shows only the 'Display.Rendering' collection"); + break; + case Command.Monitor: + sb.AppendLine("Usage: pc monitor <mode> <collectionName> <threshold>"); + sb.AppendLine("Usage: pc monitor <mode> <collectionName> <sourceName> <threshold>"); + sb.AppendLine(); + sb.AppendLine("Manages monitoring settings."); + sb.AppendLine(); + sb.AppendLine("Arguments:"); + sb.AppendLine(" <mode> Optional. Specifies if a specific source or a specific collection should be monitored."); + sb.AppendLine(" - list Lists current monitoring settings"); + sb.AppendLine(" - collection Sets up a monitor for a collection"); + sb.AppendLine(" - clear Clears all monitoring entries"); + sb.AppendLine(" Defaults to 'list' if not specified."); + sb.AppendLine(); + sb.AppendLine(" <collectionName> Required if the mode 'collection' is specified."); + sb.AppendLine(" Specifies the name of the collection to be monitored. Must be an exact match."); + sb.AppendLine(); + sb.AppendLine(" <sourceName> Optional. Specifies the name of a specific source. Must be an exact match."); + sb.AppendLine(); + sb.AppendLine(" <threshold> Required if the mode 'collection' is specified."); + sb.AppendLine(" Specifies the threshold in milliseconds (fractions allowed)."); + sb.AppendLine(" Can also be 'remove' to remove the threshold."); + sb.AppendLine(); + sb.AppendLine("Examples:"); + sb.AppendLine(); + sb.AppendLine("pc monitor collection Display.Rendering 10"); + sb.AppendLine(" Sets up monitoring to write an alert 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 monitor collection Display.Rendering Pathoschild.ChestsAnywhere 5"); + sb.AppendLine(" Sets up monitoring to write an alert 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 monitor collection Display.Rendering remove"); + sb.AppendLine(" Removes the threshold previously defined from the collection. Note that source-specific thresholds are left intact."); + sb.AppendLine(); + sb.AppendLine("pc monitor clear"); + sb.AppendLine(" Clears all previously setup monitors."); + break; + case Command.Reset: + sb.AppendLine("Usage: pc reset <type> <name>"); + sb.AppendLine(); + sb.AppendLine("Resets performance counters."); + sb.AppendLine(); + sb.AppendLine("Arguments:"); + sb.AppendLine(" <type> 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(" <name> Required if a <type> 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); + } + + private void ResetCounter(IMonitor monitor, ArgumentParser args) + { + if (args.TryGet(1, "type", out string type, false)) + { + args.TryGet(2, "name", out string name); + + switch (type) + { + case "category": + SCore.PerformanceCounterManager.ResetCategory(name); + monitor.Log($"All performance counters for category {name} are now cleared.", LogLevel.Info); + break; + case "mod": + SCore.PerformanceCounterManager.ResetSource(name); + monitor.Log($"All performance counters for mod {name} are now cleared.", LogLevel.Info); + break; + } + } + else + { + SCore.PerformanceCounterManager.Reset(); + monitor.Log("All performance counters are now cleared.", LogLevel.Info); + } + } + + private void DisplayPerformanceCounterSummary(IMonitor monitor, ArgumentParser args) + { + IEnumerable<PerformanceCounterCollection> 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; + } + + 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()), + this.FormatMilliseconds(item.GetModsAverageExecutionTime()), + this.FormatMilliseconds(item.GetAverageExecutionTime()) + } + )); + + monitor.Log(sb.ToString(), LogLevel.Info); + } + + private void DisplayPerformanceCounterDetail(IMonitor monitor, ArgumentParser args) + { + List<PerformanceCounterCollection> collections = new List<PerformanceCounterCollection>(); + 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 (var c in collections) + { + this.DisplayPerformanceCollectionDetail(monitor, c, averageInterval, thresholdMilliseconds, sourceFilter); + } + } + + private void DisplayPerformanceCollectionDetail(IMonitor monitor, PerformanceCounterCollection collection, + TimeSpan averageInterval, double? thresholdMilliseconds, string sourceFilter = null) + { + StringBuilder sb = new StringBuilder($"Performance Counter for {collection.Name}:\n\n"); + + IEnumerable<KeyValuePair<string, PerformanceCounter>> data = collection.PerformanceCounters; + + if (sourceFilter != null) + { + data = collection.PerformanceCounters.Where(p => + p.Value.Source.ToLowerInvariant().Contains(sourceFilter.ToLowerInvariant())); + } + + if (thresholdMilliseconds != null) + { + data = data.Where(p => p.Value.GetAverage(averageInterval) >= thresholdMilliseconds); + } + + 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()?.Elapsed.TotalMilliseconds), + this.FormatMilliseconds(item.Value.GetPeak()?.Elapsed.TotalMilliseconds) + } + )); + + monitor.Log(sb.ToString(), LogLevel.Info); + } + + private string FormatMilliseconds(double? milliseconds, double? thresholdMilliseconds = null) + { + if (milliseconds == null || (thresholdMilliseconds != null && milliseconds < thresholdMilliseconds)) + { + return "-"; + } + + return ((double) milliseconds).ToString("F2"); + } + + /// <summary>Get the command description.</summary> + private static string GetDescription() + { + StringBuilder sb = new StringBuilder(); + + sb.AppendLine("Displays and configured 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 <command> <action>"); + 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(" monitor Configures monitoring settings"); + 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 <command>', for example:"); + sb.AppendLine("pc help summary"); + sb.AppendLine(); + sb.AppendLine("Defaults to summary if no command is given."); + sb.AppendLine(); + return sb.ToString(); } } } diff --git a/src/SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj b/src/SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj index ce35bf73..f073ac21 100644 --- a/src/SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj +++ b/src/SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj @@ -67,6 +67,10 @@ </None> </ItemGroup> + <ItemGroup> + <PackageReference Include="System.ValueTuple" Version="4.5.0" /> + </ItemGroup> + <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> <Import Project="..\..\build\common.targets" /> |