diff options
Diffstat (limited to 'src')
83 files changed, 2726 insertions, 556 deletions
diff --git a/src/SMAPI.Installer/Framework/InstallerPaths.cs b/src/SMAPI.Installer/Framework/InstallerPaths.cs index 9393e14f..ac6c3a8e 100644 --- a/src/SMAPI.Installer/Framework/InstallerPaths.cs +++ b/src/SMAPI.Installer/Framework/InstallerPaths.cs @@ -8,6 +8,9 @@ namespace StardewModdingAPI.Installer.Framework /********* ** Accessors *********/ + /**** + ** Main folders + ****/ /// <summary>The directory path containing the files to copy into the game folder.</summary> public DirectoryInfo BundleDir { get; } @@ -17,9 +20,18 @@ namespace StardewModdingAPI.Installer.Framework /// <summary>The directory into which to install mods.</summary> public DirectoryInfo ModsDir { get; } + /**** + ** Installer paths + ****/ /// <summary>The full path to directory path containing the files to copy into the game folder.</summary> public string BundlePath => this.BundleDir.FullName; + /// <summary>The full path to the backup API user settings folder, if applicable.</summary> + public string BundleApiUserConfigPath { get; } + + /**** + ** Game paths + ****/ /// <summary>The full path to the directory containing the installed game.</summary> public string GamePath => this.GameDir.FullName; @@ -29,6 +41,9 @@ namespace StardewModdingAPI.Installer.Framework /// <summary>The full path to SMAPI's internal configuration file.</summary> public string ApiConfigPath { get; } + /// <summary>The full path to the user's config overrides file.</summary> + public string ApiUserConfigPath { get; } + /// <summary>The full path to the installed SMAPI executable file.</summary> public string ExecutablePath { get; } @@ -55,11 +70,14 @@ namespace StardewModdingAPI.Installer.Framework this.GameDir = gameDir; this.ModsDir = new DirectoryInfo(Path.Combine(gameDir.FullName, "Mods")); + this.BundleApiUserConfigPath = Path.Combine(bundleDir.FullName, "smapi-internal", "config.user.json"); + this.ExecutablePath = Path.Combine(gameDir.FullName, gameExecutableName); this.UnixLauncherPath = Path.Combine(gameDir.FullName, "StardewValley"); this.UnixSmapiLauncherPath = Path.Combine(gameDir.FullName, "StardewModdingAPI"); this.UnixBackupLauncherPath = Path.Combine(gameDir.FullName, "StardewValley-original"); this.ApiConfigPath = Path.Combine(gameDir.FullName, "smapi-internal", "config.json"); + this.ApiUserConfigPath = Path.Combine(gameDir.FullName, "smapi-internal", "config.user.json"); } } } diff --git a/src/SMAPI.Installer/InteractiveInstaller.cs b/src/SMAPI.Installer/InteractiveInstaller.cs index 964300ac..2d58baf0 100644 --- a/src/SMAPI.Installer/InteractiveInstaller.cs +++ b/src/SMAPI.Installer/InteractiveInstaller.cs @@ -352,6 +352,12 @@ namespace StardewModdingApi.Installer Console.WriteLine(); /**** + ** Back up user settings + ****/ + if (File.Exists(paths.ApiUserConfigPath)) + File.Copy(paths.ApiUserConfigPath, paths.BundleApiUserConfigPath); + + /**** ** Always uninstall old files ****/ // restore game launcher @@ -373,6 +379,21 @@ namespace StardewModdingApi.Installer this.InteractivelyDelete(path); } + // move global save data folder (changed in 3.2) + { + string dataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley"); + DirectoryInfo oldDir = new DirectoryInfo(Path.Combine(dataPath, "Saves", ".smapi")); + DirectoryInfo newDir = new DirectoryInfo(Path.Combine(dataPath, ".smapi")); + + if (oldDir.Exists) + { + if (newDir.Exists) + this.InteractivelyDelete(oldDir.FullName); + else + oldDir.MoveTo(newDir.FullName); + } + } + /**** ** Install new files ****/ diff --git a/src/SMAPI.Installer/SMAPI.Installer.csproj b/src/SMAPI.Installer/SMAPI.Installer.csproj index 3f01c8fe..79e19d89 100644 --- a/src/SMAPI.Installer/SMAPI.Installer.csproj +++ b/src/SMAPI.Installer/SMAPI.Installer.csproj @@ -8,7 +8,6 @@ <LangVersion>latest</LangVersion> <OutputType>Exe</OutputType> <PlatformTarget>x86</PlatformTarget> - <OutputPath>$(SolutionDir)\..\bin\$(Configuration)\Installer</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> </PropertyGroup> @@ -17,19 +16,7 @@ </ItemGroup> <ItemGroup> - <None Update="README.txt"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </None> - <None Include="windows-exe-config.xml"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </None> - <None Include="windows-install.bat"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </None> - <None Include="unix-install.sh"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </None> - <None Include="unix-launcher.sh"> + <None Update="assets\*"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> </ItemGroup> diff --git a/src/SMAPI.Installer/README.txt b/src/SMAPI.Installer/assets/README.txt index 0da49a46..0da49a46 100644 --- a/src/SMAPI.Installer/README.txt +++ b/src/SMAPI.Installer/assets/README.txt diff --git a/src/SMAPI.Installer/assets/System.Numerics.dll b/src/SMAPI.Installer/assets/System.Numerics.dll Binary files differnew file mode 100644 index 00000000..fed0f92c --- /dev/null +++ b/src/SMAPI.Installer/assets/System.Numerics.dll diff --git a/src/SMAPI.Installer/assets/System.Runtime.Caching.dll b/src/SMAPI.Installer/assets/System.Runtime.Caching.dll Binary files differnew file mode 100644 index 00000000..a062391d --- /dev/null +++ b/src/SMAPI.Installer/assets/System.Runtime.Caching.dll diff --git a/src/SMAPI.Installer/unix-install.sh b/src/SMAPI.Installer/assets/unix-install.sh index 6d0c86ce..6d0c86ce 100644 --- a/src/SMAPI.Installer/unix-install.sh +++ b/src/SMAPI.Installer/assets/unix-install.sh diff --git a/src/SMAPI.Installer/unix-launcher.sh b/src/SMAPI.Installer/assets/unix-launcher.sh index b72eed22..b72eed22 100644 --- a/src/SMAPI.Installer/unix-launcher.sh +++ b/src/SMAPI.Installer/assets/unix-launcher.sh diff --git a/src/SMAPI.Installer/windows-exe-config.xml b/src/SMAPI.Installer/assets/windows-exe-config.xml index 386c7f1a..386c7f1a 100644 --- a/src/SMAPI.Installer/windows-exe-config.xml +++ b/src/SMAPI.Installer/assets/windows-exe-config.xml diff --git a/src/SMAPI.Installer/windows-install.bat b/src/SMAPI.Installer/assets/windows-install.bat index d02dd4c6..d02dd4c6 100644 --- a/src/SMAPI.Installer/windows-install.bat +++ b/src/SMAPI.Installer/assets/windows-install.bat diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj b/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj index 7e3ce7d4..c1d5626f 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj @@ -9,7 +9,10 @@ <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.10.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.4.0" /> <PackageReference Include="NUnit" Version="3.12.0" /> - <PackageReference Include="NUnit3TestAdapter" Version="3.15.1" /> + <PackageReference Include="NUnit3TestAdapter" Version="3.16.1"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> </ItemGroup> <ItemGroup> diff --git a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs index a852f133..f0363a3e 100644 --- a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs +++ b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs @@ -156,6 +156,9 @@ namespace StardewModdingAPI.ModBuildConfig.Framework // release zips this.EqualsInvariant(file.Extension, ".zip") + // Harmony (bundled into SMAPI) + || this.EqualsInvariant(file.Name, "0Harmony.dll") + // Json.NET (bundled into SMAPI) || this.EqualsInvariant(file.Name, "Newtonsoft.Json.dll") || this.EqualsInvariant(file.Name, "Newtonsoft.Json.pdb") diff --git a/src/SMAPI.ModBuildConfig/package.nuspec b/src/SMAPI.ModBuildConfig/package.nuspec index 846f438d..afb03cec 100644 --- a/src/SMAPI.ModBuildConfig/package.nuspec +++ b/src/SMAPI.ModBuildConfig/package.nuspec @@ -2,7 +2,7 @@ <package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd"> <metadata> <id>Pathoschild.Stardew.ModBuildConfig</id> - <version>3.0.0</version> + <version>3.1.0</version> <title>Build package for SMAPI mods</title> <authors>Pathoschild</authors> <owners>Pathoschild</owners> @@ -14,18 +14,9 @@ <iconUrl>https://raw.githubusercontent.com/Pathoschild/SMAPI/develop/src/SMAPI.ModBuildConfig/assets/nuget-icon.png</iconUrl> <description>Automates the build configuration for crossplatform Stardew Valley SMAPI mods. For SMAPI 3.0 or later.</description> <releaseNotes> - 3.0.0: - - Updated for SMAPI 3.0 and Stardew Valley 1.4. - - Added automatic support for 'assets' folders. - - Added $(GameExecutableName) MSBuild variable. - - Added support for projects using the simplified .csproj format. - - Added option to disable game debugging config. - - Added .pdb files to builds by default (to enable line numbers in error stack traces). - - Added optional Harmony reference. - - Fixed Newtonsoft.Json.pdb included in release zips when Json.NET is referenced directly. - - Fixed <IgnoreModFilePatterns> not working for i18n files. - - Dropped support for older versions of SMAPI and Visual Studio. - - Migrated package icon to NuGet's new format. + 3.1.0: + - Added support for semantic versioning 2.0. + - 0Harmony.dll is now ignored if the mod references it directly (it's bundled with SMAPI). </releaseNotes> </metadata> <files> diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ArgumentParser.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ArgumentParser.cs index 10007b42..9c7082c9 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; using System.Collections; using System.Collections.Generic; +using System.Globalization; using System.Linq; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands @@ -31,13 +32,6 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands /// <param name="index">The zero-based index of the element to get.</param> public string this[int index] => this.Args[index]; - /// <summary>A method which parses a string argument into the given value.</summary> - /// <typeparam name="T">The expected argument type.</typeparam> - /// <param name="input">The argument to parse.</param> - /// <param name="output">The parsed value.</param> - /// <returns>Returns whether the argument was successfully parsed.</returns> - public delegate bool ParseDelegate<T>(string input, out T output); - /********* ** Public methods @@ -113,6 +107,38 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands 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 +180,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/ITrainerCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ITrainerCommand.cs index a0b739f8..d4d36e5d 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ITrainerCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ITrainerCommand.cs @@ -12,8 +12,11 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands /// <summary>The command description.</summary> string Description { get; } - /// <summary>Whether the command needs to perform logic when the game updates.</summary> - bool NeedsUpdate { get; } + /// <summary>Whether the command may need to perform logic when the game updates. This value shouldn't change.</summary> + bool MayNeedUpdate { get; } + + /// <summary>Whether the command may need to perform logic when the player presses a button. This value shouldn't change.</summary> + bool MayNeedInput { get; } /********* @@ -27,6 +30,11 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands /// <summary>Perform any logic needed on update tick.</summary> /// <param name="monitor">Writes messages to the console and log file.</param> - void Update(IMonitor monitor); + void OnUpdated(IMonitor monitor); + + /// <summary>Perform any logic when input is received.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="button">The button that was pressed.</param> + void OnButtonPressed(IMonitor monitor, SButton button); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/PerformanceCounterCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/PerformanceCounterCommand.cs new file mode 100644 index 00000000..63851c9d --- /dev/null +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/PerformanceCounterCommand.cs @@ -0,0 +1,647 @@ +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; + } + } + } +} diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/TestInputCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/TestInputCommand.cs new file mode 100644 index 00000000..11aa10c3 --- /dev/null +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/TestInputCommand.cs @@ -0,0 +1,59 @@ +using System; + +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other +{ + /// <summary>A command which logs the keys being pressed for 30 seconds once enabled.</summary> + internal class TestInputCommand : TrainerCommand + { + /********* + ** Fields + *********/ + /// <summary>The number of seconds for which to log input.</summary> + private readonly int LogSeconds = 30; + + /// <summary>When the command should stop printing input, or <c>null</c> if currently disabled.</summary> + private long? ExpiryTicks; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public TestInputCommand() + : base("test_input", "Prints all input to the console for 30 seconds.", mayNeedUpdate: true, mayNeedInput: true) { } + + /// <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) + { + this.ExpiryTicks = DateTime.UtcNow.Add(TimeSpan.FromSeconds(this.LogSeconds)).Ticks; + monitor.Log($"OK, logging all player input for {this.LogSeconds} seconds.", LogLevel.Info); + } + + /// <summary>Perform any logic needed on update tick.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + public override void OnUpdated(IMonitor monitor) + { + // handle expiry + if (this.ExpiryTicks == null) + return; + if (this.ExpiryTicks <= DateTime.UtcNow.Ticks) + { + monitor.Log("No longer logging input.", LogLevel.Info); + this.ExpiryTicks = null; + return; + } + } + + /// <summary>Perform any logic when input is received.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="button">The button that was pressed.</param> + public override void OnButtonPressed(IMonitor monitor, SButton button) + { + if (this.ExpiryTicks != null) + monitor.Log($"Pressed {button}", LogLevel.Info); + } + } +} diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs index 1abb82b5..59bda5dd 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System.Linq; using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player @@ -14,18 +14,11 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player /********* - ** Accessors - *********/ - /// <summary>Whether the command needs to perform logic when the game updates.</summary> - public override bool NeedsUpdate => this.InfiniteHealth; - - - /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> public SetHealthCommand() - : base("player_sethealth", "Sets the player's health.\n\nUsage: player_sethealth [value]\n- value: an integer amount, or 'inf' for infinite health.") { } + : base("player_sethealth", "Sets the player's health.\n\nUsage: player_sethealth [value]\n- value: an integer amount, or 'inf' for infinite health.", mayNeedUpdate: true) { } /// <summary>Handle the command.</summary> /// <param name="monitor">Writes messages to the console and log file.</param> @@ -62,9 +55,9 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player /// <summary>Perform any logic needed on update tick.</summary> /// <param name="monitor">Writes messages to the console and log file.</param> - public override void Update(IMonitor monitor) + public override void OnUpdated(IMonitor monitor) { - if (this.InfiniteHealth) + if (this.InfiniteHealth && Context.IsWorldReady) Game1.player.health = Game1.player.maxHealth; } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs index 1706bbc1..6e3d68b6 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs @@ -14,18 +14,11 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player /********* - ** Accessors - *********/ - /// <summary>Whether the command needs to perform logic when the game updates.</summary> - public override bool NeedsUpdate => this.InfiniteMoney; - - - /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> public SetMoneyCommand() - : base("player_setmoney", "Sets the player's money.\n\nUsage: player_setmoney <value>\n- value: an integer amount, or 'inf' for infinite money.") { } + : base("player_setmoney", "Sets the player's money.\n\nUsage: player_setmoney <value>\n- value: an integer amount, or 'inf' for infinite money.", mayNeedUpdate: true) { } /// <summary>Handle the command.</summary> /// <param name="monitor">Writes messages to the console and log file.</param> @@ -62,9 +55,9 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player /// <summary>Perform any logic needed on update tick.</summary> /// <param name="monitor">Writes messages to the console and log file.</param> - public override void Update(IMonitor monitor) + public override void OnUpdated(IMonitor monitor) { - if (this.InfiniteMoney) + if (this.InfiniteMoney && Context.IsWorldReady) Game1.player.Money = 999999; } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs index 009cb1de..60a1dcb1 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System.Linq; using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player @@ -14,18 +14,11 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player /********* - ** Accessors - *********/ - /// <summary>Whether the command needs to perform logic when the game updates.</summary> - public override bool NeedsUpdate => this.InfiniteStamina; - - - /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> public SetStaminaCommand() - : base("player_setstamina", "Sets the player's stamina.\n\nUsage: player_setstamina [value]\n- value: an integer amount, or 'inf' for infinite stamina.") { } + : base("player_setstamina", "Sets the player's stamina.\n\nUsage: player_setstamina [value]\n- value: an integer amount, or 'inf' for infinite stamina.", mayNeedUpdate: true) { } /// <summary>Handle the command.</summary> /// <param name="monitor">Writes messages to the console and log file.</param> @@ -62,9 +55,9 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player /// <summary>Perform any logic needed on update tick.</summary> /// <param name="monitor">Writes messages to the console and log file.</param> - public override void Update(IMonitor monitor) + public override void OnUpdated(IMonitor monitor) { - if (this.InfiniteStamina) + if (this.InfiniteStamina && Context.IsWorldReady) Game1.player.stamina = Game1.player.MaxStamina; } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/TrainerCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/TrainerCommand.cs index 466b8f6e..77a26c6a 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/TrainerCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/TrainerCommand.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; @@ -16,8 +16,11 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands /// <summary>The command description.</summary> public string Description { get; } - /// <summary>Whether the command needs to perform logic when the game updates.</summary> - public virtual bool NeedsUpdate { get; } = false; + /// <summary>Whether the command may need to perform logic when the player presses a button. This value shouldn't change.</summary> + public bool MayNeedInput { get; } + + /// <summary>Whether the command may need to perform logic when the game updates. This value shouldn't change.</summary> + public bool MayNeedUpdate { get; } /********* @@ -31,7 +34,12 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands /// <summary>Perform any logic needed on update tick.</summary> /// <param name="monitor">Writes messages to the console and log file.</param> - public virtual void Update(IMonitor monitor) { } + public virtual void OnUpdated(IMonitor monitor) { } + + /// <summary>Perform any logic when input is received.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="button">The button that was pressed.</param> + public virtual void OnButtonPressed(IMonitor monitor, SButton button) { } /********* @@ -40,10 +48,14 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands /// <summary>Construct an instance.</summary> /// <param name="name">The command name the user must type.</param> /// <param name="description">The command description.</param> - protected TrainerCommand(string name, string description) + /// <param name="mayNeedInput">Whether the command may need to perform logic when the player presses a button.</param> + /// <param name="mayNeedUpdate">Whether the command may need to perform logic when the game updates.</param> + protected TrainerCommand(string name, string description, bool mayNeedInput = false, bool mayNeedUpdate = false) { this.Name = name; this.Description = description; + this.MayNeedInput = mayNeedInput; + this.MayNeedUpdate = mayNeedUpdate; } /// <summary>Log an error indicating incorrect usage.</summary> @@ -66,7 +78,8 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands /// <param name="data">The data to display.</param> /// <param name="header">The table header.</param> /// <param name="getRow">Returns a set of fields for a data value.</param> - protected string GetTableString<T>(IEnumerable<T> data, string[] header, Func<T, string[]> getRow) + /// <param name="rightAlign">Whether to right-align the data.</param> + protected string GetTableString<T>(IEnumerable<T> data, string[] header, Func<T, string[]> getRow, bool rightAlign = false) { // get table data int[] widths = header.Select(p => p.Length).ToArray(); @@ -94,8 +107,9 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands return string.Join( Environment.NewLine, - lines.Select(line => string.Join(" | ", line.Select((field, i) => field.PadRight(widths[i], ' ')).ToArray()) - ) + lines.Select(line => string.Join(" | ", + line.Select((field, i) => rightAlign ? field.PadRight(widths[i], ' ') : field.PadLeft(widths[i], ' ')) + )) ); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/FreezeTimeCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/FreezeTimeCommand.cs index 6a7ab162..736a93a0 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/FreezeTimeCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/FreezeTimeCommand.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System.Linq; using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World @@ -17,18 +17,11 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World /********* - ** Accessors - *********/ - /// <summary>Whether the command needs to perform logic when the game updates.</summary> - public override bool NeedsUpdate => this.FreezeTime; - - - /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> public FreezeTimeCommand() - : base("world_freezetime", "Freezes or resumes time.\n\nUsage: world_freezetime [value]\n- value: one of 0 (resume), 1 (freeze), or blank (toggle).") { } + : base("world_freezetime", "Freezes or resumes time.\n\nUsage: world_freezetime [value]\n- value: one of 0 (resume), 1 (freeze), or blank (toggle).", mayNeedUpdate: true) { } /// <summary>Handle the command.</summary> /// <param name="monitor">Writes messages to the console and log file.</param> @@ -57,9 +50,9 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World /// <summary>Perform any logic needed on update tick.</summary> /// <param name="monitor">Writes messages to the console and log file.</param> - public override void Update(IMonitor monitor) + public override void OnUpdated(IMonitor monitor) { - if (this.FreezeTime) + if (this.FreezeTime && Context.IsWorldReady) Game1.timeOfDay = FreezeTimeCommand.FrozenTime; } } diff --git a/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs b/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs index 4807c46d..5c4f3bba 100644 --- a/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs +++ b/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using StardewModdingAPI.Events; using StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands; namespace StardewModdingAPI.Mods.ConsoleCommands @@ -14,6 +15,12 @@ namespace StardewModdingAPI.Mods.ConsoleCommands /// <summary>The commands to handle.</summary> private ITrainerCommand[] Commands; + /// <summary>The commands which may need to handle update ticks.</summary> + private ITrainerCommand[] UpdateHandlers; + + /// <summary>The commands which may need to handle input.</summary> + private ITrainerCommand[] InputHandlers; + /********* ** Public methods @@ -27,27 +34,35 @@ namespace StardewModdingAPI.Mods.ConsoleCommands foreach (ITrainerCommand command in this.Commands) helper.ConsoleCommands.Add(command.Name, command.Description, (name, args) => this.HandleCommand(command, name, args)); + // cache commands + this.InputHandlers = this.Commands.Where(p => p.MayNeedInput).ToArray(); + this.UpdateHandlers = this.Commands.Where(p => p.MayNeedUpdate).ToArray(); + // hook events helper.Events.GameLoop.UpdateTicked += this.OnUpdateTicked; + helper.Events.Input.ButtonPressed += this.OnButtonPressed; } /********* ** Private methods *********/ + /// <summary>The method invoked when a button is pressed.</summary> + /// <param name="sender">The event sender.</param> + /// <param name="e">The event arguments.</param> + private void OnButtonPressed(object sender, ButtonPressedEventArgs e) + { + foreach (ITrainerCommand command in this.InputHandlers) + command.OnButtonPressed(this.Monitor, e.Button); + } + /// <summary>The method invoked when the game updates its state.</summary> /// <param name="sender">The event sender.</param> /// <param name="e">The event arguments.</param> private void OnUpdateTicked(object sender, EventArgs e) { - if (!Context.IsWorldReady) - return; - - foreach (ITrainerCommand command in this.Commands) - { - if (command.NeedsUpdate) - command.Update(this.Monitor); - } + foreach (ITrainerCommand command in this.UpdateHandlers) + command.OnUpdated(this.Monitor); } /// <summary>Handle a console command.</summary> diff --git a/src/SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj b/src/SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj index ce35bf73..526d406b 100644 --- a/src/SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj +++ b/src/SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj @@ -5,7 +5,6 @@ <RootNamespace>StardewModdingAPI.Mods.ConsoleCommands</RootNamespace> <TargetFramework>net45</TargetFramework> <LangVersion>latest</LangVersion> - <OutputPath>$(SolutionDir)\..\bin\$(Configuration)\Mods\ConsoleCommands</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <PlatformTarget>x86</PlatformTarget> </PropertyGroup> diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json index 1e12e13e..0d0e4901 100644 --- a/src/SMAPI.Mods.ConsoleCommands/manifest.json +++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json @@ -1,9 +1,9 @@ { "Name": "Console Commands", "Author": "SMAPI", - "Version": "3.1.0", + "Version": "3.2.0", "Description": "Adds SMAPI console commands that let you manipulate the game.", "UniqueID": "SMAPI.ConsoleCommands", "EntryDll": "ConsoleCommands.dll", - "MinimumApiVersion": "3.1.0" + "MinimumApiVersion": "3.2.0" } diff --git a/src/SMAPI.Mods.SaveBackup/ModEntry.cs b/src/SMAPI.Mods.SaveBackup/ModEntry.cs index 3b47759b..8b139d8f 100644 --- a/src/SMAPI.Mods.SaveBackup/ModEntry.cs +++ b/src/SMAPI.Mods.SaveBackup/ModEntry.cs @@ -68,22 +68,21 @@ namespace StardewModdingAPI.Mods.SaveBackup if (targetFile.Exists || fallbackDir.Exists) return; - // back up saves - this.Monitor.Log($"Backing up saves to {targetFile.FullName}...", LogLevel.Trace); - if (!this.TryCompress(Constants.SavesPath, targetFile, out Exception compressError)) + // copy saves to fallback directory (ignore non-save files/folders) + this.Monitor.Log($"Backing up saves to {fallbackDir.FullName}...", LogLevel.Trace); + DirectoryInfo savesDir = new DirectoryInfo(Constants.SavesPath); + this.RecursiveCopy(savesDir, fallbackDir, entry => this.MatchSaveFolders(savesDir, entry), copyRoot: false); + + // compress backup if possible + this.Monitor.Log("Compressing backup if possible...", LogLevel.Trace); + if (!this.TryCompress(fallbackDir.FullName, targetFile, out Exception compressError)) { - // log error (expected on Android due to missing compression DLLs) - if (Constants.TargetPlatform == GamePlatform.Android) - this.Monitor.VerboseLog($"Compression isn't supported on Android:\n{compressError}"); - else - { - this.Monitor.Log("Couldn't zip the save backup, creating uncompressed backup instead.", LogLevel.Debug); - this.Monitor.Log(compressError.ToString(), LogLevel.Trace); - } - - // fallback to uncompressed - this.RecursiveCopy(new DirectoryInfo(Constants.SavesPath), fallbackDir, copyRoot: false); + if (Constants.TargetPlatform != GamePlatform.Android) // expected to fail on Android + this.Monitor.Log($"Couldn't compress backup, leaving it uncompressed.\n{compressError}", LogLevel.Trace); } + else + fallbackDir.Delete(recursive: true); + this.Monitor.Log("Backup done!", LogLevel.Trace); } catch (Exception ex) @@ -198,12 +197,16 @@ namespace StardewModdingAPI.Mods.SaveBackup /// <param name="source">The file or folder to copy.</param> /// <param name="targetFolder">The folder to copy into.</param> /// <param name="copyRoot">Whether to copy the root folder itself, or <c>false</c> to only copy its contents.</param> + /// <param name="filter">A filter which matches the files or directories to copy, or <c>null</c> to copy everything.</param> /// <remarks>Derived from the SMAPI installer code.</remarks> - private void RecursiveCopy(FileSystemInfo source, DirectoryInfo targetFolder, bool copyRoot = true) + private void RecursiveCopy(FileSystemInfo source, DirectoryInfo targetFolder, Func<FileSystemInfo, bool> filter, bool copyRoot = true) { if (!targetFolder.Exists) targetFolder.Create(); + if (filter?.Invoke(source) == false) + return; + switch (source) { case FileInfo sourceFile: @@ -213,12 +216,29 @@ namespace StardewModdingAPI.Mods.SaveBackup case DirectoryInfo sourceDir: DirectoryInfo targetSubfolder = copyRoot ? new DirectoryInfo(Path.Combine(targetFolder.FullName, sourceDir.Name)) : targetFolder; foreach (var entry in sourceDir.EnumerateFileSystemInfos()) - this.RecursiveCopy(entry, targetSubfolder); + this.RecursiveCopy(entry, targetSubfolder, filter); break; default: throw new NotSupportedException($"Unknown filesystem info type '{source.GetType().FullName}'."); } } + + /// <summary>A copy filter which matches save folders.</summary> + /// <param name="savesFolder">The folder containing save folders.</param> + /// <param name="entry">The current entry to check under <paramref name="savesFolder"/>.</param> + private bool MatchSaveFolders(DirectoryInfo savesFolder, FileSystemInfo entry) + { + // only need to filter top-level entries + string parentPath = (entry as FileInfo)?.DirectoryName ?? (entry as DirectoryInfo)?.Parent?.FullName; + if (parentPath != savesFolder.FullName) + return true; + + + // match folders with Name_ID format + return + entry is DirectoryInfo + && ulong.TryParse(entry.Name.Split('_').Last(), out _); + } } } diff --git a/src/SMAPI.Mods.SaveBackup/SMAPI.Mods.SaveBackup.csproj b/src/SMAPI.Mods.SaveBackup/SMAPI.Mods.SaveBackup.csproj index 2d031408..970ccea8 100644 --- a/src/SMAPI.Mods.SaveBackup/SMAPI.Mods.SaveBackup.csproj +++ b/src/SMAPI.Mods.SaveBackup/SMAPI.Mods.SaveBackup.csproj @@ -5,7 +5,6 @@ <RootNamespace>StardewModdingAPI.Mods.SaveBackup</RootNamespace> <TargetFramework>net45</TargetFramework> <LangVersion>latest</LangVersion> - <OutputPath>$(SolutionDir)\..\bin\$(Configuration)\Mods\SaveBackup</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <PlatformTarget>x86</PlatformTarget> </PropertyGroup> diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json index 55af8f35..74256013 100644 --- a/src/SMAPI.Mods.SaveBackup/manifest.json +++ b/src/SMAPI.Mods.SaveBackup/manifest.json @@ -1,9 +1,9 @@ { "Name": "Save Backup", "Author": "SMAPI", - "Version": "3.1.0", + "Version": "3.2.0", "Description": "Automatically backs up all your saves once per day into its folder.", "UniqueID": "SMAPI.SaveBackup", "EntryDll": "SaveBackup.dll", - "MinimumApiVersion": "3.1.0" + "MinimumApiVersion": "3.2.0" } diff --git a/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs b/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs index 48afcaa2..ac4ef39b 100644 --- a/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs +++ b/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs @@ -17,7 +17,8 @@ namespace SMAPI.Tests.Utilities /**** ** Constructor ****/ - [Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from a string.")] + /// <summary>Assert the parsed version when constructed from a standard string.</summary> + /// <param name="input">The version string to parse.</param> [TestCase("1.0", ExpectedResult = "1.0.0")] [TestCase("1.0.0", ExpectedResult = "1.0.0")] [TestCase("3000.4000.5000", ExpectedResult = "3000.4000.5000")] @@ -29,10 +30,76 @@ namespace SMAPI.Tests.Utilities [TestCase("1.2+3.4.5-build.004", ExpectedResult = "1.2.0+3.4.5-build.004")] public string Constructor_FromString(string input) { - return new SemanticVersion(input).ToString(); + // act + ISemanticVersion version = new SemanticVersion(input); + + // assert + return version.ToString(); } - [Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from the individual numbers.")] + + /// <summary>Assert that the constructor rejects invalid values when constructed from a string.</summary> + /// <param name="input">The version string to parse.</param> + [Test(Description = "Assert that the constructor throws the expected exception for invalid versions.")] + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + [TestCase("1")] + [TestCase("01.0")] + [TestCase("1.05")] + [TestCase("1.5.06")] // leading zeros specifically prohibited by spec + [TestCase("1.2.3.4")] + [TestCase("1.apple")] + [TestCase("1.2.apple")] + [TestCase("1.2.3.apple")] + [TestCase("1..2..3")] + [TestCase("1.2.3-")] + [TestCase("1.2.3--some-tag")] + [TestCase("1.2.3-some-tag...")] + [TestCase("1.2.3-some-tag...4")] + [TestCase("1.2.3-some-tag.4+build...4")] + [TestCase("apple")] + [TestCase("-apple")] + [TestCase("-5")] + public void Constructor_FromString_WithInvalidValues(string input) + { + if (input == null) + this.AssertAndLogException<ArgumentNullException>(() => new SemanticVersion(input)); + else + this.AssertAndLogException<FormatException>(() => new SemanticVersion(input)); + } + + /// <summary>Assert the parsed version when constructed from a non-standard string.</summary> + /// <param name="input">The version string to parse.</param> + [TestCase("1.2.3", ExpectedResult = "1.2.3")] + [TestCase("1.0.0.0", ExpectedResult = "1.0.0")] + [TestCase("1.0.0.5", ExpectedResult = "1.0.0.5")] + [TestCase("1.2.3.4-some-tag.4 ", ExpectedResult = "1.2.3.4-some-tag.4")] + public string Constructor_FromString_NonStandard(string input) + { + // act + ISemanticVersion version = new SemanticVersion(input, allowNonStandard: true); + + // assert + return version.ToString(); + } + + /// <summary>Assert that the constructor rejects a non-standard string when the non-standard flag isn't set.</summary> + /// <param name="input">The version string to parse.</param> + [TestCase("1.0.0.0")] + [TestCase("1.0.0.5")] + [TestCase("1.2.3.4-some-tag.4 ")] + public void Constructor_FromString_Standard_DisallowsNonStandardVersion(string input) + { + Assert.Throws<FormatException>(() => new SemanticVersion(input)); + } + + /// <summary>Assert the parsed version when constructed from standard parts.</summary> + /// <param name="major">The major number.</param> + /// <param name="minor">The minor number.</param> + /// <param name="patch">The patch number.</param> + /// <param name="prerelease">The prerelease tag.</param> + /// <param name="build">The build metadata.</param> [TestCase(1, 0, 0, null, null, ExpectedResult = "1.0.0")] [TestCase(3000, 4000, 5000, null, null, ExpectedResult = "3000.4000.5000")] [TestCase(1, 2, 3, "", null, ExpectedResult = "1.2.3")] @@ -49,15 +116,43 @@ namespace SMAPI.Tests.Utilities ISemanticVersion version = new SemanticVersion(major, minor, patch, prerelease, build); // assert - Assert.AreEqual(major, version.MajorVersion, "The major version doesn't match the given value."); - Assert.AreEqual(minor, version.MinorVersion, "The minor version doesn't match the given value."); - Assert.AreEqual(patch, version.PatchVersion, "The patch version doesn't match the given value."); - Assert.AreEqual(string.IsNullOrWhiteSpace(prerelease) ? null : prerelease.Trim(), version.PrereleaseTag, "The prerelease tag doesn't match the given value."); - Assert.AreEqual(string.IsNullOrWhiteSpace(build) ? null : build.Trim(), version.BuildMetadata, "The build metadata doesn't match the given value."); + this.AssertParts(version, major, minor, patch, prerelease, build, nonStandard: false); return version.ToString(); } - [Test(Description = "Assert that the constructor throws the expected exception for invalid versions when constructed from the individual numbers.")] + /// <summary>Assert the parsed version when constructed from parts including non-standard fields.</summary> + /// <param name="major">The major number.</param> + /// <param name="minor">The minor number.</param> + /// <param name="patch">The patch number.</param> + /// <param name="platformRelease">The non-standard platform release number.</param> + /// <param name="prerelease">The prerelease tag.</param> + /// <param name="build">The build metadata.</param> + [TestCase(1, 0, 0, 0, null, null, ExpectedResult = "1.0.0")] + [TestCase(3000, 4000, 5000, 6000, null, null, ExpectedResult = "3000.4000.5000.6000")] + [TestCase(1, 2, 3, 4, "", null, ExpectedResult = "1.2.3.4")] + [TestCase(1, 2, 3, 4, " ", null, ExpectedResult = "1.2.3.4")] + [TestCase(1, 2, 3, 4, "0", null, ExpectedResult = "1.2.3.4-0")] + [TestCase(1, 2, 3, 4, "some-tag.4", null, ExpectedResult = "1.2.3.4-some-tag.4")] + [TestCase(1, 2, 3, 4, "sOMe-TaG.4", null, ExpectedResult = "1.2.3.4-sOMe-TaG.4")] + [TestCase(1, 2, 3, 4, "some-tag.4 ", null, ExpectedResult = "1.2.3.4-some-tag.4")] + [TestCase(1, 2, 3, 4, "some-tag.4 ", "build.004", ExpectedResult = "1.2.3.4-some-tag.4+build.004")] + [TestCase(1, 2, 0, 4, null, "3.4.5-build.004", ExpectedResult = "1.2.0.4+3.4.5-build.004")] + public string Constructor_FromParts_NonStandard(int major, int minor, int patch, int platformRelease, string prerelease, string build) + { + // act + ISemanticVersion version = new SemanticVersion(major, minor, patch, platformRelease, prerelease, build); + + // assert + this.AssertParts(version, major, minor, patch, prerelease, build, nonStandard: platformRelease != 0); + return version.ToString(); + } + + /// <summary>Assert that the constructor rejects invalid values when constructed from the individual numbers.</summary> + /// <param name="major">The major number.</param> + /// <param name="minor">The minor number.</param> + /// <param name="patch">The patch number.</param> + /// <param name="prerelease">The prerelease tag.</param> + /// <param name="build">The build metadata.</param> [TestCase(0, 0, 0, null, null)] [TestCase(-1, 0, 0, null, null)] [TestCase(0, -1, 0, null, null)] @@ -71,6 +166,10 @@ namespace SMAPI.Tests.Utilities this.AssertAndLogException<FormatException>(() => new SemanticVersion(major, minor, patch, prerelease, build)); } + /// <summary>Assert the parsed version when constructed from an assembly version.</summary> + /// <param name="major">The major number.</param> + /// <param name="minor">The minor number.</param> + /// <param name="patch">The patch number.</param> [Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from an assembly version.")] [TestCase(1, 0, 0, ExpectedResult = "1.0.0")] [TestCase(1, 2, 3, ExpectedResult = "1.2.3")] @@ -81,45 +180,16 @@ namespace SMAPI.Tests.Utilities ISemanticVersion version = new SemanticVersion(new Version(major, minor, patch)); // assert - Assert.AreEqual(major, version.MajorVersion, "The major version doesn't match the given value."); - Assert.AreEqual(minor, version.MinorVersion, "The minor version doesn't match the given value."); - Assert.AreEqual(patch, version.PatchVersion, "The patch version doesn't match the given value."); + this.AssertParts(version, major, minor, patch, null, null, nonStandard: false); return version.ToString(); } - [Test(Description = "Assert that the constructor throws the expected exception for invalid versions.")] - [TestCase(null)] - [TestCase("")] - [TestCase(" ")] - [TestCase("1")] - [TestCase("01.0")] - [TestCase("1.05")] - [TestCase("1.5.06")] // leading zeros specifically prohibited by spec - [TestCase("1.2.3.4")] - [TestCase("1.apple")] - [TestCase("1.2.apple")] - [TestCase("1.2.3.apple")] - [TestCase("1..2..3")] - [TestCase("1.2.3-")] - [TestCase("1.2.3--some-tag")] - [TestCase("1.2.3-some-tag...")] - [TestCase("1.2.3-some-tag...4")] - [TestCase("1.2.3-some-tag.4+build...4")] - [TestCase("apple")] - [TestCase("-apple")] - [TestCase("-5")] - public void Constructor_FromString_WithInvalidValues(string input) - { - if (input == null) - this.AssertAndLogException<ArgumentNullException>(() => new SemanticVersion(input)); - else - this.AssertAndLogException<FormatException>(() => new SemanticVersion(input)); - } - /**** ** CompareTo ****/ - [Test(Description = "Assert that version.CompareTo returns the expected value.")] + /// <summary>Assert that <see cref="ISemanticVersion.CompareTo"/> returns the expected value.</summary> + /// <param name="versionStrA">The left version.</param> + /// <param name="versionStrB">The right version.</param> // equal [TestCase("0.5.7", "0.5.7", ExpectedResult = 0)] [TestCase("1.0", "1.0", ExpectedResult = 0)] @@ -149,15 +219,20 @@ namespace SMAPI.Tests.Utilities [TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = 1)] public int CompareTo(string versionStrA, string versionStrB) { + // arrange ISemanticVersion versionA = new SemanticVersion(versionStrA); ISemanticVersion versionB = new SemanticVersion(versionStrB); + + // assert return versionA.CompareTo(versionB); } /**** ** IsOlderThan ****/ - [Test(Description = "Assert that version.IsOlderThan returns the expected value.")] + /// <summary>Assert that <see cref="ISemanticVersion.IsOlderThan(string)"/> and <see cref="ISemanticVersion.IsOlderThan(ISemanticVersion)"/> return the expected value.</summary> + /// <param name="versionStrA">The left version.</param> + /// <param name="versionStrB">The right version.</param> // keep test cases in sync with CompareTo for simplicity. // equal [TestCase("0.5.7", "0.5.7", ExpectedResult = false)] @@ -187,15 +262,21 @@ namespace SMAPI.Tests.Utilities [TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = false)] public bool IsOlderThan(string versionStrA, string versionStrB) { + // arrange ISemanticVersion versionA = new SemanticVersion(versionStrA); ISemanticVersion versionB = new SemanticVersion(versionStrB); + + // assert + Assert.AreEqual(versionA.IsOlderThan(versionB), versionA.IsOlderThan(versionB.ToString()), "The two signatures returned different results."); return versionA.IsOlderThan(versionB); } /**** ** IsNewerThan ****/ - [Test(Description = "Assert that version.IsNewerThan returns the expected value.")] + /// <summary>Assert that <see cref="ISemanticVersion.IsNewerThan(string)"/> and <see cref="ISemanticVersion.IsNewerThan(ISemanticVersion)"/> return the expected value.</summary> + /// <param name="versionStrA">The left version.</param> + /// <param name="versionStrB">The right version.</param> // keep test cases in sync with CompareTo for simplicity. // equal [TestCase("0.5.7", "0.5.7", ExpectedResult = false)] @@ -225,14 +306,22 @@ namespace SMAPI.Tests.Utilities [TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = true)] public bool IsNewerThan(string versionStrA, string versionStrB) { + // arrange ISemanticVersion versionA = new SemanticVersion(versionStrA); ISemanticVersion versionB = new SemanticVersion(versionStrB); + + // assert + Assert.AreEqual(versionA.IsNewerThan(versionB), versionA.IsNewerThan(versionB.ToString()), "The two signatures returned different results."); return versionA.IsNewerThan(versionB); } /**** ** IsBetween ****/ + /// <summary>Assert that <see cref="ISemanticVersion.IsBetween(string, string)"/> and <see cref="ISemanticVersion.IsBetween(ISemanticVersion, ISemanticVersion)"/> return the expected value.</summary> + /// <param name="versionStr">The main version.</param> + /// <param name="lowerStr">The lower version number.</param> + /// <param name="upperStr">The upper version number.</param> [Test(Description = "Assert that version.IsNewerThan returns the expected value.")] // is between [TestCase("0.5.7-beta.3", "0.5.7-beta.3", "0.5.7-beta.3", ExpectedResult = true)] @@ -250,17 +339,24 @@ namespace SMAPI.Tests.Utilities [TestCase("1.0-beta-2", "1.0-beta-10", "1.0-beta-3", ExpectedResult = false)] public bool IsBetween(string versionStr, string lowerStr, string upperStr) { + // arrange ISemanticVersion lower = new SemanticVersion(lowerStr); ISemanticVersion upper = new SemanticVersion(upperStr); ISemanticVersion version = new SemanticVersion(versionStr); + + // assert + Assert.AreEqual(version.IsBetween(lower, upper), version.IsBetween(lower.ToString(), upper.ToString()), "The two signatures returned different results."); return version.IsBetween(lower, upper); } /**** ** Serializable ****/ - [Test(Description = "Assert that SemanticVersion can be round-tripped through JSON with no special configuration.")] + /// <summary>Assert that the version can be round-tripped through JSON with no special configuration.</summary> + /// <param name="versionStr">The semantic version.</param> [TestCase("1.0.0")] + [TestCase("1.0.0-beta.400")] + [TestCase("1.0.0-beta.400+build")] public void Serializable(string versionStr) { // act @@ -272,10 +368,12 @@ namespace SMAPI.Tests.Utilities Assert.AreEqual(versionStr, after.ToString(), "The semantic version after deserialization doesn't match the input version."); } + /**** ** GameVersion ****/ - [Test(Description = "Assert that the GameVersion subclass correctly parses legacy game versions.")] + /// <summary>Assert that the GameVersion subclass correctly parses non-standard game versions.</summary> + /// <param name="versionStr">The raw version.</param> [TestCase("1.0")] [TestCase("1.01")] [TestCase("1.02")] @@ -307,6 +405,24 @@ namespace SMAPI.Tests.Utilities /********* ** Private methods *********/ + /// <summary>Assert that the version matches the expected parts.</summary> + /// <param name="version">The version number.</param> + /// <param name="major">The major number.</param> + /// <param name="minor">The minor number.</param> + /// <param name="patch">The patch number.</param> + /// <param name="prerelease">The prerelease tag.</param> + /// <param name="build">The build metadata.</param> + /// <param name="nonStandard">Whether the version should be marked as non-standard.</param> + private void AssertParts(ISemanticVersion version, int major, int minor, int patch, string prerelease, string build, bool nonStandard) + { + Assert.AreEqual(major, version.MajorVersion, "The major version doesn't match."); + Assert.AreEqual(minor, version.MinorVersion, "The minor version doesn't match."); + Assert.AreEqual(patch, version.PatchVersion, "The patch version doesn't match."); + Assert.AreEqual(string.IsNullOrWhiteSpace(prerelease) ? null : prerelease.Trim(), version.PrereleaseTag, "The prerelease tag doesn't match."); + Assert.AreEqual(string.IsNullOrWhiteSpace(build) ? null : build.Trim(), version.BuildMetadata, "The build metadata doesn't match."); + Assert.AreEqual(nonStandard, version.IsNonStandard(), $"The version is incorrectly marked {(nonStandard ? "standard" : "non-standard")}."); + } + /// <summary>Assert that the expected exception type is thrown, and log the action output and thrown exception.</summary> /// <typeparam name="T">The expected exception type.</typeparam> /// <param name="action">The action which may throw the exception.</param> diff --git a/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs b/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs index b8572d50..b228b2d1 100644 --- a/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs +++ b/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs @@ -61,5 +61,8 @@ namespace StardewModdingAPI /// <summary>Get a string representation of the version.</summary> string ToString(); + + /// <summary>Whether the version uses non-standard extensions, like four-part game versions on some platforms.</summary> + bool IsNonStandard(); } } diff --git a/src/SMAPI.Toolkit.CoreInterfaces/SMAPI.Toolkit.CoreInterfaces.csproj b/src/SMAPI.Toolkit.CoreInterfaces/SMAPI.Toolkit.CoreInterfaces.csproj index 1b9c04ff..accc9175 100644 --- a/src/SMAPI.Toolkit.CoreInterfaces/SMAPI.Toolkit.CoreInterfaces.csproj +++ b/src/SMAPI.Toolkit.CoreInterfaces/SMAPI.Toolkit.CoreInterfaces.csproj @@ -5,9 +5,8 @@ <RootNamespace>StardewModdingAPI</RootNamespace> <Description>Provides toolkit interfaces which are available to SMAPI mods.</Description> <TargetFrameworks>net4.5;netstandard2.0</TargetFrameworks> - <OutputPath>..\..\bin\$(Configuration)\SMAPI.Toolkit.CoreInterfaces</OutputPath> - <DocumentationFile>..\..\bin\$(Configuration)\SMAPI.Toolkit.CoreInterfaces\$(TargetFramework)\SMAPI.Toolkit.CoreInterfaces.xml</DocumentationFile> <LangVersion>latest</LangVersion> + <DocumentationFile>bin\$(Configuration)\$(TargetFramework)\SMAPI.Toolkit.CoreInterfaces.xml</DocumentationFile> <PlatformTarget Condition="'$(TargetFramework)' == 'net4.5'">x86</PlatformTarget> </PropertyGroup> diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs index f1bcfccc..2f58a3f1 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs @@ -1,5 +1,3 @@ -using System; - namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi { /// <summary>Metadata about a mod.</summary> @@ -17,26 +15,6 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi /// <summary>Optional extended data which isn't needed for update checks.</summary> public ModExtendedMetadataModel Metadata { get; set; } - /// <summary>The main version.</summary> - [Obsolete] - public ModEntryVersionModel Main { get; set; } - - /// <summary>The latest optional version, if newer than <see cref="Main"/>.</summary> - [Obsolete] - public ModEntryVersionModel Optional { get; set; } - - /// <summary>The latest unofficial version, if newer than <see cref="Main"/> and <see cref="Optional"/>.</summary> - [Obsolete] - public ModEntryVersionModel Unofficial { get; set; } - - /// <summary>The latest unofficial version for the current Stardew Valley or SMAPI beta, if any (see <see cref="HasBetaInfo"/>).</summary> - [Obsolete] - public ModEntryVersionModel UnofficialForBeta { get; set; } - - /// <summary>Whether a Stardew Valley or SMAPI beta which affects mod compatibility is in progress. If this is true, <see cref="UnofficialForBeta"/> should be used for beta versions of SMAPI instead of <see cref="Unofficial"/>.</summary> - [Obsolete] - public bool? HasBetaInfo { get; set; } - /// <summary>The errors that occurred while fetching update data.</summary> public string[] Errors { get; set; } = new string[0]; } diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs index 4a697585..8c21e4e0 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs @@ -55,7 +55,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi /// <summary>The latest unofficial version, if newer than <see cref="Main"/> and <see cref="Optional"/>.</summary> public ModEntryVersionModel Unofficial { get; set; } - /// <summary>The latest unofficial version for the current Stardew Valley or SMAPI beta, if any (see <see cref="HasBetaInfo"/>).</summary> + /// <summary>The latest unofficial version for the current Stardew Valley or SMAPI beta, if any.</summary> public ModEntryVersionModel UnofficialForBeta { get; set; } /**** @@ -84,6 +84,15 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi /// <summary>The beta game or SMAPI version which broke this mod, if applicable.</summary> public string BetaBrokeIn { get; set; } + /**** + ** Version mappings + ****/ + /// <summary>Maps local versions to a semantic version for update checks.</summary> + public IDictionary<string, string> MapLocalVersions { get; set; } + + /// <summary>Maps remote versions to a semantic version for update checks.</summary> + public IDictionary<string, string> MapRemoteVersions { get; set; } + /********* ** Public methods @@ -127,13 +136,16 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi this.BetaCompatibilityStatus = wiki.BetaCompatibility?.Status; this.BetaCompatibilitySummary = wiki.BetaCompatibility?.Summary; this.BetaBrokeIn = wiki.BetaCompatibility?.BrokeIn; + + this.MapLocalVersions = wiki.MapLocalVersions; + this.MapRemoteVersions = wiki.MapRemoteVersions; } // internal DB data if (db != null) { this.ID = this.ID.Union(db.FormerIDs).ToArray(); - this.Name = this.Name ?? db.DisplayName; + this.Name ??= db.DisplayName; } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs index 384f23fc..c829c0f4 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs @@ -102,6 +102,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki string anchor = this.GetAttribute(node, "id"); string contentPackFor = this.GetAttribute(node, "data-content-pack-for"); string devNote = this.GetAttribute(node, "data-dev-note"); + string pullRequestUrl = this.GetAttribute(node, "data-pr"); IDictionary<string, string> mapLocalVersions = this.GetAttributeAsVersionMapping(node, "data-map-local-versions"); IDictionary<string, string> mapRemoteVersions = this.GetAttributeAsVersionMapping(node, "data-map-remote-versions"); @@ -132,15 +133,6 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki } } - // parse links - List<Tuple<Uri, string>> metadataLinks = new List<Tuple<Uri, string>>(); - foreach (HtmlNode linkElement in node.Descendants("td").Last().Descendants("a").Skip(1)) // skip anchor link - { - string text = linkElement.InnerText.Trim(); - Uri url = new Uri(linkElement.GetAttributeValue("href", "")); - metadataLinks.Add(Tuple.Create(url, text)); - } - // yield model yield return new WikiModEntry { @@ -159,7 +151,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki Compatibility = compatibility, BetaCompatibility = betaCompatibility, Warnings = warnings, - MetadataLinks = metadataLinks.ToArray(), + PullRequestUrl = pullRequestUrl, DevNote = devNote, MapLocalVersions = mapLocalVersions, MapRemoteVersions = mapRemoteVersions, diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs index 931dcd43..474dce3d 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs @@ -57,8 +57,8 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// <summary>The human-readable warnings for players about this mod.</summary> public string[] Warnings { get; set; } - /// <summary>Extra metadata links (usually for open pull requests).</summary> - public Tuple<Uri, string>[] MetadataLinks { get; set; } + /// <summary>The URL of the pull request which submits changes for an unofficial update to the author, if any.</summary> + public string PullRequestUrl { get; set; } /// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests. </summary> public string DevNote { get; set; } diff --git a/src/SMAPI.Toolkit/Framework/SemanticVersionReader.cs b/src/SMAPI.Toolkit/Framework/SemanticVersionReader.cs new file mode 100644 index 00000000..489e1c4d --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/SemanticVersionReader.cs @@ -0,0 +1,126 @@ +namespace StardewModdingAPI.Toolkit.Framework +{ + /// <summary>Reads strings into a semantic version.</summary> + internal static class SemanticVersionReader + { + /********* + ** Public methods + *********/ + /// <summary>Parse a semantic version string.</summary> + /// <param name="versionStr">The version string to parse.</param> + /// <param name="allowNonStandard">Whether to recognize non-standard semver extensions.</param> + /// <param name="major">The major version incremented for major API changes.</param> + /// <param name="minor">The minor version incremented for backwards-compatible changes.</param> + /// <param name="patch">The patch version for backwards-compatible fixes.</param> + /// <param name="platformRelease">The platform-specific version (if applicable).</param> + /// <param name="prereleaseTag">An optional prerelease tag.</param> + /// <param name="buildMetadata">Optional build metadata. This is ignored when determining version precedence.</param> + /// <returns>Returns whether the version was successfully parsed.</returns> + public static bool TryParse(string versionStr, bool allowNonStandard, out int major, out int minor, out int patch, out int platformRelease, out string prereleaseTag, out string buildMetadata) + { + // init + major = 0; + minor = 0; + patch = 0; + platformRelease = 0; + prereleaseTag = null; + buildMetadata = null; + + // normalize + versionStr = versionStr?.Trim(); + if (string.IsNullOrWhiteSpace(versionStr)) + return false; + char[] raw = versionStr.ToCharArray(); + + // read major/minor version + int i = 0; + if (!TryParseVersionPart(raw, ref i, out major) || !TryParseLiteral(raw, ref i, '.') || !TryParseVersionPart(raw, ref i, out minor)) + return false; + + // read optional patch version + if (TryParseLiteral(raw, ref i, '.') && !TryParseVersionPart(raw, ref i, out patch)) + return false; + + // read optional non-standard platform release version + if (allowNonStandard && TryParseLiteral(raw, ref i, '.') && !TryParseVersionPart(raw, ref i, out platformRelease)) + return false; + + // read optional prerelease tag + if (TryParseLiteral(raw, ref i, '-') && !TryParseTag(raw, ref i, out prereleaseTag)) + return false; + + // read optional build tag + if (TryParseLiteral(raw, ref i, '+') && !TryParseTag(raw, ref i, out buildMetadata)) + return false; + + // validate + return i == versionStr.Length; // valid if we're at the end + } + + + /********* + ** Private methods + *********/ + /// <summary>Try to parse the next characters in a queue as a numeric part.</summary> + /// <param name="raw">The raw characters to parse.</param> + /// <param name="index">The index of the next character to read.</param> + /// <param name="part">The parsed part.</param> + private static bool TryParseVersionPart(char[] raw, ref int index, out int part) + { + part = 0; + + // take digits + string str = ""; + for (int i = index; i < raw.Length && char.IsDigit(raw[i]); i++) + str += raw[i]; + + // validate + if (str.Length == 0) + return false; + if (str.Length > 1 && str[0] == '0') + return false; // can't have leading zeros + + // parse + part = int.Parse(str); + index += str.Length; + return true; + } + + /// <summary>Try to parse a literal character.</summary> + /// <param name="raw">The raw characters to parse.</param> + /// <param name="index">The index of the next character to read.</param> + /// <param name="ch">The expected character.</param> + private static bool TryParseLiteral(char[] raw, ref int index, char ch) + { + if (index >= raw.Length || raw[index] != ch) + return false; + + index++; + return true; + } + + /// <summary>Try to parse a tag.</summary> + /// <param name="raw">The raw characters to parse.</param> + /// <param name="index">The index of the next character to read.</param> + /// <param name="tag">The parsed tag.</param> + private static bool TryParseTag(char[] raw, ref int index, out string tag) + { + // read tag length + int length = 0; + for (int i = index; i < raw.Length && (char.IsLetterOrDigit(raw[i]) || raw[i] == '-' || raw[i] == '.'); i++) + length++; + + // validate + if (length == 0) + { + tag = null; + return false; + } + + // parse + tag = new string(raw, index, length); + index += length; + return true; + } + } +} diff --git a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj index 3bb7e313..16a97dbf 100644 --- a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj +++ b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj @@ -5,15 +5,14 @@ <RootNamespace>StardewModdingAPI.Toolkit</RootNamespace> <Description>A library which encapsulates mod-handling logic for mod managers and tools. Not intended for use by mods.</Description> <TargetFrameworks>net4.5;netstandard2.0</TargetFrameworks> - <OutputPath>..\..\bin\$(Configuration)\SMAPI.Toolkit</OutputPath> - <DocumentationFile>..\..\bin\$(Configuration)\SMAPI.Toolkit\$(TargetFramework)\SMAPI.Toolkit.xml</DocumentationFile> <LangVersion>latest</LangVersion> + <DocumentationFile>bin\$(Configuration)\$(TargetFramework)\SMAPI.Toolkit.xml</DocumentationFile> <PlatformTarget Condition="'$(TargetFramework)' == 'net4.5'">x86</PlatformTarget> <RootNamespace>StardewModdingAPI.Toolkit</RootNamespace> </PropertyGroup> <ItemGroup> - <PackageReference Include="HtmlAgilityPack" Version="1.11.16" /> + <PackageReference Include="HtmlAgilityPack" Version="1.11.18" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" /> <PackageReference Include="System.Management" Version="4.5.0" Condition="'$(OS)' == 'Windows_NT'" /> diff --git a/src/SMAPI.Toolkit/SemanticVersion.cs b/src/SMAPI.Toolkit/SemanticVersion.cs index 4955dcae..5ead6dc8 100644 --- a/src/SMAPI.Toolkit/SemanticVersion.cs +++ b/src/SMAPI.Toolkit/SemanticVersion.cs @@ -1,5 +1,6 @@ using System; using System.Text.RegularExpressions; +using StardewModdingAPI.Toolkit.Framework; namespace StardewModdingAPI.Toolkit { @@ -9,6 +10,8 @@ namespace StardewModdingAPI.Toolkit /// - short-form "x.y" versions are supported (equivalent to "x.y.0"); /// - hyphens are synonymous with dots in prerelease tags and build metadata (like "-unofficial.3-pathoschild"); /// - and "-unofficial" in prerelease tags is always lower-precedence (e.g. "1.0-beta" is newer than "1.0-unofficial"). + /// + /// This optionally also supports four-part versions, a non-standard extension used by Stardew Valley on ported platforms to represent platform-specific patches to a ported version, represented as a fourth number in the version string. /// </remarks> public class SemanticVersion : ISemanticVersion { @@ -16,14 +19,7 @@ namespace StardewModdingAPI.Toolkit ** Fields *********/ /// <summary>A regex pattern matching a valid prerelease or build metadata tag.</summary> - internal const string TagPattern = @"(?>[a-z0-9]+[\-\.]?)+"; - - /// <summary>A regex pattern matching a version within a larger string.</summary> - internal const string UnboundedVersionPattern = @"(?>(?<major>0|[1-9]\d*))\.(?>(?<minor>0|[1-9]\d*))(?>(?:\.(?<patch>0|[1-9]\d*))?)(?:-(?<prerelease>" + SemanticVersion.TagPattern + "))?(?:\\+(?<buildmetadata>" + SemanticVersion.TagPattern + "))?"; - - /// <summary>A regular expression matching a semantic version string.</summary> - /// <remarks>This pattern is derived from the BNF documentation in the <a href="https://github.com/mojombo/semver">semver repo</a>, with deviations to support the Stardew Valley mod conventions (see remarks on <see cref="SemanticVersion"/>).</remarks> - internal static readonly Regex Regex = new Regex($@"^{SemanticVersion.UnboundedVersionPattern}$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.ExplicitCapture); + private const string TagPattern = @"(?>[a-z0-9]+[\-\.]?)+"; /********* @@ -38,6 +34,9 @@ namespace StardewModdingAPI.Toolkit /// <summary>The patch version for backwards-compatible bug fixes.</summary> public int PatchVersion { get; } + /// <summary>The platform release. This is a non-standard semver extension used by Stardew Valley on ported platforms to represent platform-specific patches to a ported version, represented as a fourth number in the version string.</summary> + public int PlatformRelease { get; } + /// <summary>An optional prerelease tag.</summary> public string PrereleaseTag { get; } @@ -52,13 +51,15 @@ namespace StardewModdingAPI.Toolkit /// <param name="major">The major version incremented for major API changes.</param> /// <param name="minor">The minor version incremented for backwards-compatible changes.</param> /// <param name="patch">The patch version for backwards-compatible fixes.</param> + /// <param name="platformRelease">The platform-specific version (if applicable).</param> /// <param name="prereleaseTag">An optional prerelease tag.</param> /// <param name="buildMetadata">Optional build metadata. This is ignored when determining version precedence.</param> - public SemanticVersion(int major, int minor, int patch, string prereleaseTag = null, string buildMetadata = null) + public SemanticVersion(int major, int minor, int patch, int platformRelease = 0, string prereleaseTag = null, string buildMetadata = null) { this.MajorVersion = major; this.MinorVersion = minor; this.PatchVersion = patch; + this.PlatformRelease = platformRelease; this.PrereleaseTag = this.GetNormalizedTag(prereleaseTag); this.BuildMetadata = this.GetNormalizedTag(buildMetadata); @@ -82,23 +83,22 @@ namespace StardewModdingAPI.Toolkit /// <summary>Construct an instance.</summary> /// <param name="version">The semantic version string.</param> + /// <param name="allowNonStandard">Whether to recognize non-standard semver extensions.</param> /// <exception cref="ArgumentNullException">The <paramref name="version"/> is null.</exception> /// <exception cref="FormatException">The <paramref name="version"/> is not a valid semantic version.</exception> - public SemanticVersion(string version) + public SemanticVersion(string version, bool allowNonStandard = false) { - // parse if (version == null) throw new ArgumentNullException(nameof(version), "The input version string can't be null."); - var match = SemanticVersion.Regex.Match(version.Trim()); - if (!match.Success) + if (!SemanticVersionReader.TryParse(version, allowNonStandard, out int major, out int minor, out int patch, out int platformRelease, out string prereleaseTag, out string buildMetadata) || (!allowNonStandard && platformRelease != 0)) throw new FormatException($"The input '{version}' isn't a valid semantic version."); - // initialize - this.MajorVersion = int.Parse(match.Groups["major"].Value); - this.MinorVersion = match.Groups["minor"].Success ? int.Parse(match.Groups["minor"].Value) : 0; - this.PatchVersion = match.Groups["patch"].Success ? int.Parse(match.Groups["patch"].Value) : 0; - this.PrereleaseTag = match.Groups["prerelease"].Success ? this.GetNormalizedTag(match.Groups["prerelease"].Value) : null; - this.BuildMetadata = match.Groups["buildmetadata"].Success ? this.GetNormalizedTag(match.Groups["buildmetadata"].Value) : null; + this.MajorVersion = major; + this.MinorVersion = minor; + this.PatchVersion = patch; + this.PlatformRelease = platformRelease; + this.PrereleaseTag = prereleaseTag; + this.BuildMetadata = buildMetadata; this.AssertValid(); } @@ -110,7 +110,7 @@ namespace StardewModdingAPI.Toolkit { if (other == null) throw new ArgumentNullException(nameof(other)); - return this.CompareTo(other.MajorVersion, other.MinorVersion, other.PatchVersion, other.PrereleaseTag); + return this.CompareTo(other.MajorVersion, other.MinorVersion, other.PatchVersion, (other as SemanticVersion)?.PlatformRelease ?? 0, other.PrereleaseTag); } /// <summary>Indicates whether the current object is equal to another object of the same type.</summary> @@ -139,7 +139,7 @@ namespace StardewModdingAPI.Toolkit /// <exception cref="FormatException">The specified version is not a valid semantic version.</exception> public bool IsOlderThan(string other) { - return this.IsOlderThan(new SemanticVersion(other)); + return this.IsOlderThan(new SemanticVersion(other, allowNonStandard: true)); } /// <summary>Get whether this version is newer than the specified version.</summary> @@ -154,7 +154,7 @@ namespace StardewModdingAPI.Toolkit /// <exception cref="FormatException">The specified version is not a valid semantic version.</exception> public bool IsNewerThan(string other) { - return this.IsNewerThan(new SemanticVersion(other)); + return this.IsNewerThan(new SemanticVersion(other, allowNonStandard: true)); } /// <summary>Get whether this version is between two specified versions (inclusively).</summary> @@ -171,13 +171,15 @@ namespace StardewModdingAPI.Toolkit /// <exception cref="FormatException">One of the specified versions is not a valid semantic version.</exception> public bool IsBetween(string min, string max) { - return this.IsBetween(new SemanticVersion(min), new SemanticVersion(max)); + return this.IsBetween(new SemanticVersion(min, allowNonStandard: true), new SemanticVersion(max, allowNonStandard: true)); } /// <summary>Get a string representation of the version.</summary> public override string ToString() { string version = $"{this.MajorVersion}.{this.MinorVersion}.{this.PatchVersion}"; + if (this.PlatformRelease != 0) + version += $".{this.PlatformRelease}"; if (this.PrereleaseTag != null) version += $"-{this.PrereleaseTag}"; if (this.BuildMetadata != null) @@ -185,15 +187,30 @@ namespace StardewModdingAPI.Toolkit return version; } + /// <summary>Whether the version uses non-standard extensions, like four-part game versions on some platforms.</summary> + public bool IsNonStandard() + { + return this.PlatformRelease != 0; + } + /// <summary>Parse a version string without throwing an exception if it fails.</summary> /// <param name="version">The version string.</param> /// <param name="parsed">The parsed representation.</param> /// <returns>Returns whether parsing the version succeeded.</returns> public static bool TryParse(string version, out ISemanticVersion parsed) { + return SemanticVersion.TryParseNonStandard(version, out parsed) && !parsed.IsNonStandard(); + } + + /// <summary>Parse a version string without throwing an exception if it fails, including support for non-standard extensions like <see cref="IPlatformSpecificVersion"/>.</summary> + /// <param name="version">The version string.</param> + /// <param name="parsed">The parsed representation.</param> + /// <returns>Returns whether parsing the version succeeded.</returns> + public static bool TryParseNonStandard(string version, out ISemanticVersion parsed) + { try { - parsed = new SemanticVersion(version); + parsed = new SemanticVersion(version, true); return true; } catch @@ -219,8 +236,9 @@ namespace StardewModdingAPI.Toolkit /// <param name="otherMajor">The major version to compare with this instance.</param> /// <param name="otherMinor">The minor version to compare with this instance.</param> /// <param name="otherPatch">The patch version to compare with this instance.</param> + /// <param name="otherPlatformRelease">The non-standard platform release to compare with this instance.</param> /// <param name="otherTag">The prerelease tag to compare with this instance.</param> - private int CompareTo(int otherMajor, int otherMinor, int otherPatch, string otherTag) + private int CompareTo(int otherMajor, int otherMinor, int otherPatch, int otherPlatformRelease, string otherTag) { const int same = 0; const int curNewer = 1; @@ -233,6 +251,8 @@ namespace StardewModdingAPI.Toolkit return this.MinorVersion.CompareTo(otherMinor); if (this.PatchVersion != otherPatch) return this.PatchVersion.CompareTo(otherPatch); + if (this.PlatformRelease != otherPlatformRelease) + return this.PlatformRelease.CompareTo(otherPlatformRelease); if (this.PrereleaseTag == otherTag) return same; @@ -274,7 +294,7 @@ namespace StardewModdingAPI.Toolkit } // fallback (this should never happen) - return string.Compare(this.ToString(), new SemanticVersion(otherMajor, otherMinor, otherPatch, otherTag).ToString(), StringComparison.InvariantCultureIgnoreCase); + return string.Compare(this.ToString(), new SemanticVersion(otherMajor, otherMinor, otherPatch, otherPlatformRelease, otherTag).ToString(), StringComparison.InvariantCultureIgnoreCase); } /// <summary>Assert that the current version is valid.</summary> diff --git a/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs b/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs index ece4a72e..e1b9db1d 100644 --- a/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs +++ b/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs @@ -68,7 +68,7 @@ namespace StardewModdingAPI.Toolkit.Serialization.Converters int patch = obj.ValueIgnoreCase<int>(nameof(ISemanticVersion.PatchVersion)); string prereleaseTag = obj.ValueIgnoreCase<string>(nameof(ISemanticVersion.PrereleaseTag)); - return new SemanticVersion(major, minor, patch, prereleaseTag); + return new SemanticVersion(major, minor, patch, prereleaseTag: prereleaseTag); } /// <summary>Read a JSON string.</summary> diff --git a/src/SMAPI.Web.LegacyRedirects/SMAPI.Web.LegacyRedirects.csproj b/src/SMAPI.Web.LegacyRedirects/SMAPI.Web.LegacyRedirects.csproj index a3d5c2b6..36831961 100644 --- a/src/SMAPI.Web.LegacyRedirects/SMAPI.Web.LegacyRedirects.csproj +++ b/src/SMAPI.Web.LegacyRedirects/SMAPI.Web.LegacyRedirects.csproj @@ -9,7 +9,7 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.0.0" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.0.2" /> <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" /> </ItemGroup> diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index 3e3b81c8..f194b4d0 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -94,8 +94,6 @@ namespace StardewModdingAPI.Web.Controllers if (model?.Mods == null) return new ModEntryModel[0]; - bool legacyMode = SemanticVersion.TryParse(version, out ISemanticVersion parsedVersion) && parsedVersion.IsOlderThan("3.0.0-beta.20191109"); - // fetch wiki data WikiModEntry[] wikiData = this.WikiCache.GetWikiMods().Select(p => p.GetModel()).ToArray(); IDictionary<string, ModEntryModel> mods = new Dictionary<string, ModEntryModel>(StringComparer.CurrentCultureIgnoreCase); @@ -104,19 +102,8 @@ namespace StardewModdingAPI.Web.Controllers if (string.IsNullOrWhiteSpace(mod.ID)) continue; - ModEntryModel result = await this.GetModData(mod, wikiData, model.IncludeExtendedMetadata || legacyMode, model.ApiVersion); - if (legacyMode) - { - result.Main = result.Metadata.Main; - result.Optional = result.Metadata.Optional; - result.Unofficial = result.Metadata.Unofficial; - result.UnofficialForBeta = result.Metadata.UnofficialForBeta; - result.HasBetaInfo = result.Metadata.BetaCompatibilityStatus != null; - result.SuggestedUpdate = null; - if (!model.IncludeExtendedMetadata) - result.Metadata = null; - } - else if (!model.IncludeExtendedMetadata && (model.ApiVersion == null || mod.InstalledVersion == null)) + ModEntryModel result = await this.GetModData(mod, wikiData, model.IncludeExtendedMetadata, model.ApiVersion); + if (!model.IncludeExtendedMetadata && (model.ApiVersion == null || mod.InstalledVersion == null)) { var errors = new List<string>(result.Errors); errors.Add($"This API can't suggest an update because {nameof(model.ApiVersion)} or {nameof(mod.InstalledVersion)} are null, and you didn't specify {nameof(model.IncludeExtendedMetadata)} to get other info. See the SMAPI technical docs for usage."); diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs b/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs index 8569984a..7e7c99bc 100644 --- a/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs +++ b/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs @@ -67,8 +67,8 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki /// <summary>The human-readable warnings for players about this mod.</summary> public string[] Warnings { get; set; } - /// <summary>Extra metadata links (usually for open pull requests).</summary> - public Tuple<Uri, string>[] MetadataLinks { get; set; } + /// <summary>The URL of the pull request which submits changes for an unofficial update to the author, if any.</summary> + public string PullRequestUrl { get; set; } /// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests. </summary> public string DevNote { get; set; } @@ -150,7 +150,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki this.CustomSourceUrl = mod.CustomSourceUrl; this.CustomUrl = mod.CustomUrl; this.ContentPackFor = mod.ContentPackFor; - this.MetadataLinks = mod.MetadataLinks; + this.PullRequestUrl = mod.PullRequestUrl; this.Warnings = mod.Warnings; this.DevNote = mod.DevNote; this.Anchor = mod.Anchor; @@ -192,7 +192,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki CustomUrl = this.CustomUrl, ContentPackFor = this.ContentPackFor, Warnings = this.Warnings, - MetadataLinks = this.MetadataLinks, + PullRequestUrl = this.PullRequestUrl, DevNote = this.DevNote, Anchor = this.Anchor, diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs index 939c32c6..cdb281e2 100644 --- a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs @@ -59,7 +59,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish string name = doc.DocumentNode.SelectSingleNode("//meta[@name='twitter:title']").Attributes["content"].Value; if (name.StartsWith("[SMAPI] ")) name = name.Substring("[SMAPI] ".Length); - string version = doc.DocumentNode.SelectSingleNode("//h1/span").InnerText; + string version = doc.DocumentNode.SelectSingleNode("//h1/span")?.InnerText; // create model return new ChucklefishMod diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs index 1210f708..cc91ec51 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; -using StardewModdingAPI.Toolkit; using StardewModdingAPI.Web.Framework.LogParsing.Models; namespace StardewModdingAPI.Web.Framework.LogParsing @@ -31,22 +30,22 @@ namespace StardewModdingAPI.Web.Framework.LogParsing /// <summary>A regex pattern matching an entry in SMAPI's mod list.</summary> /// <remarks>The author name and description are optional.</remarks> - private readonly Regex ModListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>" + SemanticVersion.UnboundedVersionPattern + @")(?: by (?<author>[^\|]+))?(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ModListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>[^\s]+)(?: by (?<author>[^\|]+))?(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching the start of SMAPI's content pack list.</summary> private readonly Regex ContentPackListStartPattern = new Regex(@"^Loaded \d+ content packs:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching an entry in SMAPI's content pack list.</summary> - private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>" + SemanticVersion.UnboundedVersionPattern + @")(?: by (?<author>[^\|]+))? \| for (?<for>[^\|]+)(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>[^\s]+)(?: by (?<author>[^\|]+))? \| for (?<for>[^\|]+)(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching the start of SMAPI's mod update list.</summary> private readonly Regex ModUpdateListStartPattern = new Regex(@"^You can update \d+ mods?:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching an entry in SMAPI's mod update list.</summary> - private readonly Regex ModUpdateListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>" + SemanticVersion.UnboundedVersionPattern + @"): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ModUpdateListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching SMAPI's update line.</summary> - private readonly Regex SMAPIUpdatePattern = new Regex(@"^You can update SMAPI to (?<version>" + SemanticVersion.UnboundedVersionPattern + @"): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex SMAPIUpdatePattern = new Regex(@"^You can update SMAPI to (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /********* diff --git a/src/SMAPI.Web/Framework/VersionConstraint.cs b/src/SMAPI.Web/Framework/VersionConstraint.cs index 2d6ec603..72f5ef84 100644 --- a/src/SMAPI.Web/Framework/VersionConstraint.cs +++ b/src/SMAPI.Web/Framework/VersionConstraint.cs @@ -1,16 +1,34 @@ -using Microsoft.AspNetCore.Routing.Constraints; +using System; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using StardewModdingAPI.Toolkit; namespace StardewModdingAPI.Web.Framework { /// <summary>Constrains a route value to a valid semantic version.</summary> - internal class VersionConstraint : RegexRouteConstraint + internal class VersionConstraint : IRouteConstraint { /********* ** Public methods *********/ - /// <summary>Construct an instance.</summary> - public VersionConstraint() - : base(SemanticVersion.Regex) { } + /// <summary>Get whether the URL parameter contains a valid value for this constraint.</summary> + /// <param name="httpContext">An object that encapsulates information about the HTTP request.</param> + /// <param name="route">The router that this constraint belongs to.</param> + /// <param name="routeKey">The name of the parameter that is being checked.</param> + /// <param name="values">A dictionary that contains the parameters for the URL.</param> + /// <param name="routeDirection">An object that indicates whether the constraint check is being performed when an incoming request is being handled or when a URL is being generated.</param> + /// <returns><c>true</c> if the URL parameter contains a valid value; otherwise, <c>false</c>.</returns> + public bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection) + { + if (routeKey == null) + throw new ArgumentNullException(nameof(routeKey)); + if (values == null) + throw new ArgumentNullException(nameof(values)); + + return + values.TryGetValue(routeKey, out object routeValue) + && routeValue is string routeStr + && SemanticVersion.TryParseNonStandard(routeStr, out _); + } } } diff --git a/src/SMAPI.Web/SMAPI.Web.csproj b/src/SMAPI.Web/SMAPI.Web.csproj index 504254cd..148631a9 100644 --- a/src/SMAPI.Web/SMAPI.Web.csproj +++ b/src/SMAPI.Web/SMAPI.Web.csproj @@ -12,21 +12,21 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Azure.Storage.Blobs" Version="12.1.0" /> - <PackageReference Include="Hangfire.AspNetCore" Version="1.7.7" /> + <PackageReference Include="Azure.Storage.Blobs" Version="12.2.0" /> + <PackageReference Include="Hangfire.AspNetCore" Version="1.7.9" /> <PackageReference Include="Hangfire.MemoryStorage" Version="1.6.3" /> - <PackageReference Include="Hangfire.Mongo" Version="0.6.5" /> - <PackageReference Include="HtmlAgilityPack" Version="1.11.16" /> + <PackageReference Include="Hangfire.Mongo" Version="0.6.6" /> + <PackageReference Include="HtmlAgilityPack" Version="1.11.18" /> <PackageReference Include="Humanizer.Core" Version="2.7.9" /> <PackageReference Include="JetBrains.Annotations" Version="2019.1.3" /> - <PackageReference Include="Markdig" Version="0.18.0" /> + <PackageReference Include="Markdig" Version="0.18.1" /> <PackageReference Include="Microsoft.AspNetCore" Version="2.2.0" /> <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" /> <PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.2.0" /> <PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.2.0" /> <PackageReference Include="Mongo2Go" Version="2.2.12" /> - <PackageReference Include="MongoDB.Driver" Version="2.9.3" /> - <PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.11" /> + <PackageReference Include="MongoDB.Driver" Version="2.10.1" /> + <PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.13" /> <PackageReference Include="Pathoschild.FluentNexus" Version="0.8.0" /> <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" /> </ItemGroup> diff --git a/src/SMAPI.Web/ViewModels/ModModel.cs b/src/SMAPI.Web/ViewModels/ModModel.cs index 2b478c81..56316ab7 100644 --- a/src/SMAPI.Web/ViewModels/ModModel.cs +++ b/src/SMAPI.Web/ViewModels/ModModel.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Linq; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; @@ -38,8 +37,8 @@ namespace StardewModdingAPI.Web.ViewModels /// <summary>The human-readable warnings for players about this mod.</summary> public string[] Warnings { get; set; } - /// <summary>Extra metadata links (usually for open pull requests).</summary> - public Tuple<Uri, string>[] MetadataLinks { get; set; } + /// <summary>The URL of the pull request which submits changes for an unofficial update to the author, if any.</summary> + public string PullRequestUrl { get; set; } /// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests. </summary> public string DevNote { get; set; } @@ -68,7 +67,7 @@ namespace StardewModdingAPI.Web.ViewModels this.BetaCompatibility = entry.BetaCompatibility != null ? new ModCompatibilityModel(entry.BetaCompatibility) : null; this.ModPages = this.GetModPageUrls(entry).ToArray(); this.Warnings = entry.Warnings; - this.MetadataLinks = entry.MetadataLinks; + this.PullRequestUrl = entry.PullRequestUrl; this.DevNote = entry.DevNote; this.Slug = entry.Anchor; } diff --git a/src/SMAPI.Web/Views/Index/Index.cshtml b/src/SMAPI.Web/Views/Index/Index.cshtml index 5d91dc84..eded9df3 100644 --- a/src/SMAPI.Web/Views/Index/Index.cshtml +++ b/src/SMAPI.Web/Views/Index/Index.cshtml @@ -1,4 +1,3 @@ -@using Markdig @using Microsoft.Extensions.Options @using StardewModdingAPI.Web.Framework @using StardewModdingAPI.Web.Framework.ConfigModels @@ -6,18 +5,22 @@ @model StardewModdingAPI.Web.ViewModels.IndexModel @{ ViewData["Title"] = "SMAPI"; + ViewData["ViewTitle"] = string.Empty; } @section Head { - <link rel="stylesheet" href="~/Content/css/index.css?r=20190620" /> + <link rel="stylesheet" href="~/Content/css/index.css?r=20200105" /> <script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script> - <script src="~/Content/js/index.js?r=20190620"></script> + <script src="~/Content/js/index.js?r=20200105"></script> } -<p id="blurb"> - The mod loader for Stardew Valley. It works fine with GOG and Steam achievements, it's - compatible with Linux/Mac/Windows, you can uninstall it anytime, and there's a friendly - community if you need help. It's a cool pufferchick. -</p> +<h1> + SMAPI + <img id="pufferchick" src="Content/images/pufferchick.png" /> +</h1> +<div id="blurb"> + <p>The mod loader for Stardew Valley.</p> + <p>Compatible with GOG/Steam achievements and Linux/Mac/Windows, uninstall anytime, and there's a friendly community if you need help.</p> +</div> <div id="call-to-action"> <div class="cta-dropdown"> @@ -45,80 +48,84 @@ </div><br /> } <div><a href="https://stardewvalleywiki.com/Modding:Player_Guide" class="secondary-cta">Player guide</a></div> - <div class="sublinks"> - <a href="https://github.com/Pathoschild/SMAPI">source code</a> | <a href="@Url.PlainAction("Privacy", "Index")">privacy</a> - </div> - <img id="pufferchick" src="Content/images/pufferchick.png" /> </div> -<h2 id="help">Get help</h2> -<ul> - <li><a href="@Url.PlainAction("Index", "Mods")">Mod compatibility list</a></li> - <li>Get help <a href="https://smapi.io/community">on Discord or in the forums</a></li> -</ul> - -@if (Model.BetaVersion == null) -{ - <h2 id="whatsnew">What's new in SMAPI @Model.StableVersion.Version?</h2> - <div class="github-description"> - @Html.Raw(Markdig.Markdown.ToHtml(Model.StableVersion.Description)) - </div> - <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="@Url.PlainAction("Index", "Mods")">mod compatibility list</a> for more info.</p> -} -else -{ - <h2 id="whatsnew">What's new in...</h2> - <h3>SMAPI @Model.StableVersion.Version?</h3> - <div class="github-description"> - @Html.Raw(Markdig.Markdown.ToHtml(Model.StableVersion.Description)) - </div> - <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="@Url.PlainAction("Index", "Mods")">mod compatibility list</a> for more info.</p> +<div class="area"> + <h2 id="help">Get help</h2> + <ul> + <li><a href="https://smapi.io/community">Ask on Discord</a></li> + <li><a href="https://reddit.com/r/SMAPI">Ask on Reddit</a></li> + <li><a href="@Url.PlainAction("Index", "Mods")">Mod compatibility list</a></li> + </ul> + (Or join the community!) +</div> - <h3>SMAPI @Model.BetaVersion.Version?</h3> - <div class="github-description"> - @Html.Raw(Markdig.Markdown.ToHtml(Model.BetaVersion.Description)) - </div> - <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="@Url.PlainAction("Index", "Mods")">mod compatibility list</a> for more info.</p> -} +<div class="area"> + @if (Model.BetaVersion == null) + { + <h2 id="whatsnew">What's new</h2> + <div class="github-description"> + @Html.Raw(Markdig.Markdown.ToHtml(Model.StableVersion.Description)) + </div> + <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="@Url.PlainAction("Index", "Mods")">mod compatibility list</a> for more info.</p> + } + else + { + <h2 id="whatsnew">What's new in...</h2> + <h3>SMAPI @Model.StableVersion.Version?</h3> + <div class="github-description"> + @Html.Raw(Markdig.Markdown.ToHtml(Model.StableVersion.Description)) + </div> + <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="@Url.PlainAction("Index", "Mods")">mod compatibility list</a> for more info.</p> -<h2 id="donate">Support SMAPI ♥</h2> -<p> - SMAPI is an open-source project by Pathoschild. It will always be free, but donations - are much appreciated to help pay for development, server hosting, domain fees, coffee, etc. -</p> + <h3>SMAPI @Model.BetaVersion.Version?</h3> + <div class="github-description"> + @Html.Raw(Markdig.Markdown.ToHtml(Model.BetaVersion.Description)) + </div> + <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="@Url.PlainAction("Index", "Mods")">mod compatibility list</a> for more info.</p> + } +</div> -<ul id="donate-links"> - <li> - <a href="https://www.patreon.com/pathoschild" class="donate-button"> - <img src="Content/images/patreon.png" /> Become a patron - </a> - </li> - <li> - <a href="https://ko-fi.com/pathoschild" class="donate-button"> - <img src="Content/images/ko-fi.png" /> Buy me a coffee - </a> - </li> - <li> - <a href="https://www.paypal.me/pathoschild" class="donate-button"> - <img src="Content/images/paypal.png" /> Donate via PayPal - </a> - </li> -</ul> +<div class="area"> + <h2 id="donate">Support SMAPI ♥</h2> + <p> + SMAPI is an open-source project by Pathoschild. It will always be free, but donations + are much appreciated to help pay for development, server hosting, domain fees, coffee, etc. + </p> -@if (!string.IsNullOrWhiteSpace(Model.SupporterList)) -{ - @Html.Raw(Markdig.Markdown.ToHtml( - $"Special thanks to {Model.SupporterList}, and a few anonymous users for their ongoing support on Patreon; you're awesome!" - )) -} + <ul id="donate-links"> + <li> + <a href="https://www.patreon.com/pathoschild" class="donate-button"> + <img src="Content/images/patreon.png" /> Become a patron + </a> + </li> + <li> + <a href="https://ko-fi.com/pathoschild" class="donate-button"> + <img src="Content/images/ko-fi.png" /> Buy me a coffee + </a> + </li> + <li> + <a href="https://www.paypal.me/pathoschild" class="donate-button"> + <img src="Content/images/paypal.png" /> Donate via PayPal + </a> + </li> + </ul> -<h2 id="modcreators">For mod creators</h2> -<ul> - <li><a href="@Model.StableVersion.DevDownloadUrl">SMAPI @Model.StableVersion.Version for developers</a> (includes <a href="https://docs.microsoft.com/en-us/visualstudio/ide/using-intellisense">intellisense</a> and full console output)</li> - @if (Model.BetaVersion != null) + @if (!string.IsNullOrWhiteSpace(Model.SupporterList)) { - <li><a href="@Model.BetaVersion.DevDownloadUrl">SMAPI @Model.BetaVersion.Version for developers</a> (includes <a href="https://docs.microsoft.com/en-us/visualstudio/ide/using-intellisense">intellisense</a> and full console output)</li> + @Html.Raw(Markdig.Markdown.ToHtml( + $"Special thanks to {Model.SupporterList}, and a few anonymous users for their ongoing support on Patreon; you're awesome!" + )) } - <li><a href="https://stardewvalleywiki.com/Modding:Index">Modding documentation</a></li> - <li>Need help? Come <a href="https://smapi.io/community">chat on Discord</a>.</li> -</ul> +</div> + + <h2 id="modcreators">For mod creators</h2> + <ul> + <li><a href="@Model.StableVersion.DevDownloadUrl">SMAPI @Model.StableVersion.Version for developers</a> (includes <a href="https://docs.microsoft.com/en-us/visualstudio/ide/using-intellisense">intellisense</a> and full console output)</li> + @if (Model.BetaVersion != null) + { + <li><a href="@Model.BetaVersion.DevDownloadUrl">SMAPI @Model.BetaVersion.Version for developers</a> (includes <a href="https://docs.microsoft.com/en-us/visualstudio/ide/using-intellisense">intellisense</a> and full console output)</li> + } + <li><a href="https://stardewvalleywiki.com/Modding:Index">Modding documentation</a></li> + <li><a href="https://github.com/Pathoschild/SMAPI">Source code</a></li> + </ul> diff --git a/src/SMAPI.Web/Views/Mods/Index.cshtml b/src/SMAPI.Web/Views/Mods/Index.cshtml index 50b59b45..5b310d55 100644 --- a/src/SMAPI.Web/Views/Mods/Index.cshtml +++ b/src/SMAPI.Web/Views/Mods/Index.cshtml @@ -110,11 +110,8 @@ else <small> <a v-bind:href="'#' + mod.Slug">#</a> <span v-show="showAdvanced"> - <template v-for="(link, i) in mod.MetadataLinks"> - <a v-bind:href="link.Item1">{{link.Item2}}</a> - </template> - - <abbr v-bind:title="mod.DevNote" v-show="mod.DevNote">[dev note]</abbr> + <a v-bind:href="mod.PullRequestUrl" v-if="mod.PullRequestUrl">PR</a> + <abbr v-bind:title="mod.DevNote" v-if="mod.DevNote">[dev note]</abbr> </span> </small> </td> diff --git a/src/SMAPI.Web/Views/Shared/_Layout.cshtml b/src/SMAPI.Web/Views/Shared/_Layout.cshtml index 17f1f673..2d06ceb1 100644 --- a/src/SMAPI.Web/Views/Shared/_Layout.cshtml +++ b/src/SMAPI.Web/Views/Shared/_Layout.cshtml @@ -29,12 +29,15 @@ </div> <div id="content-column"> <div id="content"> - <h1>@(ViewData["ViewTitle"] ?? ViewData["Title"])</h1> + @if (ViewData["ViewTitle"] != string.Empty) + { + <h1>@(ViewData["ViewTitle"] ?? ViewData["Title"])</h1> + } @RenderBody() </div> <div id="footer"> <div id="license"> - Hi! You can <a href="https://github.com/pathoschild/SMAPI" title="view source">view the source code</a> or <a href="https://github.com/pathoschild/SMAPI/issues" title="report issue">report a bug or suggestion</a>. + Hi! See the <a href="@Url.PlainAction("Privacy", "Index")">privacy page</a>, <a href="https://github.com/pathoschild/SMAPI" title="view source">SMAPI's source code</a>, or <a href="https://smapi.io/community" title="community pages">ask questions</a>. </div> </div> </div> diff --git a/src/SMAPI.Web/wwwroot/Content/css/index.css b/src/SMAPI.Web/wwwroot/Content/css/index.css index 93a85bed..1cf8d261 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/index.css +++ b/src/SMAPI.Web/wwwroot/Content/css/index.css @@ -21,12 +21,10 @@ h1 { #call-to-action a.main-cta, #call-to-action a.secondary-cta { box-shadow: #caefab 0 1px 0 0 inset; - background: linear-gradient(#77d42a 5%, #5cb811 100%) #77d42a; border-radius: 6px; border: 1px solid #268a16; display: inline-block; cursor: pointer; - color: #306108; font-weight: bold; margin-bottom: 1em; padding: 6px 24px; @@ -34,10 +32,16 @@ h1 { text-shadow: #aade7c 0 1px 0; } +#call-to-action a.main-cta { + background: linear-gradient(#77d42a 5%, #5cb811 75%) #77d42a; + font-size: 1.5em; + color: #306108; +} + #call-to-action a.secondary-cta { background: #768d87; border: 1px solid #566963; - color: #ffffff; + color: #eee; text-shadow: #2b665e 0 1px 0; } @@ -101,9 +105,24 @@ h1 { /********* ** Subsections *********/ -.github-description { - border-left: 0.25em solid #dfe2e5; - padding-left: 1em; +.area { + background: rgba(0, 170, 0, 0.2); + padding: 0 1em 1em 1em; + margin-bottom: 1em; +} + +.area > ul, +.area > div, +.area > p { + margin-left: 3em; +} + +.area > ul { + padding-left: 0; +} + +.area > h2 { + border: 0; } #donate-links li { @@ -114,12 +133,12 @@ h1 { #donate-links .donate-button { display: inline-block; min-width: 10em; - background: #2A413B; + background: #2a413b; padding: 6px 12px; font-family: Quicksand, Helvetica, Century Gothic, sans-serif; text-decoration: none; font-weight: 700; - color: #FFF; + color: #fff; border-radius: 8px; } diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index 97204d86..201d3166 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -20,10 +20,10 @@ namespace StardewModdingAPI ** Public ****/ /// <summary>SMAPI's current semantic version.</summary> - public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.1.0"); + public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.2.0"); /// <summary>The minimum supported version of Stardew Valley.</summary> - public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.4.0"); + public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.4.1"); /// <summary>The maximum supported version of Stardew Valley.</summary> public static ISemanticVersion MaximumGameVersion { get; } = null; @@ -55,12 +55,18 @@ namespace StardewModdingAPI /// <summary>The URL of the SMAPI home page.</summary> internal const string HomePageUrl = "https://smapi.io"; + /// <summary>The default performance counter name for unknown event handlers.</summary> + internal const string GamePerformanceCounterName = "<StardewValley>"; + /// <summary>The absolute path to the folder containing SMAPI's internal files.</summary> internal static readonly string InternalFilesPath = Program.DllSearchPath; /// <summary>The file path for the SMAPI configuration file.</summary> internal static string ApiConfigPath => Path.Combine(Constants.InternalFilesPath, "config.json"); + /// <summary>The file path for the overrides file for <see cref="ApiConfigPath"/>, which is applied over it.</summary> + internal static string ApiUserConfigPath => Path.Combine(Constants.InternalFilesPath, "config.user.json"); + /// <summary>The file path for the SMAPI metadata file.</summary> internal static string ApiMetadataPath => Path.Combine(Constants.InternalFilesPath, "metadata.json"); diff --git a/src/SMAPI/Framework/Content/ContentCache.cs b/src/SMAPI/Framework/Content/ContentCache.cs index f33ff84d..b0933ac6 100644 --- a/src/SMAPI/Framework/Content/ContentCache.cs +++ b/src/SMAPI/Framework/Content/ContentCache.cs @@ -4,7 +4,6 @@ using System.Diagnostics.Contracts; using System.Linq; using Microsoft.Xna.Framework; using StardewModdingAPI.Framework.Reflection; -using StardewModdingAPI.Internal; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs index 82d3805b..2fd31263 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; +using System.Threading; using Microsoft.Xna.Framework.Content; using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.ContentManagers; @@ -48,6 +50,10 @@ namespace StardewModdingAPI.Framework /// <summary>Whether the content coordinator has been disposed.</summary> private bool IsDisposed; + /// <summary>A lock used to prevent asynchronous changes to the content manager list.</summary> + /// <remarks>The game may adds content managers in asynchronous threads (e.g. when populating the load screen).</remarks> + private readonly ReaderWriterLockSlim ContentManagerLock = new ReaderWriterLockSlim(); + /********* ** Accessors @@ -59,10 +65,10 @@ namespace StardewModdingAPI.Framework public LocalizedContentManager.LanguageCode Language => this.MainContentManager.Language; /// <summary>Interceptors which provide the initial versions of matching assets.</summary> - public IDictionary<IModMetadata, IList<IAssetLoader>> Loaders { get; } = new Dictionary<IModMetadata, IList<IAssetLoader>>(); + public IList<ModLinked<IAssetLoader>> Loaders { get; } = new List<ModLinked<IAssetLoader>>(); /// <summary>Interceptors which edit matching assets after they're loaded.</summary> - public IDictionary<IModMetadata, IList<IAssetEditor>> Editors { get; } = new Dictionary<IModMetadata, IList<IAssetEditor>>(); + public IList<ModLinked<IAssetEditor>> Editors { get; } = new List<ModLinked<IAssetEditor>>(); /// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary> public string FullRootDirectory { get; } @@ -96,9 +102,12 @@ namespace StardewModdingAPI.Framework /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param> public GameContentManager CreateGameContentManager(string name) { - GameContentManager manager = new GameContentManager(name, this.MainContentManager.ServiceProvider, this.MainContentManager.RootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.OnDisposing, this.OnLoadingFirstAsset); - this.ContentManagers.Add(manager); - return manager; + return this.ContentManagerLock.InWriteLock(() => + { + GameContentManager manager = new GameContentManager(name, this.MainContentManager.ServiceProvider, this.MainContentManager.RootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.OnDisposing, this.OnLoadingFirstAsset); + this.ContentManagers.Add(manager); + return manager; + }); } /// <summary>Get a new content manager which handles reading files from a SMAPI mod folder with support for unpacked files.</summary> @@ -107,20 +116,23 @@ namespace StardewModdingAPI.Framework /// <param name="gameContentManager">The game content manager used for map tilesheets not provided by the mod.</param> public ModContentManager CreateModContentManager(string name, string rootDirectory, IContentManager gameContentManager) { - ModContentManager manager = new ModContentManager( - name: name, - gameContentManager: gameContentManager, - serviceProvider: this.MainContentManager.ServiceProvider, - rootDirectory: rootDirectory, - currentCulture: this.MainContentManager.CurrentCulture, - coordinator: this, - monitor: this.Monitor, - reflection: this.Reflection, - jsonHelper: this.JsonHelper, - onDisposing: this.OnDisposing - ); - this.ContentManagers.Add(manager); - return manager; + return this.ContentManagerLock.InWriteLock(() => + { + ModContentManager manager = new ModContentManager( + name: name, + gameContentManager: gameContentManager, + serviceProvider: this.MainContentManager.ServiceProvider, + rootDirectory: rootDirectory, + currentCulture: this.MainContentManager.CurrentCulture, + coordinator: this, + monitor: this.Monitor, + reflection: this.Reflection, + jsonHelper: this.JsonHelper, + onDisposing: this.OnDisposing + ); + this.ContentManagers.Add(manager); + return manager; + }); } /// <summary>Get the current content locale.</summary> @@ -132,8 +144,11 @@ namespace StardewModdingAPI.Framework /// <summary>Perform any cleanup needed when the locale changes.</summary> public void OnLocaleChanged() { - foreach (IContentManager contentManager in this.ContentManagers) - contentManager.OnLocaleChanged(); + this.ContentManagerLock.InReadLock(() => + { + foreach (IContentManager contentManager in this.ContentManagers) + contentManager.OnLocaleChanged(); + }); } /// <summary>Get whether this asset is mapped to a mod folder.</summary> @@ -180,7 +195,9 @@ namespace StardewModdingAPI.Framework public T LoadManagedAsset<T>(string contentManagerID, string relativePath) { // get content manager - IContentManager contentManager = this.ContentManagers.FirstOrDefault(p => p.IsNamespaced && p.Name == contentManagerID); + IContentManager contentManager = this.ContentManagerLock.InReadLock(() => + this.ContentManagers.FirstOrDefault(p => p.IsNamespaced && p.Name == contentManagerID) + ); if (contentManager == null) throw new InvalidOperationException($"The '{contentManagerID}' prefix isn't handled by any mod."); @@ -210,15 +227,18 @@ namespace StardewModdingAPI.Framework { // invalidate cache & track removed assets IDictionary<string, ISet<object>> removedAssets = new Dictionary<string, ISet<object>>(StringComparer.InvariantCultureIgnoreCase); - foreach (IContentManager contentManager in this.ContentManagers) + this.ContentManagerLock.InReadLock(() => { - foreach (var entry in contentManager.InvalidateCache(predicate, dispose)) + foreach (IContentManager contentManager in this.ContentManagers) { - if (!removedAssets.TryGetValue(entry.Key, out ISet<object> assets)) - removedAssets[entry.Key] = assets = new HashSet<object>(new ObjectReferenceComparer<object>()); - assets.Add(entry.Value); + foreach (var entry in contentManager.InvalidateCache(predicate, dispose)) + { + if (!removedAssets.TryGetValue(entry.Key, out ISet<object> assets)) + removedAssets[entry.Key] = assets = new HashSet<object>(new ObjectReferenceComparer<object>()); + assets.Add(entry.Value); + } } - } + }); // reload core game assets if (removedAssets.Any()) @@ -232,6 +252,23 @@ namespace StardewModdingAPI.Framework return removedAssets.Keys; } + /// <summary>Get all loaded instances of an asset name.</summary> + /// <param name="assetName">The asset name.</param> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "This method is provided for Content Patcher.")] + public IEnumerable<object> GetLoadedValues(string assetName) + { + return this.ContentManagerLock.InReadLock(() => + { + List<object> values = new List<object>(); + foreach (IContentManager content in this.ContentManagers.Where(p => !p.IsNamespaced && p.IsLoaded(assetName))) + { + object value = content.Load<object>(assetName, this.Language, useCache: true); + values.Add(value); + } + return values; + }); + } + /// <summary>Dispose held resources.</summary> public void Dispose() { @@ -244,6 +281,8 @@ namespace StardewModdingAPI.Framework contentManager.Dispose(); this.ContentManagers.Clear(); this.MainContentManager = null; + + this.ContentManagerLock.Dispose(); } @@ -257,7 +296,9 @@ namespace StardewModdingAPI.Framework if (this.IsDisposed) return; - this.ContentManagers.Remove(contentManager); + this.ContentManagerLock.InWriteLock(() => + this.ContentManagers.Remove(contentManager) + ); } } } diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs index 8930267d..eecdda74 100644 --- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs @@ -21,10 +21,10 @@ namespace StardewModdingAPI.Framework.ContentManagers private readonly ContextHash<string> AssetsBeingLoaded = new ContextHash<string>(); /// <summary>Interceptors which provide the initial versions of matching assets.</summary> - private IDictionary<IModMetadata, IList<IAssetLoader>> Loaders => this.Coordinator.Loaders; + private IList<ModLinked<IAssetLoader>> Loaders => this.Coordinator.Loaders; /// <summary>Interceptors which edit matching assets after they're loaded.</summary> - private IDictionary<IModMetadata, IList<IAssetEditor>> Editors => this.Coordinator.Editors; + private IList<ModLinked<IAssetEditor>> Editors => this.Coordinator.Editors; /// <summary>A lookup which indicates whether the asset is localizable (i.e. the filename contains the locale), if previously loaded.</summary> private readonly IDictionary<string, bool> IsLocalizableLookup; @@ -278,16 +278,16 @@ namespace StardewModdingAPI.Framework.ContentManagers private IAssetData ApplyLoader<T>(IAssetInfo info) { // find matching loaders - var loaders = this.GetInterceptors(this.Loaders) + var loaders = this.Loaders .Where(entry => { try { - return entry.Value.CanLoad<T>(info); + return entry.Data.CanLoad<T>(info); } catch (Exception ex) { - entry.Key.LogAsMod($"Mod failed when checking whether it could load asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + entry.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); return false; } }) @@ -298,14 +298,14 @@ namespace StardewModdingAPI.Framework.ContentManagers return null; if (loaders.Length > 1) { - string[] loaderNames = loaders.Select(p => p.Key.DisplayName).ToArray(); + string[] loaderNames = loaders.Select(p => p.Mod.DisplayName).ToArray(); this.Monitor.Log($"Multiple mods want to provide the '{info.AssetName}' asset ({string.Join(", ", loaderNames)}), but an asset can't be loaded multiple times. SMAPI will use the default asset instead; uninstall one of the mods to fix this. (Message for modders: you should usually use {typeof(IAssetEditor)} instead to avoid conflicts.)", LogLevel.Warn); return null; } // fetch asset from loader - IModMetadata mod = loaders[0].Key; - IAssetLoader loader = loaders[0].Value; + IModMetadata mod = loaders[0].Mod; + IAssetLoader loader = loaders[0].Data; T data; try { @@ -338,11 +338,11 @@ namespace StardewModdingAPI.Framework.ContentManagers IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName); // edit asset - foreach (var entry in this.GetInterceptors(this.Editors)) + foreach (var entry in this.Editors) { // check for match - IModMetadata mod = entry.Key; - IAssetEditor editor = entry.Value; + IModMetadata mod = entry.Mod; + IAssetEditor editor = entry.Data; try { if (!editor.CanEdit<T>(info)) @@ -382,19 +382,5 @@ namespace StardewModdingAPI.Framework.ContentManagers // return result return asset; } - - /// <summary>Get all registered interceptors from a list.</summary> - private IEnumerable<KeyValuePair<IModMetadata, T>> GetInterceptors<T>(IDictionary<IModMetadata, IList<T>> entries) - { - foreach (var entry in entries) - { - IModMetadata mod = entry.Key; - IList<T> interceptors = entry.Value; - - // registered editors - foreach (T interceptor in interceptors) - yield return new KeyValuePair<IModMetadata, T>(mod, interceptor); - } - } } } diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index fdf76b24..0a526fc8 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -154,6 +154,7 @@ namespace StardewModdingAPI.Framework.ContentManagers // unpacked map case ".tbin": + case ".tmx": { // validate if (typeof(T) != typeof(Map)) diff --git a/src/SMAPI/Framework/Events/EventManager.cs b/src/SMAPI/Framework/Events/EventManager.cs index 892cbc7b..a9dfda97 100644 --- a/src/SMAPI/Framework/Events/EventManager.cs +++ b/src/SMAPI/Framework/Events/EventManager.cs @@ -1,5 +1,8 @@ +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Reflection; using StardewModdingAPI.Events; +using StardewModdingAPI.Framework.PerformanceMonitoring; namespace StardewModdingAPI.Framework.Events { @@ -173,28 +176,32 @@ namespace StardewModdingAPI.Framework.Events /// <summary>Construct an instance.</summary> /// <param name="monitor">Writes messages to the log.</param> /// <param name="modRegistry">The mod registry with which to identify mods.</param> - public EventManager(IMonitor monitor, ModRegistry modRegistry) + /// <param name="performanceMonitor">Tracks performance metrics.</param> + public EventManager(IMonitor monitor, ModRegistry modRegistry, PerformanceMonitor performanceMonitor) { // create shortcut initializers - ManagedEvent<TEventArgs> ManageEventOf<TEventArgs>(string typeName, string eventName) => new ManagedEvent<TEventArgs>($"{typeName}.{eventName}", monitor, modRegistry); + ManagedEvent<TEventArgs> ManageEventOf<TEventArgs>(string typeName, string eventName, bool isPerformanceCritical = false) + { + return new ManagedEvent<TEventArgs>($"{typeName}.{eventName}", monitor, modRegistry, performanceMonitor, isPerformanceCritical); + } // init events (new) this.MenuChanged = ManageEventOf<MenuChangedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.MenuChanged)); - this.Rendering = ManageEventOf<RenderingEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendering)); - this.Rendered = ManageEventOf<RenderedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendered)); - this.RenderingWorld = ManageEventOf<RenderingWorldEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingWorld)); - this.RenderedWorld = ManageEventOf<RenderedWorldEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedWorld)); - this.RenderingActiveMenu = ManageEventOf<RenderingActiveMenuEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingActiveMenu)); - this.RenderedActiveMenu = ManageEventOf<RenderedActiveMenuEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedActiveMenu)); - this.RenderingHud = ManageEventOf<RenderingHudEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingHud)); - this.RenderedHud = ManageEventOf<RenderedHudEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedHud)); + this.Rendering = ManageEventOf<RenderingEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendering), isPerformanceCritical: true); + this.Rendered = ManageEventOf<RenderedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendered), isPerformanceCritical: true); + this.RenderingWorld = ManageEventOf<RenderingWorldEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingWorld), isPerformanceCritical: true); + this.RenderedWorld = ManageEventOf<RenderedWorldEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedWorld), isPerformanceCritical: true); + this.RenderingActiveMenu = ManageEventOf<RenderingActiveMenuEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingActiveMenu), isPerformanceCritical: true); + this.RenderedActiveMenu = ManageEventOf<RenderedActiveMenuEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedActiveMenu), isPerformanceCritical: true); + this.RenderingHud = ManageEventOf<RenderingHudEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingHud), isPerformanceCritical: true); + this.RenderedHud = ManageEventOf<RenderedHudEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedHud), isPerformanceCritical: true); this.WindowResized = ManageEventOf<WindowResizedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.WindowResized)); this.GameLaunched = ManageEventOf<GameLaunchedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.GameLaunched)); - this.UpdateTicking = ManageEventOf<UpdateTickingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicking)); - this.UpdateTicked = ManageEventOf<UpdateTickedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicked)); - this.OneSecondUpdateTicking = ManageEventOf<OneSecondUpdateTickingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.OneSecondUpdateTicking)); - this.OneSecondUpdateTicked = ManageEventOf<OneSecondUpdateTickedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.OneSecondUpdateTicked)); + this.UpdateTicking = ManageEventOf<UpdateTickingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicking), isPerformanceCritical: true); + this.UpdateTicked = ManageEventOf<UpdateTickedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicked), isPerformanceCritical: true); + this.OneSecondUpdateTicking = ManageEventOf<OneSecondUpdateTickingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.OneSecondUpdateTicking), isPerformanceCritical: true); + this.OneSecondUpdateTicked = ManageEventOf<OneSecondUpdateTickedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.OneSecondUpdateTicked), isPerformanceCritical: true); this.SaveCreating = ManageEventOf<SaveCreatingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.SaveCreating)); this.SaveCreated = ManageEventOf<SaveCreatedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.SaveCreated)); this.Saving = ManageEventOf<SavingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.Saving)); @@ -207,7 +214,7 @@ namespace StardewModdingAPI.Framework.Events this.ButtonPressed = ManageEventOf<ButtonPressedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonPressed)); this.ButtonReleased = ManageEventOf<ButtonReleasedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonReleased)); - this.CursorMoved = ManageEventOf<CursorMovedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.CursorMoved)); + this.CursorMoved = ManageEventOf<CursorMovedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.CursorMoved), isPerformanceCritical: true); this.MouseWheelScrolled = ManageEventOf<MouseWheelScrolledEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.MouseWheelScrolled)); this.PeerContextReceived = ManageEventOf<PeerContextReceivedEventArgs>(nameof(IModEvents.Multiplayer), nameof(IMultiplayerEvents.PeerContextReceived)); @@ -228,8 +235,15 @@ namespace StardewModdingAPI.Framework.Events this.TerrainFeatureListChanged = ManageEventOf<TerrainFeatureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.TerrainFeatureListChanged)); this.LoadStageChanged = ManageEventOf<LoadStageChangedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.LoadStageChanged)); - this.UnvalidatedUpdateTicking = ManageEventOf<UnvalidatedUpdateTickingEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicking)); - this.UnvalidatedUpdateTicked = ManageEventOf<UnvalidatedUpdateTickedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicked)); + this.UnvalidatedUpdateTicking = ManageEventOf<UnvalidatedUpdateTickingEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicking), isPerformanceCritical: true); + this.UnvalidatedUpdateTicked = ManageEventOf<UnvalidatedUpdateTickedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicked), isPerformanceCritical: true); + } + + /// <summary>Get all managed events.</summary> + public IEnumerable<IManagedEvent> GetAllEvents() + { + foreach (FieldInfo field in this.GetType().GetFields()) + yield return (IManagedEvent)field.GetValue(this); } } } diff --git a/src/SMAPI/Framework/Events/IManagedEvent.cs b/src/SMAPI/Framework/Events/IManagedEvent.cs new file mode 100644 index 00000000..e4e3ca08 --- /dev/null +++ b/src/SMAPI/Framework/Events/IManagedEvent.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Framework.Events +{ + /// <summary>Metadata for an event raised by SMAPI.</summary> + internal interface IManagedEvent + { + /********* + ** Accessors + *********/ + /// <summary>A human-readable name for the event.</summary> + string EventName { get; } + + /// <summary>Whether the event is typically called at least once per second.</summary> + bool IsPerformanceCritical { get; } + } +} diff --git a/src/SMAPI/Framework/Events/ManagedEvent.cs b/src/SMAPI/Framework/Events/ManagedEvent.cs index 2afe7a03..118b73ac 100644 --- a/src/SMAPI/Framework/Events/ManagedEvent.cs +++ b/src/SMAPI/Framework/Events/ManagedEvent.cs @@ -1,12 +1,13 @@ using System; using System.Collections.Generic; using System.Linq; +using StardewModdingAPI.Framework.PerformanceMonitoring; namespace StardewModdingAPI.Framework.Events { /// <summary>An event wrapper which intercepts and logs errors in handler code.</summary> /// <typeparam name="TEventArgs">The event arguments type.</typeparam> - internal class ManagedEvent<TEventArgs> + internal class ManagedEvent<TEventArgs> : IManagedEvent { /********* ** Fields @@ -14,9 +15,6 @@ namespace StardewModdingAPI.Framework.Events /// <summary>The underlying event.</summary> private event EventHandler<TEventArgs> Event; - /// <summary>A human-readable name for the event.</summary> - private readonly string EventName; - /// <summary>Writes messages to the log.</summary> private readonly IMonitor Monitor; @@ -29,6 +27,19 @@ namespace StardewModdingAPI.Framework.Events /// <summary>The cached invocation list.</summary> private EventHandler<TEventArgs>[] CachedInvocationList; + /// <summary>Tracks performance metrics.</summary> + private readonly PerformanceMonitor PerformanceMonitor; + + + /********* + ** Accessors + *********/ + /// <summary>A human-readable name for the event.</summary> + public string EventName { get; } + + /// <summary>Whether the event is typically called at least once per second.</summary> + public bool IsPerformanceCritical { get; } + /********* ** Public methods @@ -37,11 +48,15 @@ namespace StardewModdingAPI.Framework.Events /// <param name="eventName">A human-readable name for the event.</param> /// <param name="monitor">Writes messages to the log.</param> /// <param name="modRegistry">The mod registry with which to identify mods.</param> - public ManagedEvent(string eventName, IMonitor monitor, ModRegistry modRegistry) + /// <param name="performanceMonitor">Tracks performance metrics.</param> + /// <param name="isPerformanceCritical">Whether the event is typically called at least once per second.</param> + public ManagedEvent(string eventName, IMonitor monitor, ModRegistry modRegistry, PerformanceMonitor performanceMonitor, bool isPerformanceCritical = false) { this.EventName = eventName; this.Monitor = monitor; this.ModRegistry = modRegistry; + this.PerformanceMonitor = performanceMonitor; + this.IsPerformanceCritical = isPerformanceCritical; } /// <summary>Get whether anything is listening to the event.</summary> @@ -81,17 +96,21 @@ namespace StardewModdingAPI.Framework.Events if (this.Event == null) return; - foreach (EventHandler<TEventArgs> handler in this.CachedInvocationList) + + this.PerformanceMonitor.Track(this.EventName, () => { - try + foreach (EventHandler<TEventArgs> handler in this.CachedInvocationList) { - handler.Invoke(null, args); - } - catch (Exception ex) - { - this.LogError(handler, ex); + try + { + this.PerformanceMonitor.Track(this.EventName, this.GetModNameForPerformanceCounters(handler), () => handler.Invoke(null, args)); + } + catch (Exception ex) + { + this.LogError(handler, ex); + } } - } + }); } /// <summary>Raise the event and notify all handlers.</summary> @@ -122,6 +141,19 @@ namespace StardewModdingAPI.Framework.Events /********* ** Private methods *********/ + /// <summary>Get the mod name for a given event handler to display in performance monitoring reports.</summary> + /// <param name="handler">The event handler.</param> + private string GetModNameForPerformanceCounters(EventHandler<TEventArgs> handler) + { + IModMetadata mod = this.GetSourceMod(handler); + if (mod == null) + return Constants.GamePerformanceCounterName; + + return mod.HasManifest() + ? mod.Manifest.UniqueID + : mod.DisplayName; + } + /// <summary>Track an event handler.</summary> /// <param name="mod">The mod which added the handler.</param> /// <param name="handler">The event handler.</param> diff --git a/src/SMAPI/Framework/GameVersion.cs b/src/SMAPI/Framework/GameVersion.cs index 29cfbc39..07957624 100644 --- a/src/SMAPI/Framework/GameVersion.cs +++ b/src/SMAPI/Framework/GameVersion.cs @@ -3,8 +3,8 @@ using System.Collections.Generic; namespace StardewModdingAPI.Framework { - /// <summary>An implementation of <see cref="ISemanticVersion"/> that correctly handles the non-semantic versions used by older Stardew Valley releases.</summary> - internal class GameVersion : SemanticVersion + /// <summary>An extension of <see cref="ISemanticVersion"/> that correctly handles non-semantic versions used by Stardew Valley.</summary> + internal class GameVersion : Toolkit.SemanticVersion { /********* ** Private methods @@ -18,11 +18,11 @@ namespace StardewModdingAPI.Framework ["1.03"] = "1.0.3", ["1.04"] = "1.0.4", ["1.05"] = "1.0.5", - ["1.051"] = "1.0.6-prerelease1", // not a very good mapping, but good enough for SMAPI's purposes. - ["1.051b"] = "1.0.6-prerelease2", + ["1.051"] = "1.0.5.1", + ["1.051b"] = "1.0.5.2", ["1.06"] = "1.0.6", ["1.07"] = "1.0.7", - ["1.07a"] = "1.0.8-prerelease1", + ["1.07a"] = "1.0.7.1", ["1.08"] = "1.0.8", ["1.1"] = "1.1.0", ["1.2"] = "1.2.0", @@ -36,7 +36,7 @@ namespace StardewModdingAPI.Framework /// <summary>Construct an instance.</summary> /// <param name="version">The game version string.</param> public GameVersion(string version) - : base(GameVersion.GetSemanticVersionString(version)) { } + : base(GameVersion.GetSemanticVersionString(version), allowNonStandard: true) { } /// <summary>Get a string representation of the version.</summary> public override string ToString() @@ -53,33 +53,21 @@ namespace StardewModdingAPI.Framework private static string GetSemanticVersionString(string gameVersion) { // mapped version - if (GameVersion.VersionMap.TryGetValue(gameVersion, out string semanticVersion)) - return semanticVersion; - - // special case: four-part versions - string[] parts = gameVersion.Split('.'); - if (parts.Length == 4) - return $"{parts[0]}.{parts[1]}.{parts[2]}+{parts[3]}"; - - return gameVersion; + return GameVersion.VersionMap.TryGetValue(gameVersion, out string semanticVersion) + ? semanticVersion + : gameVersion; } /// <summary>Convert a semantic version string to the equivalent game version string.</summary> /// <param name="semanticVersion">The semantic version string.</param> private static string GetGameVersionString(string semanticVersion) { - // mapped versions foreach (var mapping in GameVersion.VersionMap) { if (mapping.Value.Equals(semanticVersion, StringComparison.InvariantCultureIgnoreCase)) return mapping.Key; } - // special case: four-part versions - string[] parts = semanticVersion.Split('.', '+'); - if (parts.Length == 4) - return $"{parts[0]}.{parts[1]}.{parts[2]}.{parts[3]}"; - return semanticVersion; } } diff --git a/src/SMAPI/Framework/InternalExtensions.cs b/src/SMAPI/Framework/InternalExtensions.cs index c3155b1c..8b45e196 100644 --- a/src/SMAPI/Framework/InternalExtensions.cs +++ b/src/SMAPI/Framework/InternalExtensions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Reflection; +using System.Threading; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.Events; using StardewModdingAPI.Framework.Reflection; @@ -84,6 +85,75 @@ namespace StardewModdingAPI.Framework } /**** + ** ReaderWriterLockSlim + ****/ + /// <summary>Run code within a read lock.</summary> + /// <param name="lock">The lock to set.</param> + /// <param name="action">The action to perform.</param> + public static void InReadLock(this ReaderWriterLockSlim @lock, Action action) + { + @lock.EnterReadLock(); + try + { + action(); + } + finally + { + @lock.ExitReadLock(); + } + } + + /// <summary>Run code within a read lock.</summary> + /// <typeparam name="TReturn">The action's return value.</typeparam> + /// <param name="lock">The lock to set.</param> + /// <param name="action">The action to perform.</param> + public static TReturn InReadLock<TReturn>(this ReaderWriterLockSlim @lock, Func<TReturn> action) + { + @lock.EnterReadLock(); + try + { + return action(); + } + finally + { + @lock.ExitReadLock(); + } + } + + /// <summary>Run code within a write lock.</summary> + /// <param name="lock">The lock to set.</param> + /// <param name="action">The action to perform.</param> + public static void InWriteLock(this ReaderWriterLockSlim @lock, Action action) + { + @lock.EnterWriteLock(); + try + { + action(); + } + finally + { + @lock.ExitWriteLock(); + } + } + + /// <summary>Run code within a write lock.</summary> + /// <typeparam name="TReturn">The action's return value.</typeparam> + /// <param name="lock">The lock to set.</param> + /// <param name="action">The action to perform.</param> + public static TReturn InWriteLock<TReturn>(this ReaderWriterLockSlim @lock, Func<TReturn> action) + { + @lock.EnterWriteLock(); + try + { + return action(); + } + finally + { + @lock.ExitWriteLock(); + } + } + + /**** ** Sprite batch ****/ /// <summary>Get whether the sprite batch is between a begin and end pair.</summary> diff --git a/src/SMAPI/Framework/ModHelpers/DataHelper.cs b/src/SMAPI/Framework/ModHelpers/DataHelper.cs index 3d43c539..6cde849c 100644 --- a/src/SMAPI/Framework/ModHelpers/DataHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/DataHelper.cs @@ -177,7 +177,7 @@ namespace StardewModdingAPI.Framework.ModHelpers private string GetGlobalDataPath(string key) { this.AssertSlug(key, nameof(key)); - return Path.Combine(Constants.SavesPath, ".smapi", "mod-data", this.ModID.ToLower(), $"{key}.json".ToLower()); + return Path.Combine(Constants.DataPath, ".smapi", "mod-data", this.ModID.ToLower(), $"{key}.json".ToLower()); } /// <summary>Assert that a key contains only characters that are safe in all contexts.</summary> diff --git a/src/SMAPI/Framework/ModLinked.cs b/src/SMAPI/Framework/ModLinked.cs new file mode 100644 index 00000000..8cfe6f5f --- /dev/null +++ b/src/SMAPI/Framework/ModLinked.cs @@ -0,0 +1,29 @@ +namespace StardewModdingAPI.Framework +{ + /// <summary>A generic tuple which links something to a mod.</summary> + /// <typeparam name="T">The interceptor type.</typeparam> + internal class ModLinked<T> + { + /********* + ** Accessors + *********/ + /// <summary>The mod metadata.</summary> + public IModMetadata Mod { get; } + + /// <summary>The instance linked to the mod.</summary> + public T Data { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="mod">The mod metadata.</param> + /// <param name="data">The instance linked to the mod.</param> + public ModLinked(IModMetadata mod, T data) + { + this.Mod = mod; + this.Data = data; + } + } +} diff --git a/src/SMAPI/Framework/PerformanceMonitoring/AlertContext.cs b/src/SMAPI/Framework/PerformanceMonitoring/AlertContext.cs new file mode 100644 index 00000000..01197f74 --- /dev/null +++ b/src/SMAPI/Framework/PerformanceMonitoring/AlertContext.cs @@ -0,0 +1,34 @@ +namespace StardewModdingAPI.Framework.PerformanceMonitoring +{ + /// <summary>The context for an alert.</summary> + internal struct AlertContext + { + /********* + ** Accessors + *********/ + /// <summary>The source which triggered the alert.</summary> + public string Source { get; } + + /// <summary>The elapsed milliseconds.</summary> + public double Elapsed { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="source">The source which triggered the alert.</param> + /// <param name="elapsed">The elapsed milliseconds.</param> + public AlertContext(string source, double elapsed) + { + this.Source = source; + this.Elapsed = elapsed; + } + + /// <summary>Get a human-readable text form of this instance.</summary> + public override string ToString() + { + return $"{this.Source}: {this.Elapsed:F2}ms"; + } + } +} diff --git a/src/SMAPI/Framework/PerformanceMonitoring/AlertEntry.cs b/src/SMAPI/Framework/PerformanceMonitoring/AlertEntry.cs new file mode 100644 index 00000000..f5b80189 --- /dev/null +++ b/src/SMAPI/Framework/PerformanceMonitoring/AlertEntry.cs @@ -0,0 +1,38 @@ +namespace StardewModdingAPI.Framework.PerformanceMonitoring +{ + /// <summary>A single alert entry.</summary> + internal struct AlertEntry + { + /********* + ** Accessors + *********/ + /// <summary>The collection in which the alert occurred.</summary> + public PerformanceCounterCollection Collection { get; } + + /// <summary>The actual execution time in milliseconds.</summary> + public double ExecutionTimeMilliseconds { get; } + + /// <summary>The configured alert threshold in milliseconds.</summary> + public double ThresholdMilliseconds { get; } + + /// <summary>The sources involved in exceeding the threshold.</summary> + public AlertContext[] Context { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="collection">The collection in which the alert occurred.</param> + /// <param name="executionTimeMilliseconds">The actual execution time in milliseconds.</param> + /// <param name="thresholdMilliseconds">The configured alert threshold in milliseconds.</param> + /// <param name="context">The sources involved in exceeding the threshold.</param> + public AlertEntry(PerformanceCounterCollection collection, double executionTimeMilliseconds, double thresholdMilliseconds, AlertContext[] context) + { + this.Collection = collection; + this.ExecutionTimeMilliseconds = executionTimeMilliseconds; + this.ThresholdMilliseconds = thresholdMilliseconds; + this.Context = context; + } + } +} diff --git a/src/SMAPI/Framework/PerformanceMonitoring/PeakEntry.cs b/src/SMAPI/Framework/PerformanceMonitoring/PeakEntry.cs new file mode 100644 index 00000000..cff502ad --- /dev/null +++ b/src/SMAPI/Framework/PerformanceMonitoring/PeakEntry.cs @@ -0,0 +1,35 @@ +using System; + +namespace StardewModdingAPI.Framework.PerformanceMonitoring +{ + /// <summary>A peak invocation time.</summary> + internal struct PeakEntry + { + /********* + ** Accessors + *********/ + /// <summary>The actual execution time in milliseconds.</summary> + public double ExecutionTimeMilliseconds { get; } + + /// <summary>When the entry occurred.</summary> + public DateTime EventTime { get; } + + /// <summary>The sources involved in exceeding the threshold.</summary> + public AlertContext[] Context { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="executionTimeMilliseconds">The actual execution time in milliseconds.</param> + /// <param name="eventTime">When the entry occurred.</param> + /// <param name="context">The sources involved in exceeding the threshold.</param> + public PeakEntry(double executionTimeMilliseconds, DateTime eventTime, AlertContext[] context) + { + this.ExecutionTimeMilliseconds = executionTimeMilliseconds; + this.EventTime = eventTime; + this.Context = context; + } + } +} diff --git a/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounter.cs b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounter.cs new file mode 100644 index 00000000..3cf668ee --- /dev/null +++ b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounter.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Harmony; + +namespace StardewModdingAPI.Framework.PerformanceMonitoring +{ + /// <summary>Tracks metadata about a particular code event.</summary> + internal class PerformanceCounter + { + /********* + ** Fields + *********/ + /// <summary>The size of the ring buffer.</summary> + private readonly int MaxEntries = 16384; + + /// <summary>The collection to which this performance counter belongs.</summary> + private readonly PerformanceCounterCollection ParentCollection; + + /// <summary>The performance counter entries.</summary> + private readonly Stack<PerformanceCounterEntry> Entries; + + /// <summary>The entry with the highest execution time.</summary> + private PerformanceCounterEntry? PeakPerformanceCounterEntry; + + + /********* + ** Accessors + *********/ + /// <summary>The name of the source.</summary> + public string Source { get; } + + /// <summary>The alert threshold in milliseconds</summary> + public double AlertThresholdMilliseconds { get; set; } + + /// <summary>If alerting is enabled or not</summary> + public bool EnableAlerts { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="parentCollection">The collection to which this performance counter belongs.</param> + /// <param name="source">The name of the source.</param> + public PerformanceCounter(PerformanceCounterCollection parentCollection, string source) + { + this.ParentCollection = parentCollection; + this.Source = source; + this.Entries = new Stack<PerformanceCounterEntry>(this.MaxEntries); + } + + /// <summary>Add a performance counter entry to the list, update monitoring, and raise alerts if needed.</summary> + /// <param name="entry">The entry to add.</param> + public void Add(PerformanceCounterEntry entry) + { + // add entry + if (this.Entries.Count > this.MaxEntries) + this.Entries.Pop(); + this.Entries.Add(entry); + + // update metrics + if (this.PeakPerformanceCounterEntry == null || entry.ElapsedMilliseconds > this.PeakPerformanceCounterEntry.Value.ElapsedMilliseconds) + this.PeakPerformanceCounterEntry = entry; + + // raise alert + if (this.EnableAlerts && entry.ElapsedMilliseconds > this.AlertThresholdMilliseconds) + this.ParentCollection.AddAlert(entry.ElapsedMilliseconds, this.AlertThresholdMilliseconds, new AlertContext(this.Source, entry.ElapsedMilliseconds)); + } + + /// <summary>Clear all performance counter entries and monitoring.</summary> + public void Reset() + { + this.Entries.Clear(); + this.PeakPerformanceCounterEntry = null; + } + + /// <summary>Get the peak entry.</summary> + public PerformanceCounterEntry? GetPeak() + { + return this.PeakPerformanceCounterEntry; + } + + /// <summary>Get the entry with the highest execution time.</summary> + /// <param name="range">The time range to search.</param> + /// <param name="endTime">The end time for the <paramref name="range"/>, or null for the current time.</param> + public PerformanceCounterEntry? GetPeak(TimeSpan range, DateTime? endTime = null) + { + endTime ??= DateTime.UtcNow; + DateTime startTime = endTime.Value.Subtract(range); + + return this.Entries + .Where(entry => entry.EventTime >= startTime && entry.EventTime <= endTime) + .OrderByDescending(x => x.ElapsedMilliseconds) + .FirstOrDefault(); + } + + /// <summary>Get the last entry added to the list.</summary> + public PerformanceCounterEntry? GetLastEntry() + { + if (this.Entries.Count == 0) + return null; + + return this.Entries.Peek(); + } + + /// <summary>Get the average over a given time span.</summary> + /// <param name="range">The time range to search.</param> + /// <param name="endTime">The end time for the <paramref name="range"/>, or null for the current time.</param> + public double GetAverage(TimeSpan range, DateTime? endTime = null) + { + endTime ??= DateTime.UtcNow; + DateTime startTime = endTime.Value.Subtract(range); + + double[] entries = this.Entries + .Where(entry => entry.EventTime >= startTime && entry.EventTime <= endTime) + .Select(p => p.ElapsedMilliseconds) + .ToArray(); + + return entries.Length > 0 + ? entries.Average() + : 0; + } + } +} diff --git a/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterCollection.cs b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterCollection.cs new file mode 100644 index 00000000..0bb78c74 --- /dev/null +++ b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterCollection.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace StardewModdingAPI.Framework.PerformanceMonitoring +{ + internal class PerformanceCounterCollection + { + /********* + ** Fields + *********/ + /// <summary>The number of peak invocations to keep.</summary> + private readonly int MaxEntries = 16384; + + /// <summary>The sources involved in exceeding alert thresholds.</summary> + private readonly List<AlertContext> TriggeredPerformanceCounters = new List<AlertContext>(); + + /// <summary>The stopwatch used to track the invocation time.</summary> + private readonly Stopwatch InvocationStopwatch = new Stopwatch(); + + /// <summary>The performance counter manager.</summary> + private readonly PerformanceMonitor PerformanceMonitor; + + /// <summary>The time to calculate average calls per second.</summary> + private DateTime CallsPerSecondStart = DateTime.UtcNow; + + /// <summary>The number of invocations.</summary> + private long CallCount; + + /// <summary>The peak invocations.</summary> + private readonly Stack<PeakEntry> PeakInvocations; + + + /********* + ** Accessors + *********/ + /// <summary>The associated performance counters.</summary> + public IDictionary<string, PerformanceCounter> PerformanceCounters { get; } = new Dictionary<string, PerformanceCounter>(); + + /// <summary>The name of this collection.</summary> + public string Name { get; } + + /// <summary>Whether the source is typically invoked at least once per second.</summary> + public bool IsPerformanceCritical { get; } + + /// <summary>The alert threshold in milliseconds.</summary> + public double AlertThresholdMilliseconds { get; set; } + + /// <summary>Whether alerts are enabled.</summary> + public bool EnableAlerts { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="performanceMonitor">The performance counter manager.</param> + /// <param name="name">The name of this collection.</param> + /// <param name="isPerformanceCritical">Whether the source is typically invoked at least once per second.</param> + public PerformanceCounterCollection(PerformanceMonitor performanceMonitor, string name, bool isPerformanceCritical = false) + { + this.PeakInvocations = new Stack<PeakEntry>(this.MaxEntries); + this.Name = name; + this.PerformanceMonitor = performanceMonitor; + this.IsPerformanceCritical = isPerformanceCritical; + } + + /// <summary>Track a single invocation for a named source.</summary> + /// <param name="source">The name of the source.</param> + /// <param name="entry">The entry.</param> + public void Track(string source, PerformanceCounterEntry entry) + { + // add entry + if (!this.PerformanceCounters.ContainsKey(source)) + this.PerformanceCounters.Add(source, new PerformanceCounter(this, source)); + this.PerformanceCounters[source].Add(entry); + + // raise alert + if (this.EnableAlerts) + this.TriggeredPerformanceCounters.Add(new AlertContext(source, entry.ElapsedMilliseconds)); + } + + /// <summary>Get the average execution time for all non-game internal sources in milliseconds.</summary> + /// <param name="interval">The interval for which to get the average, relative to now</param> + public double GetModsAverageExecutionTime(TimeSpan interval) + { + return this.PerformanceCounters + .Where(entry => entry.Key != Constants.GamePerformanceCounterName) + .Sum(entry => entry.Value.GetAverage(interval)); + } + + /// <summary>Get the overall average execution time in milliseconds.</summary> + /// <param name="interval">The interval for which to get the average, relative to now</param> + public double GetAverageExecutionTime(TimeSpan interval) + { + return this.PerformanceCounters + .Sum(entry => entry.Value.GetAverage(interval)); + } + + /// <summary>Get the average execution time for game-internal sources in milliseconds.</summary> + public double GetGameAverageExecutionTime(TimeSpan interval) + { + return this.PerformanceCounters.TryGetValue(Constants.GamePerformanceCounterName, out PerformanceCounter gameExecTime) + ? gameExecTime.GetAverage(interval) + : 0; + } + + /// <summary>Get the peak execution time in milliseconds.</summary> + /// <param name="range">The time range to search.</param> + /// <param name="endTime">The end time for the <paramref name="range"/>, or null for the current time.</param> + public double GetPeakExecutionTime(TimeSpan range, DateTime? endTime = null) + { + if (this.PeakInvocations.Count == 0) + return 0; + + endTime ??= DateTime.UtcNow; + DateTime startTime = endTime.Value.Subtract(range); + + return this.PeakInvocations + .Where(entry => entry.EventTime >= startTime && entry.EventTime <= endTime) + .OrderByDescending(x => x.ExecutionTimeMilliseconds) + .Select(p => p.ExecutionTimeMilliseconds) + .FirstOrDefault(); + } + + /// <summary>Start tracking the invocation of this collection.</summary> + public void BeginTrackInvocation() + { + this.TriggeredPerformanceCounters.Clear(); + this.InvocationStopwatch.Reset(); + this.InvocationStopwatch.Start(); + + this.CallCount++; + } + + /// <summary>End tracking the invocation of this collection, and raise an alert if needed.</summary> + public void EndTrackInvocation() + { + this.InvocationStopwatch.Stop(); + + // add invocation + if (this.PeakInvocations.Count >= this.MaxEntries) + this.PeakInvocations.Pop(); + this.PeakInvocations.Push(new PeakEntry(this.InvocationStopwatch.Elapsed.TotalMilliseconds, DateTime.UtcNow, this.TriggeredPerformanceCounters.ToArray())); + + // raise alert + if (this.EnableAlerts && this.InvocationStopwatch.Elapsed.TotalMilliseconds >= this.AlertThresholdMilliseconds) + this.AddAlert(this.InvocationStopwatch.Elapsed.TotalMilliseconds, this.AlertThresholdMilliseconds, this.TriggeredPerformanceCounters.ToArray()); + } + + /// <summary>Add an alert.</summary> + /// <param name="executionTimeMilliseconds">The execution time in milliseconds.</param> + /// <param name="thresholdMilliseconds">The configured threshold.</param> + /// <param name="alerts">The sources involved in exceeding the threshold.</param> + public void AddAlert(double executionTimeMilliseconds, double thresholdMilliseconds, AlertContext[] alerts) + { + this.PerformanceMonitor.AddAlert( + new AlertEntry(this, executionTimeMilliseconds, thresholdMilliseconds, alerts) + ); + } + + /// <summary>Add an alert.</summary> + /// <param name="executionTimeMilliseconds">The execution time in milliseconds.</param> + /// <param name="thresholdMilliseconds">The configured threshold.</param> + /// <param name="alert">The source involved in exceeding the threshold.</param> + public void AddAlert(double executionTimeMilliseconds, double thresholdMilliseconds, AlertContext alert) + { + this.AddAlert(executionTimeMilliseconds, thresholdMilliseconds, new[] { alert }); + } + + /// <summary>Reset the calls per second counter.</summary> + public void ResetCallsPerSecond() + { + this.CallCount = 0; + this.CallsPerSecondStart = DateTime.UtcNow; + } + + /// <summary>Reset all performance counters in this collection.</summary> + public void Reset() + { + this.PeakInvocations.Clear(); + foreach (var counter in this.PerformanceCounters) + counter.Value.Reset(); + } + + /// <summary>Reset the performance counter for a specific source.</summary> + /// <param name="source">The source name.</param> + public void ResetSource(string source) + { + foreach (var i in this.PerformanceCounters) + if (i.Value.Source.Equals(source, StringComparison.InvariantCultureIgnoreCase)) + i.Value.Reset(); + } + + /// <summary>Get the average calls per second.</summary> + public long GetAverageCallsPerSecond() + { + long runtimeInSeconds = (long)DateTime.UtcNow.Subtract(this.CallsPerSecondStart).TotalSeconds; + return runtimeInSeconds > 0 + ? this.CallCount / runtimeInSeconds + : 0; + } + } +} diff --git a/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterEntry.cs b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterEntry.cs new file mode 100644 index 00000000..8adbd88d --- /dev/null +++ b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterEntry.cs @@ -0,0 +1,30 @@ +using System; + +namespace StardewModdingAPI.Framework.PerformanceMonitoring +{ + /// <summary>A single performance counter entry.</summary> + internal struct PerformanceCounterEntry + { + /********* + ** Accessors + *********/ + /// <summary>When the entry occurred.</summary> + public DateTime EventTime { get; } + + /// <summary>The elapsed milliseconds.</summary> + public double ElapsedMilliseconds { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="eventTime">When the entry occurred.</param> + /// <param name="elapsedMilliseconds">The elapsed milliseconds.</param> + public PerformanceCounterEntry(DateTime eventTime, double elapsedMilliseconds) + { + this.EventTime = eventTime; + this.ElapsedMilliseconds = elapsedMilliseconds; + } + } +} diff --git a/src/SMAPI/Framework/PerformanceMonitoring/PerformanceMonitor.cs b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceMonitor.cs new file mode 100644 index 00000000..dfc4f31a --- /dev/null +++ b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceMonitor.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using StardewModdingAPI.Framework.Events; + +namespace StardewModdingAPI.Framework.PerformanceMonitoring +{ + /// <summary>Tracks performance metrics.</summary> + internal class PerformanceMonitor + { + /********* + ** Fields + *********/ + /// <summary>The recorded alerts.</summary> + private readonly IList<AlertEntry> Alerts = new List<AlertEntry>(); + + /// <summary>The monitor for output logging.</summary> + private readonly IMonitor Monitor; + + /// <summary>The invocation stopwatch.</summary> + private readonly Stopwatch InvocationStopwatch = new Stopwatch(); + + /// <summary>The underlying performance counter collections.</summary> + private readonly IDictionary<string, PerformanceCounterCollection> Collections = new Dictionary<string, PerformanceCounterCollection>(StringComparer.InvariantCultureIgnoreCase); + + + /********* + ** Accessors + *********/ + /// <summary>Whether alerts are paused.</summary> + public bool PauseAlerts { get; set; } + + /// <summary>Whether performance counter tracking is enabled.</summary> + public bool EnableTracking { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="monitor">The monitor for output logging.</param> + public PerformanceMonitor(IMonitor monitor) + { + this.Monitor = monitor; + } + + /// <summary>Reset all performance counters in all collections.</summary> + public void Reset() + { + foreach (PerformanceCounterCollection collection in this.Collections.Values) + collection.Reset(); + } + + /// <summary>Track the invocation time for a collection.</summary> + /// <param name="collectionName">The name of the collection.</param> + /// <param name="action">The action to execute and track.</param> + public void Track(string collectionName, Action action) + { + if (!this.EnableTracking) + { + action(); + return; + } + + PerformanceCounterCollection collection = this.GetOrCreateCollectionByName(collectionName); + collection.BeginTrackInvocation(); + try + { + action(); + } + finally + { + collection.EndTrackInvocation(); + } + } + + /// <summary>Track a single performance counter invocation in a specific collection.</summary> + /// <param name="collectionName">The name of the collection.</param> + /// <param name="sourceName">The name of the source.</param> + /// <param name="action">The action to execute and track.</param> + public void Track(string collectionName, string sourceName, Action action) + { + if (!this.EnableTracking) + { + action(); + return; + } + + PerformanceCounterCollection collection = this.GetOrCreateCollectionByName(collectionName); + DateTime eventTime = DateTime.UtcNow; + this.InvocationStopwatch.Reset(); + this.InvocationStopwatch.Start(); + + try + { + action(); + } + finally + { + this.InvocationStopwatch.Stop(); + collection.Track(sourceName, new PerformanceCounterEntry(eventTime, this.InvocationStopwatch.Elapsed.TotalMilliseconds)); + } + } + + /// <summary>Reset the performance counters for a specific collection.</summary> + /// <param name="name">The collection name.</param> + public void ResetCollection(string name) + { + if (this.Collections.TryGetValue(name, out PerformanceCounterCollection collection)) + { + collection.ResetCallsPerSecond(); + collection.Reset(); + } + } + + /// <summary>Reset performance counters for a specific source.</summary> + /// <param name="name">The name of the source.</param> + public void ResetSource(string name) + { + foreach (PerformanceCounterCollection performanceCounterCollection in this.Collections.Values) + performanceCounterCollection.ResetSource(name); + } + + /// <summary>Print any queued alerts.</summary> + public void PrintQueuedAlerts() + { + if (this.Alerts.Count == 0) + return; + + StringBuilder report = new StringBuilder(); + + foreach (AlertEntry alert in this.Alerts) + { + report.AppendLine($"{alert.Collection.Name} took {alert.ExecutionTimeMilliseconds:F2}ms (exceeded threshold of {alert.ThresholdMilliseconds:F2}ms)"); + + foreach (AlertContext context in alert.Context.OrderByDescending(p => p.Elapsed)) + report.AppendLine(context.ToString()); + } + + this.Alerts.Clear(); + this.Monitor.Log(report.ToString(), LogLevel.Error); + } + + /// <summary>Add an alert to the queue.</summary> + /// <param name="entry">The alert to add.</param> + public void AddAlert(AlertEntry entry) + { + if (!this.PauseAlerts) + this.Alerts.Add(entry); + } + + /// <summary>Initialize the default performance counter collections.</summary> + /// <param name="eventManager">The event manager.</param> + public void InitializePerformanceCounterCollections(EventManager eventManager) + { + foreach (IManagedEvent @event in eventManager.GetAllEvents()) + this.Collections[@event.EventName] = new PerformanceCounterCollection(this, @event.EventName, @event.IsPerformanceCritical); + } + + /// <summary>Get the underlying performance counters.</summary> + public IEnumerable<PerformanceCounterCollection> GetCollections() + { + return this.Collections.Values; + } + + + /********* + ** Public methods + *********/ + /// <summary>Get a collection by name and creates it if it doesn't exist.</summary> + /// <param name="name">The name of the collection.</param> + private PerformanceCounterCollection GetOrCreateCollectionByName(string name) + { + if (!this.Collections.TryGetValue(name, out PerformanceCounterCollection collection)) + { + collection = new PerformanceCounterCollection(this, name); + this.Collections[name] = collection; + } + return collection; + } + } +} diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index dfd77e16..50e6ea1c 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -23,6 +23,7 @@ using StardewModdingAPI.Framework.Models; using StardewModdingAPI.Framework.ModHelpers; using StardewModdingAPI.Framework.ModLoading; using StardewModdingAPI.Framework.Patching; +using StardewModdingAPI.Framework.PerformanceMonitoring; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Serialization; using StardewModdingAPI.Patches; @@ -109,7 +110,7 @@ namespace StardewModdingAPI.Framework "Oops! Steam achievements won't work because Steam isn't loaded. You can launch the game through Steam to fix that.", #endif logLevel: LogLevel.Error - ), + ), // save file not found error new ReplaceLogPattern( @@ -133,6 +134,10 @@ namespace StardewModdingAPI.Framework /// <remarks>This is initialized after the game starts. This is accessed directly because it's not part of the normal class model.</remarks> internal static DeprecationManager DeprecationManager { get; private set; } + /// <summary>Manages performance counters.</summary> + /// <remarks>This is initialized after the game starts. This is non-private for use by Console Commands.</remarks> + internal static PerformanceMonitor PerformanceMonitor { get; private set; } + /********* ** Public methods @@ -153,6 +158,9 @@ namespace StardewModdingAPI.Framework // init basics this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath)); + if (File.Exists(Constants.ApiUserConfigPath)) + JsonConvert.PopulateObject(File.ReadAllText(Constants.ApiUserConfigPath), this.Settings); + this.LogFile = new LogFileManager(logPath); this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.Settings.ConsoleColors, this.Settings.VerboseLogging) { @@ -161,7 +169,11 @@ namespace StardewModdingAPI.Framework ShowFullStampInConsole = this.Settings.DeveloperMode }; this.MonitorForGame = this.GetSecondaryMonitor("game"); - this.EventManager = new EventManager(this.Monitor, this.ModRegistry); + + SCore.PerformanceMonitor = new PerformanceMonitor(this.Monitor); + this.EventManager = new EventManager(this.Monitor, this.ModRegistry, SCore.PerformanceMonitor); + SCore.PerformanceMonitor.InitializePerformanceCounterCollections(this.EventManager); + SCore.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry); // redirect direct console output @@ -193,7 +205,7 @@ namespace StardewModdingAPI.Framework #else if (Constants.Platform == Platform.Windows) { - this.Monitor.Log("Oops! You're running {Constants.Platform}, but this version of SMAPI is for Windows. Please reinstall SMAPI to fix this.", LogLevel.Error); + this.Monitor.Log($"Oops! You're running {Constants.Platform}, but this version of SMAPI is for Windows. Please reinstall SMAPI to fix this.", LogLevel.Error); this.PressAnyKeyToExit(); return; } @@ -210,6 +222,7 @@ namespace StardewModdingAPI.Framework JsonConverter[] converters = { new ColorConverter(), new PointConverter(), + new Vector2Converter(), new RectangleConverter() }; foreach (JsonConverter converter in converters) @@ -222,7 +235,7 @@ namespace StardewModdingAPI.Framework #endif AppDomain.CurrentDomain.UnhandledException += (sender, e) => this.Monitor.Log($"Critical app domain exception: {e.ExceptionObject}", LogLevel.Error); - // add more lenient assembly resolvers + // add more lenient assembly resolver AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => AssemblyLoader.ResolveAssembly(e.Name); // hook locale event @@ -239,6 +252,7 @@ namespace StardewModdingAPI.Framework jsonHelper: this.Toolkit.JsonHelper, modRegistry: this.ModRegistry, deprecationManager: SCore.DeprecationManager, + performanceMonitor: SCore.PerformanceMonitor, onGameInitialized: this.InitializeAfterGameStart, onGameExiting: this.Dispose, cancellationToken: this.CancellationToken, @@ -253,7 +267,8 @@ namespace StardewModdingAPI.Framework new DialogueErrorPatch(this.MonitorForGame, this.Reflection), new ObjectErrorPatch(), new LoadContextPatch(this.Reflection, this.GameInstance.OnLoadStageChanged), - new LoadErrorPatch(this.Monitor, this.GameInstance.OnSaveContentRemoved) + new LoadErrorPatch(this.Monitor, this.GameInstance.OnSaveContentRemoved), + new ScheduleErrorPatch(this.MonitorForGame) ); // add exit handler @@ -307,7 +322,7 @@ namespace StardewModdingAPI.Framework // show details if game crashed during last session if (File.Exists(Constants.FatalCrashMarker)) { - this.Monitor.Log("The game crashed last time you played. That can be due to bugs in the game, but if it happens repeatedly you can ask for help here: https://community.playstarbound.com/threads/108375/.", LogLevel.Error); + this.Monitor.Log("The game crashed last time you played. If it happens repeatedly, see 'get help' on https://smapi.io.", LogLevel.Error); this.Monitor.Log("If you ask for help, make sure to share your SMAPI log: https://smapi.io/log.", LogLevel.Error); this.Monitor.Log("Press any key to delete the crash data and continue playing.", LogLevel.Info); Console.ReadKey(); @@ -406,6 +421,17 @@ namespace StardewModdingAPI.Framework return; } + // init TMX support + try + { + xTile.Format.FormatManager.Instance.RegisterMapFormat(new TMXTile.TMXFormat(Game1.tileSize / Game1.pixelZoom, Game1.tileSize / Game1.pixelZoom, Game1.pixelZoom, Game1.pixelZoom)); + } + catch (Exception ex) + { + this.Monitor.Log("SMAPI couldn't load TMX support. Some mods may not work correctly.", LogLevel.Warn); + this.Monitor.Log($"Technical details: {ex.GetLogSummary()}", LogLevel.Trace); + } + // load mod data ModToolkit toolkit = new ModToolkit(); ModDatabase modDatabase = toolkit.GetModDatabase(Constants.ApiMetadataPath); @@ -589,6 +615,8 @@ namespace StardewModdingAPI.Framework else this.Monitor.Log(" SMAPI okay.", LogLevel.Trace); + updateFound = response.SuggestedUpdate?.Version; + // show errors if (response.Errors.Any()) { @@ -791,13 +819,13 @@ namespace StardewModdingAPI.Framework { // ReSharper disable SuspiciousTypeConversion.Global if (metadata.Mod is IAssetEditor editor) - helper.ObservableAssetEditors.Add(editor); + this.ContentCore.Editors.Add(new ModLinked<IAssetEditor>(metadata, editor)); if (metadata.Mod is IAssetLoader loader) - helper.ObservableAssetLoaders.Add(loader); + this.ContentCore.Loaders.Add(new ModLinked<IAssetLoader>(metadata, loader)); // ReSharper restore SuspiciousTypeConversion.Global - this.ContentCore.Editors[metadata] = helper.ObservableAssetEditors; - this.ContentCore.Loaders[metadata] = helper.ObservableAssetLoaders; + helper.ObservableAssetEditors.CollectionChanged += (sender, e) => this.OnInterceptorsChanged(metadata, e.NewItems?.Cast<IAssetEditor>(), e.OldItems?.Cast<IAssetEditor>(), this.ContentCore.Editors); + helper.ObservableAssetLoaders.CollectionChanged += (sender, e) => this.OnInterceptorsChanged(metadata, e.NewItems?.Cast<IAssetLoader>(), e.OldItems?.Cast<IAssetLoader>(), this.ContentCore.Loaders); } // call entry method @@ -846,6 +874,24 @@ namespace StardewModdingAPI.Framework this.ModRegistry.AreAllModsInitialized = true; } + /// <summary>Handle a mod adding or removing asset interceptors.</summary> + /// <typeparam name="T">The asset interceptor type (one of <see cref="IAssetEditor"/> or <see cref="IAssetLoader"/>).</typeparam> + /// <param name="mod">The mod metadata.</param> + /// <param name="added">The interceptors that were added.</param> + /// <param name="removed">The interceptors that were removed.</param> + /// <param name="list">The list to update.</param> + private void OnInterceptorsChanged<T>(IModMetadata mod, IEnumerable<T> added, IEnumerable<T> removed, IList<ModLinked<T>> list) + { + foreach (T interceptor in added ?? new T[0]) + list.Add(new ModLinked<T>(mod, interceptor)); + + foreach (T interceptor in removed ?? new T[0]) + { + foreach (ModLinked<T> entry in list.Where(p => p.Mod == mod && object.ReferenceEquals(p.Data, interceptor)).ToArray()) + list.Remove(entry); + } + } + /// <summary>Load a given mod.</summary> /// <param name="mod">The mod to load.</param> /// <param name="mods">The mods being loaded.</param> diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index d6c3b836..4b346059 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -17,6 +17,7 @@ using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.Events; using StardewModdingAPI.Framework.Input; using StardewModdingAPI.Framework.Networking; +using StardewModdingAPI.Framework.PerformanceMonitoring; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.StateTracking.Comparers; using StardewModdingAPI.Framework.StateTracking.Snapshots; @@ -58,6 +59,9 @@ namespace StardewModdingAPI.Framework /// <summary>Manages deprecation warnings.</summary> private readonly DeprecationManager DeprecationManager; + /// <summary>Tracks performance metrics.</summary> + private readonly PerformanceMonitor PerformanceMonitor; + /// <summary>The maximum number of consecutive attempts SMAPI should make to recover from a draw error.</summary> private readonly Countdown DrawCrashTimer = new Countdown(60); // 60 ticks = roughly one second @@ -152,11 +156,12 @@ namespace StardewModdingAPI.Framework /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> /// <param name="modRegistry">Tracks the installed mods.</param> /// <param name="deprecationManager">Manages deprecation warnings.</param> + /// <param name="performanceMonitor">Tracks performance metrics.</param> /// <param name="onGameInitialized">A callback to invoke after the game finishes initializing.</param> /// <param name="onGameExiting">A callback to invoke when the game exits.</param> /// <param name="cancellationToken">Propagates notification that SMAPI should exit.</param> /// <param name="logNetworkTraffic">Whether to log network traffic.</param> - internal SGame(Monitor monitor, IMonitor monitorForGame, Reflector reflection, Translator translator, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, DeprecationManager deprecationManager, Action onGameInitialized, Action onGameExiting, CancellationTokenSource cancellationToken, bool logNetworkTraffic) + internal SGame(Monitor monitor, IMonitor monitorForGame, Reflector reflection, Translator translator, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, DeprecationManager deprecationManager, PerformanceMonitor performanceMonitor, Action onGameInitialized, Action onGameExiting, CancellationTokenSource cancellationToken, bool logNetworkTraffic) { this.OnLoadingFirstAsset = SGame.ConstructorHack.OnLoadingFirstAsset; SGame.ConstructorHack = null; @@ -176,6 +181,7 @@ namespace StardewModdingAPI.Framework this.Reflection = reflection; this.Translator = translator; this.DeprecationManager = deprecationManager; + this.PerformanceMonitor = performanceMonitor; this.OnGameInitialized = onGameInitialized; this.OnGameExiting = onGameExiting; Game1.input = new SInputState(); @@ -307,6 +313,7 @@ namespace StardewModdingAPI.Framework try { this.DeprecationManager.PrintQueued(); + this.PerformanceMonitor.PrintQueuedAlerts(); /********* ** First-tick initialization @@ -382,7 +389,7 @@ namespace StardewModdingAPI.Framework // state while mods are running their code. This is risky, because data changes can // conflict (e.g. collection changed during enumeration errors) and data may change // unexpectedly from one mod instruction to the next. - // + // // Therefore we can just run Game1.Update here without raising any SMAPI events. There's // a small chance that the task will finish after we defer but before the game checks, // which means technically events should be raised, but the effects of missing one diff --git a/src/SMAPI/Framework/Serialization/PointConverter.cs b/src/SMAPI/Framework/Serialization/PointConverter.cs index 8c2f3396..3481c9b2 100644 --- a/src/SMAPI/Framework/Serialization/PointConverter.cs +++ b/src/SMAPI/Framework/Serialization/PointConverter.cs @@ -6,7 +6,7 @@ using StardewModdingAPI.Toolkit.Serialization.Converters; namespace StardewModdingAPI.Framework.Serialization { - /// <summary>Handles deserialization of <see cref="PointConverter"/> for crossplatform compatibility.</summary> + /// <summary>Handles deserialization of <see cref="Point"/> for crossplatform compatibility.</summary> /// <remarks> /// - Linux/Mac format: { "X": 1, "Y": 2 } /// - Windows format: "1, 2" diff --git a/src/SMAPI/Framework/Serialization/Vector2Converter.cs b/src/SMAPI/Framework/Serialization/Vector2Converter.cs new file mode 100644 index 00000000..1d9b08e0 --- /dev/null +++ b/src/SMAPI/Framework/Serialization/Vector2Converter.cs @@ -0,0 +1,43 @@ +using System; +using Microsoft.Xna.Framework; +using Newtonsoft.Json.Linq; +using StardewModdingAPI.Toolkit.Serialization; +using StardewModdingAPI.Toolkit.Serialization.Converters; + +namespace StardewModdingAPI.Framework.Serialization +{ + /// <summary>Handles deserialization of <see cref="Vector2"/> for crossplatform compatibility.</summary> + /// <remarks> + /// - Linux/Mac format: { "X": 1, "Y": 2 } + /// - Windows format: "1, 2" + /// </remarks> + internal class Vector2Converter : SimpleReadOnlyConverter<Vector2> + { + /********* + ** Protected methods + *********/ + /// <summary>Read a JSON object.</summary> + /// <param name="obj">The JSON object to read.</param> + /// <param name="path">The path to the current JSON node.</param> + protected override Vector2 ReadObject(JObject obj, string path) + { + float x = obj.ValueIgnoreCase<float>(nameof(Vector2.X)); + float y = obj.ValueIgnoreCase<float>(nameof(Vector2.Y)); + return new Vector2(x, y); + } + + /// <summary>Read a JSON string.</summary> + /// <param name="str">The JSON string value.</param> + /// <param name="path">The path to the current JSON node.</param> + protected override Vector2 ReadString(string str, string path) + { + string[] parts = str.Split(','); + if (parts.Length != 2) + throw new SParseException($"Can't parse {typeof(Vector2).Name} from invalid value '{str}' (path: {path})."); + + float x = Convert.ToSingle(parts[0]); + float y = Convert.ToSingle(parts[1]); + return new Vector2(x, y); + } + } +} diff --git a/src/SMAPI/Framework/SnapshotListDiff.cs b/src/SMAPI/Framework/SnapshotListDiff.cs index d4d5df50..2d0efa0d 100644 --- a/src/SMAPI/Framework/SnapshotListDiff.cs +++ b/src/SMAPI/Framework/SnapshotListDiff.cs @@ -42,10 +42,12 @@ namespace StardewModdingAPI.Framework this.IsChanged = isChanged; this.RemovedImpl.Clear(); - this.RemovedImpl.AddRange(removed); + if (removed != null) + this.RemovedImpl.AddRange(removed); this.AddedImpl.Clear(); - this.AddedImpl.AddRange(added); + if (added != null) + this.AddedImpl.AddRange(added); } /// <summary>Update the snapshot.</summary> diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs index b86a6790..7a58d52c 100644 --- a/src/SMAPI/Metadata/CoreAssetPropagator.cs +++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs @@ -190,17 +190,9 @@ namespace StardewModdingAPI.Metadata case "characters\\farmer\\farmer_base": // Farmer case "characters\\farmer\\farmer_base_bald": - if (Game1.player == null || !Game1.player.IsMale) - return false; - Game1.player.FarmerRenderer = new FarmerRenderer(key, Game1.player); - return true; - - case "characters\\farmer\\farmer_girl_base": // Farmer + case "characters\\farmer\\farmer_girl_base": case "characters\\farmer\\farmer_girl_base_bald": - if (Game1.player == null || Game1.player.IsMale) - return false; - Game1.player.FarmerRenderer = new FarmerRenderer(key, Game1.player); - return true; + return this.ReloadPlayerSprites(key); case "characters\\farmer\\hairstyles": // Game1.LoadContent FarmerRenderer.hairStylesTexture = content.Load<Texture2D>(key); @@ -835,6 +827,27 @@ namespace StardewModdingAPI.Metadata } } + /// <summary>Reload the sprites for matching players.</summary> + /// <param name="key">The asset key to reload.</param> + private bool ReloadPlayerSprites(string key) + { + Farmer[] players = + ( + from player in Game1.getOnlineFarmers() + where key == this.NormalizeAssetNameIgnoringEmpty(player.getTexture()) + select player + ) + .ToArray(); + + foreach (Farmer player in players) + { + this.Reflection.GetField<Dictionary<string, Dictionary<int, List<int>>>>(typeof(FarmerRenderer), "_recolorOffsets").GetValue().Remove(player.getTexture()); + player.FarmerRenderer.MarkSpriteDirty(); + } + + return players.Any(); + } + /// <summary>Reload tree textures.</summary> /// <param name="content">The content manager through which to reload the asset.</param> /// <param name="key">The asset key to reload.</param> @@ -874,7 +887,11 @@ namespace StardewModdingAPI.Metadata // update dialogue foreach (NPC villager in villagers) + { villager.resetSeasonalDialogue(); // doesn't only affect seasonal dialogue + villager.resetCurrentDialogue(); + } + return true; } @@ -896,18 +913,16 @@ namespace StardewModdingAPI.Metadata this.Reflection.GetField<bool>(villager, "_hasLoadedMasterScheduleData").SetValue(false); this.Reflection.GetField<Dictionary<string, string>>(villager, "_masterScheduleData").SetValue(null); villager.Schedule = villager.getSchedule(Game1.dayOfMonth); - if (villager.Schedule == null) - { - this.Monitor.Log($"A mod set an invalid schedule for {villager.Name ?? key}, so the NPC may not behave correctly.", LogLevel.Warn); - return true; - } // switch to new schedule if needed - int lastScheduleTime = villager.Schedule.Keys.Where(p => p <= Game1.timeOfDay).OrderByDescending(p => p).FirstOrDefault(); - if (lastScheduleTime != 0) + if (villager.Schedule != null) { - villager.scheduleTimeToTry = NPC.NO_TRY; // use time that's passed in to checkSchedule - villager.checkSchedule(lastScheduleTime); + int lastScheduleTime = villager.Schedule.Keys.Where(p => p <= Game1.timeOfDay).OrderByDescending(p => p).FirstOrDefault(); + if (lastScheduleTime != 0) + { + villager.scheduleTimeToTry = NPC.NO_TRY; // use time that's passed in to checkSchedule + villager.checkSchedule(lastScheduleTime); + } } } return true; diff --git a/src/SMAPI/Patches/LoadErrorPatch.cs b/src/SMAPI/Patches/LoadErrorPatch.cs index eedb4164..c16ca7cc 100644 --- a/src/SMAPI/Patches/LoadErrorPatch.cs +++ b/src/SMAPI/Patches/LoadErrorPatch.cs @@ -3,8 +3,10 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using Harmony; +using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Patching; using StardewValley; +using StardewValley.Buildings; using StardewValley.Locations; namespace StardewModdingAPI.Patches @@ -64,10 +66,24 @@ namespace StardewModdingAPI.Patches /// <returns>Returns whether to execute the original method.</returns> private static bool Before_SaveGame_LoadDataToLocations(List<GameLocation> gamelocations) { + bool removedAny = + LoadErrorPatch.RemoveInvalidLocations(gamelocations) + | LoadErrorPatch.RemoveBrokenBuildings(gamelocations) + | LoadErrorPatch.RemoveInvalidNpcs(gamelocations); + + if (removedAny) + LoadErrorPatch.OnContentRemoved(); + + return true; + } + + /// <summary>Remove locations which don't exist in-game.</summary> + /// <param name="locations">The current game locations.</param> + private static bool RemoveInvalidLocations(List<GameLocation> locations) + { bool removedAny = false; - // remove invalid locations - foreach (GameLocation location in gamelocations.ToArray()) + foreach (GameLocation location in locations.ToArray()) { if (location is Cellar) continue; // missing cellars will be added by the game code @@ -75,23 +91,48 @@ namespace StardewModdingAPI.Patches if (Game1.getLocationFromName(location.name) == null) { LoadErrorPatch.Monitor.Log($"Removed invalid location '{location.Name}' to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom location mod?)", LogLevel.Warn); - gamelocations.Remove(location); + locations.Remove(location); removedAny = true; } } - // get building interiors - var interiors = - ( - from location in gamelocations.OfType<BuildableGameLocation>() - from building in location.buildings - where building.indoors.Value != null - select building.indoors.Value - ); + return removedAny; + } + + /// <summary>Remove buildings which don't exist in the game data.</summary> + /// <param name="locations">The current game locations.</param> + private static bool RemoveBrokenBuildings(IEnumerable<GameLocation> locations) + { + bool removedAny = false; + + foreach (BuildableGameLocation location in locations.OfType<BuildableGameLocation>()) + { + foreach (Building building in location.buildings.ToArray()) + { + try + { + BluePrint _ = new BluePrint(building.buildingType.Value); + } + catch (SContentLoadException) + { + LoadErrorPatch.Monitor.Log($"Removed invalid building type '{building.buildingType.Value}' in {location.Name} ({building.tileX}, {building.tileY}) to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom building mod?)", LogLevel.Warn); + location.buildings.Remove(building); + removedAny = true; + } + } + } + + return removedAny; + } + + /// <summary>Remove NPCs which don't exist in the game data.</summary> + /// <param name="locations">The current game locations.</param> + private static bool RemoveInvalidNpcs(IEnumerable<GameLocation> locations) + { + bool removedAny = false; - // remove custom NPCs which no longer exist IDictionary<string, string> data = Game1.content.Load<Dictionary<string, string>>("Data\\NPCDispositions"); - foreach (GameLocation location in gamelocations.Concat(interiors)) + foreach (GameLocation location in LoadErrorPatch.GetAllLocations(locations)) { foreach (NPC npc in location.characters.ToArray()) { @@ -103,7 +144,7 @@ namespace StardewModdingAPI.Patches } catch { - LoadErrorPatch.Monitor.Log($"Removed invalid villager '{npc.Name}' to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom NPC mod?)", LogLevel.Warn); + LoadErrorPatch.Monitor.Log($"Removed invalid villager '{npc.Name}' in {location.Name} ({npc.getTileLocation()}) to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom NPC mod?)", LogLevel.Warn); location.characters.Remove(npc); removedAny = true; } @@ -111,10 +152,22 @@ namespace StardewModdingAPI.Patches } } - if (removedAny) - LoadErrorPatch.OnContentRemoved(); + return removedAny; + } - return true; + /// <summary>Get all locations, including building interiors.</summary> + /// <param name="locations">The main game locations.</param> + private static IEnumerable<GameLocation> GetAllLocations(IEnumerable<GameLocation> locations) + { + foreach (GameLocation location in locations) + { + yield return location; + if (location is BuildableGameLocation buildableLocation) + { + foreach (GameLocation interior in buildableLocation.buildings.Select(p => p.indoors.Value).Where(p => p != null)) + yield return interior; + } + } } } } diff --git a/src/SMAPI/Patches/ScheduleErrorPatch.cs b/src/SMAPI/Patches/ScheduleErrorPatch.cs new file mode 100644 index 00000000..a23aa645 --- /dev/null +++ b/src/SMAPI/Patches/ScheduleErrorPatch.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Harmony; +using StardewModdingAPI.Framework.Patching; +using StardewValley; + +namespace StardewModdingAPI.Patches +{ + /// <summary>A Harmony patch for <see cref="NPC.parseMasterSchedule"/> which intercepts crashes due to invalid schedule data.</summary> + /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks> + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] + internal class ScheduleErrorPatch : IHarmonyPatch + { + /********* + ** Fields + *********/ + /// <summary>Writes messages to the console and log file on behalf of the game.</summary> + private static IMonitor MonitorForGame; + + /// <summary>Whether the target is currently being intercepted.</summary> + private static bool IsIntercepting; + + + /********* + ** Accessors + *********/ + /// <summary>A unique name for this patch.</summary> + public string Name => nameof(ScheduleErrorPatch); + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="monitorForGame">Writes messages to the console and log file on behalf of the game.</param> + public ScheduleErrorPatch(IMonitor monitorForGame) + { + ScheduleErrorPatch.MonitorForGame = monitorForGame; + } + + /// <summary>Apply the Harmony patch.</summary> + /// <param name="harmony">The Harmony instance.</param> + public void Apply(HarmonyInstance harmony) + { + harmony.Patch( + original: AccessTools.Method(typeof(NPC), "parseMasterSchedule"), + prefix: new HarmonyMethod(this.GetType(), nameof(ScheduleErrorPatch.Before_NPC_parseMasterSchedule)) + ); + } + + + /********* + ** Private methods + *********/ + /// <summary>The method to call instead of <see cref="NPC.parseMasterSchedule"/>.</summary> + /// <param name="rawData">The raw schedule data to parse.</param> + /// <param name="__instance">The instance being patched.</param> + /// <param name="__result">The patched method's return value.</param> + /// <param name="__originalMethod">The method being wrapped.</param> + /// <returns>Returns whether to execute the original method.</returns> + private static bool Before_NPC_parseMasterSchedule(string rawData, NPC __instance, ref Dictionary<int, SchedulePathDescription> __result, MethodInfo __originalMethod) + { + if (ScheduleErrorPatch.IsIntercepting) + return true; + + try + { + ScheduleErrorPatch.IsIntercepting = true; + __result = (Dictionary<int, SchedulePathDescription>)__originalMethod.Invoke(__instance, new object[] { rawData }); + return false; + } + catch (TargetInvocationException ex) + { + ScheduleErrorPatch.MonitorForGame.Log($"Failed parsing schedule for NPC {__instance.Name}:\n{rawData}\n{ex.InnerException ?? ex}", LogLevel.Error); + __result = new Dictionary<int, SchedulePathDescription>(); + return false; + } + finally + { + ScheduleErrorPatch.IsIntercepting = false; + } + } + } +} diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs index 6bacf564..c26ae29a 100644 --- a/src/SMAPI/Program.cs +++ b/src/SMAPI/Program.cs @@ -11,6 +11,7 @@ using StardewModdingAPI.Framework; using StardewModdingAPI.Toolkit.Utilities; [assembly: InternalsVisibleTo("SMAPI.Tests")] +[assembly: InternalsVisibleTo("ConsoleCommands")] // for performance monitoring commands [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // Moq for unit testing namespace StardewModdingAPI { diff --git a/src/SMAPI/SMAPI.config.json b/src/SMAPI/SMAPI.config.json index 824bb783..57b4f885 100644 --- a/src/SMAPI/SMAPI.config.json +++ b/src/SMAPI/SMAPI.config.json @@ -6,6 +6,12 @@ This file contains advanced configuration for SMAPI. You generally shouldn't cha The default values are mirrored in StardewModdingAPI.Framework.Models.SConfig to log custom changes. +This file is overwritten each time you update or reinstall SMAPI. To avoid losing custom settings, +create a 'config.user.json' file in the same folder with *only* the settings you want to change. +That file won't be overwritten, and any settings in it will override the default options. Don't +copy all the settings, or you may cause bugs due to overridden changes in future SMAPI versions. + + */ { diff --git a/src/SMAPI/SMAPI.csproj b/src/SMAPI/SMAPI.csproj index 936c420d..c5d0f247 100644 --- a/src/SMAPI/SMAPI.csproj +++ b/src/SMAPI/SMAPI.csproj @@ -8,8 +8,7 @@ <LangVersion>latest</LangVersion> <PlatformTarget>x86</PlatformTarget> <OutputType>Exe</OutputType> - <OutputPath>$(SolutionDir)\..\bin\$(Configuration)\SMAPI</OutputPath> - <DocumentationFile>$(SolutionDir)\..\bin\$(Configuration)\SMAPI\StardewModdingAPI.xml</DocumentationFile> + <DocumentationFile>bin\$(Configuration)\StardewModdingAPI.xml</DocumentationFile> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <LargeAddressAware Condition="'$(OS)' == 'Windows_NT'">true</LargeAddressAware> <ApplicationIcon>icon.ico</ApplicationIcon> @@ -20,6 +19,7 @@ <PackageReference Include="Lib.Harmony" Version="1.2.0.1" /> <PackageReference Include="Mono.Cecil" Version="0.11.1" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> + <PackageReference Include="Platonymous.TMXTile" Version="1.0.2" /> </ItemGroup> <ItemGroup> @@ -99,28 +99,7 @@ <Link>SMAPI.metadata.json</Link> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> - <None Update="i18n\de.json"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </None> - <None Update="i18n\es.json"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </None> - <None Update="i18n\ja.json"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </None> - <None Update="i18n\default.json"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </None> - <None Update="i18n\pt.json"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </None> - <None Update="i18n\ru.json"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </None> - <None Update="i18n\tr.json"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </None> - <None Update="i18n\zh.json"> + <None Update="i18n\*"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> <None Update="steam_appid.txt"> diff --git a/src/SMAPI/SemanticVersion.cs b/src/SMAPI/SemanticVersion.cs index 2a33ecef..4a175efe 100644 --- a/src/SMAPI/SemanticVersion.cs +++ b/src/SMAPI/SemanticVersion.cs @@ -39,18 +39,36 @@ namespace StardewModdingAPI /// <param name="majorVersion">The major version incremented for major API changes.</param> /// <param name="minorVersion">The minor version incremented for backwards-compatible changes.</param> /// <param name="patchVersion">The patch version for backwards-compatible bug fixes.</param> - /// <param name="prerelease">An optional prerelease tag.</param> - /// <param name="build">Optional build metadata. This is ignored when determining version precedence.</param> + /// <param name="prereleaseTag">An optional prerelease tag.</param> + /// <param name="buildMetadata">Optional build metadata. This is ignored when determining version precedence.</param> + public SemanticVersion(int majorVersion, int minorVersion, int patchVersion, string prereleaseTag = null, string buildMetadata = null) + : this(majorVersion, minorVersion, patchVersion, 0, prereleaseTag, buildMetadata) { } + + /// <summary>Construct an instance.</summary> + /// <param name="majorVersion">The major version incremented for major API changes.</param> + /// <param name="minorVersion">The minor version incremented for backwards-compatible changes.</param> + /// <param name="patchVersion">The patch version for backwards-compatible bug fixes.</param> + /// <param name="prereleaseTag">An optional prerelease tag.</param> + /// <param name="platformRelease">The platform-specific version (if applicable).</param> + /// <param name="buildMetadata">Optional build metadata. This is ignored when determining version precedence.</param> [JsonConstructor] - public SemanticVersion(int majorVersion, int minorVersion, int patchVersion, string prerelease = null, string build = null) - : this(new Toolkit.SemanticVersion(majorVersion, minorVersion, patchVersion, prerelease, build)) { } + internal SemanticVersion(int majorVersion, int minorVersion, int patchVersion, int platformRelease, string prereleaseTag = null, string buildMetadata = null) + : this(new Toolkit.SemanticVersion(majorVersion, minorVersion, patchVersion, platformRelease, prereleaseTag, buildMetadata)) { } /// <summary>Construct an instance.</summary> /// <param name="version">The semantic version string.</param> /// <exception cref="ArgumentNullException">The <paramref name="version"/> is null.</exception> /// <exception cref="FormatException">The <paramref name="version"/> is not a valid semantic version.</exception> public SemanticVersion(string version) - : this(new Toolkit.SemanticVersion(version)) { } + : this(version, allowNonStandard: false) { } + + /// <summary>Construct an instance.</summary> + /// <param name="version">The semantic version string.</param> + /// <param name="allowNonStandard">Whether to recognize non-standard semver extensions.</param> + /// <exception cref="ArgumentNullException">The <paramref name="version"/> is null.</exception> + /// <exception cref="FormatException">The <paramref name="version"/> is not a valid semantic version.</exception> + internal SemanticVersion(string version, bool allowNonStandard) + : this(new Toolkit.SemanticVersion(version, allowNonStandard)) { } /// <summary>Construct an instance.</summary> /// <param name="version">The assembly version.</param> @@ -141,6 +159,12 @@ namespace StardewModdingAPI return this.Version.ToString(); } + /// <summary>Whether the version uses non-standard extensions, like four-part game versions on some platforms.</summary> + public bool IsNonStandard() + { + return this.Version.IsNonStandard(); + } + /// <summary>Parse a version string without throwing an exception if it fails.</summary> /// <param name="version">The version string.</param> /// <param name="parsed">The parsed representation.</param> diff --git a/src/SMAPI/i18n/fr.json b/src/SMAPI/i18n/fr.json new file mode 100644 index 00000000..6d051025 --- /dev/null +++ b/src/SMAPI/i18n/fr.json @@ -0,0 +1,3 @@ +{ + "warn.invalid-content-removed": "Le contenu non valide a été supprimé afin d'éviter un plantage (voir la console de SMAPI pour plus d'informations)." +} |