Update to SMAPI 3.2

This commit is contained in:
Chris 2020-02-01 21:53:49 -05:00
commit 673290ca14
88 changed files with 2775 additions and 614 deletions

View File

@ -1,16 +1,12 @@
Do you want to...
* **Ask for help using SMAPI?**
Please ask in [the Stardew Valley Discord or mod forums](https://smapi.io/community), don't
create a GitHub issue.
* **Report a bug?**
Please report it in [the Stardew Valley Discord or mod forums](https://smapi.io/community), don't
create a GitHub issue unless you're sure it's a bug in the SMAPI code.
* **Ask for help or report a bug?**
Please see 'get help' on [the SMAPI website](https://smapi.io) instead, don't create a GitHub
issue.
* **Submit a pull request?**
Pull requests are welcome! If you're submitting a new feature, it's best to discuss first to make
sure it'll be accepted. Feel free to come chat [on Discord or in the SMAPI discussion thread](https://smapi.io/community).
sure it'll be accepted. Feel free to come chat [on Discord](https://smapi.io/community).
Documenting your code and using the same formatting conventions is appreciated, but don't worry too
much about it. We'll fix up the code after we accept the pull request if needed.

3
.github/SUPPORT.md vendored
View File

@ -1,4 +1,3 @@
GitHub issues are only used for SMAPI development tasks.
To get help with SMAPI problems, [ask on Discord or in the forums](https://smapi.io/community)
instead.
To get help with SMAPI problems, see 'get help' on [the SMAPI website](https://smapi.io/) instead.

View File

@ -4,7 +4,7 @@
<!--set properties -->
<PropertyGroup>
<Version>3.1.0</Version>
<Version>3.2.0</Version>
<Product>SMAPI</Product>
<AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths>
@ -31,8 +31,9 @@
<Copy SourceFiles="$(TargetDir)\SMAPI.config.json" DestinationFiles="$(GamePath)\smapi-internal\config.json" />
<Copy SourceFiles="$(TargetDir)\SMAPI.metadata.json" DestinationFiles="$(GamePath)\smapi-internal\metadata.json" />
<Copy SourceFiles="$(TargetDir)\0Harmony.dll" DestinationFolder="$(GamePath)\smapi-internal" />
<Copy SourceFiles="$(TargetDir)\Newtonsoft.Json.dll" DestinationFolder="$(GamePath)\smapi-internal" />
<Copy SourceFiles="$(TargetDir)\Mono.Cecil.dll" DestinationFolder="$(GamePath)\smapi-internal" />
<Copy SourceFiles="$(TargetDir)\Newtonsoft.Json.dll" DestinationFolder="$(GamePath)\smapi-internal" />
<Copy SourceFiles="$(TargetDir)\TMXTile.dll" DestinationFolder="$(GamePath)\smapi-internal" />
<Copy SourceFiles="@(TranslationFiles)" DestinationFolder="$(GamePath)\smapi-internal\i18n" />
</Target>
<Target Name="CopyDefaultMods" Condition="'$(MSBuildProjectName)' == 'SMAPI.Mods.ConsoleCommands' OR '$(MSBuildProjectName)' == 'SMAPI.Mods.SaveBackup'">

View File

@ -1,75 +1,80 @@
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<!--
This build task is run from the installer project after all projects have been compiled, and
creates the build package in the bin\Packages folder.
-->
<Target Name="PrepareInstaller" AfterTargets="AfterBuild">
<PropertyGroup>
<RootPath>$(SolutionDir)\..</RootPath>
<CompiledRootPath>$(RootPath)\bin\$(Configuration)</CompiledRootPath>
<CompiledSmapiPath>$(CompiledRootPath)\SMAPI</CompiledSmapiPath>
<CompiledToolkitPath>$(CompiledRootPath)\SMAPI.Toolkit\net4.5</CompiledToolkitPath>
<CompiledModsPath>$(CompiledRootPath)\Mods</CompiledModsPath>
<PackagePath>$(SolutionDir)\..\bin\SMAPI installer</PackagePath>
<PackageDevPath>$(SolutionDir)\..\bin\SMAPI installer for developers</PackageDevPath>
<PlatformName>windows</PlatformName>
<PlatformName Condition="$(OS) != 'Windows_NT'">unix</PlatformName>
<BuildRootPath>$(SolutionDir)</BuildRootPath>
<OutRootPath>$(SolutionDir)\..\bin</OutRootPath>
<SmapiBin>$(BuildRootPath)\SMAPI\bin\$(Configuration)</SmapiBin>
<ToolkitBin>$(BuildRootPath)\SMAPI.Toolkit\bin\$(Configuration)\net4.5</ToolkitBin>
<ConsoleCommandsBin>$(BuildRootPath)\SMAPI.Mods.ConsoleCommands\bin\$(Configuration)</ConsoleCommandsBin>
<SaveBackupBin>$(BuildRootPath)\SMAPI.Mods.SaveBackup\bin\$(Configuration)</SaveBackupBin>
<PackagePath>$(OutRootPath)\SMAPI installer</PackagePath>
<PackageDevPath>$(OutRootPath)\SMAPI installer for developers</PackageDevPath>
</PropertyGroup>
<ItemGroup>
<TranslationFiles Include="$(CompiledSmapiPath)\i18n\*.json" />
<TranslationFiles Include="$(SmapiBin)\i18n\*.json" />
</ItemGroup>
<!-- reset package directory -->
<RemoveDir Directories="$(PackagePath)" />
<RemoveDir Directories="$(PackageDevPath)" />
<!-- copy installer files -->
<Copy SourceFiles="$(TargetDir)\unix-install.sh" DestinationFiles="$(PackagePath)\install on Linux.sh" />
<Copy SourceFiles="$(TargetDir)\unix-install.sh" DestinationFiles="$(PackagePath)\install on Mac.command" />
<Copy SourceFiles="$(TargetDir)\windows-install.bat" DestinationFiles="$(PackagePath)\install on Windows.bat" />
<Copy SourceFiles="$(TargetDir)\README.txt" DestinationFiles="$(PackagePath)\README.txt" />
<Copy SourceFiles="$(TargetDir)\assets\unix-install.sh" DestinationFiles="$(PackagePath)\install on Linux.sh" />
<Copy SourceFiles="$(TargetDir)\assets\unix-install.sh" DestinationFiles="$(PackagePath)\install on Mac.command" />
<Copy SourceFiles="$(TargetDir)\assets\windows-install.bat" DestinationFiles="$(PackagePath)\install on Windows.bat" />
<Copy SourceFiles="$(TargetDir)\assets\README.txt" DestinationFiles="$(PackagePath)\README.txt" />
<Copy SourceFiles="$(TargetDir)\$(TargetName).exe" DestinationFiles="$(PackagePath)\internal\$(PlatformName)-install.exe" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(TargetDir)\windows-exe-config.xml" DestinationFiles="$(PackagePath)\internal\$(PlatformName)-install.exe.config" />
<Copy Condition="$(PlatformName) == 'windows'" SourceFiles="$(TargetDir)\assets\windows-exe-config.xml" DestinationFiles="$(PackagePath)\internal\$(PlatformName)-install.exe.config" />
<!--copy bundle files-->
<Copy SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.exe" DestinationFolder="$(PackagePath)\bundle" />
<Copy SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.pdb" DestinationFolder="$(PackagePath)\bundle" />
<Copy SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.xml" DestinationFolder="$(PackagePath)\bundle" />
<Copy SourceFiles="$(CompiledSmapiPath)\steam_appid.txt" DestinationFolder="$(PackagePath)\bundle" />
<Copy SourceFiles="$(CompiledSmapiPath)\0Harmony.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy SourceFiles="$(CompiledSmapiPath)\Mono.Cecil.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy SourceFiles="$(CompiledSmapiPath)\Newtonsoft.Json.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy SourceFiles="$(CompiledSmapiPath)\SMAPI.config.json" DestinationFiles="$(PackagePath)\bundle\smapi-internal\config.json" />
<Copy SourceFiles="$(CompiledSmapiPath)\SMAPI.metadata.json" DestinationFiles="$(PackagePath)\bundle\smapi-internal\metadata.json" />
<Copy SourceFiles="$(CompiledToolkitPath)\SMAPI.Toolkit.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy SourceFiles="$(CompiledToolkitPath)\SMAPI.Toolkit.pdb" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy SourceFiles="$(CompiledToolkitPath)\SMAPI.Toolkit.xml" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy SourceFiles="$(CompiledToolkitPath)\SMAPI.Toolkit.CoreInterfaces.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy SourceFiles="$(CompiledToolkitPath)\SMAPI.Toolkit.CoreInterfaces.pdb" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy SourceFiles="$(CompiledToolkitPath)\SMAPI.Toolkit.CoreInterfaces.xml" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy SourceFiles="$(SmapiBin)\StardewModdingAPI.exe" DestinationFolder="$(PackagePath)\bundle" />
<Copy SourceFiles="$(SmapiBin)\StardewModdingAPI.pdb" DestinationFolder="$(PackagePath)\bundle" />
<Copy SourceFiles="$(SmapiBin)\StardewModdingAPI.xml" DestinationFolder="$(PackagePath)\bundle" />
<Copy SourceFiles="$(SmapiBin)\steam_appid.txt" DestinationFolder="$(PackagePath)\bundle" />
<Copy SourceFiles="$(SmapiBin)\0Harmony.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy SourceFiles="$(SmapiBin)\Mono.Cecil.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy SourceFiles="$(SmapiBin)\Newtonsoft.Json.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy SourceFiles="$(SmapiBin)\TMXTile.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy SourceFiles="$(SmapiBin)\SMAPI.config.json" DestinationFiles="$(PackagePath)\bundle\smapi-internal\config.json" />
<Copy SourceFiles="$(SmapiBin)\SMAPI.metadata.json" DestinationFiles="$(PackagePath)\bundle\smapi-internal\metadata.json" />
<Copy SourceFiles="$(ToolkitBin)\SMAPI.Toolkit.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy SourceFiles="$(ToolkitBin)\SMAPI.Toolkit.pdb" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy SourceFiles="$(ToolkitBin)\SMAPI.Toolkit.xml" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy SourceFiles="$(ToolkitBin)\SMAPI.Toolkit.CoreInterfaces.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy SourceFiles="$(ToolkitBin)\SMAPI.Toolkit.CoreInterfaces.pdb" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy SourceFiles="$(ToolkitBin)\SMAPI.Toolkit.CoreInterfaces.xml" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy SourceFiles="@(TranslationFiles)" DestinationFolder="$(PackagePath)\bundle\smapi-internal\i18n" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(TargetDir)\unix-launcher.sh" DestinationFiles="$(PackagePath)\bundle\StardewModdingAPI" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\System.Numerics.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\System.Runtime.Caching.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(TargetDir)\windows-exe-config.xml" DestinationFiles="$(PackagePath)\bundle\StardewModdingAPI.exe.config" />
<Copy Condition="$(PlatformName) == 'unix'" SourceFiles="$(TargetDir)\assets\unix-launcher.sh" DestinationFiles="$(PackagePath)\bundle\StardewModdingAPI" />
<Copy Condition="$(PlatformName) == 'unix'" SourceFiles="$(SmapiBin)\System.Numerics.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy Condition="$(PlatformName) == 'unix'" SourceFiles="$(SmapiBin)\System.Runtime.Caching.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy Condition="$(PlatformName) == 'windows'" SourceFiles="$(TargetDir)\assets\windows-exe-config.xml" DestinationFiles="$(PackagePath)\bundle\StardewModdingAPI.exe.config" />
<!--copy bundled mods-->
<Copy SourceFiles="$(CompiledModsPath)\ConsoleCommands\ConsoleCommands.dll" DestinationFolder="$(PackagePath)\bundle\Mods\ConsoleCommands" />
<Copy SourceFiles="$(CompiledModsPath)\ConsoleCommands\ConsoleCommands.pdb" DestinationFolder="$(PackagePath)\bundle\Mods\ConsoleCommands" />
<Copy SourceFiles="$(CompiledModsPath)\ConsoleCommands\manifest.json" DestinationFolder="$(PackagePath)\bundle\Mods\ConsoleCommands" />
<Copy SourceFiles="$(CompiledModsPath)\SaveBackup\SaveBackup.dll" DestinationFolder="$(PackagePath)\bundle\Mods\SaveBackup" />
<Copy SourceFiles="$(CompiledModsPath)\SaveBackup\SaveBackup.pdb" DestinationFolder="$(PackagePath)\bundle\Mods\SaveBackup" />
<Copy SourceFiles="$(CompiledModsPath)\SaveBackup\manifest.json" DestinationFolder="$(PackagePath)\bundle\Mods\SaveBackup" />
<Copy SourceFiles="$(ConsoleCommandsBin)\ConsoleCommands.dll" DestinationFolder="$(PackagePath)\bundle\Mods\ConsoleCommands" />
<Copy SourceFiles="$(ConsoleCommandsBin)\ConsoleCommands.pdb" DestinationFolder="$(PackagePath)\bundle\Mods\ConsoleCommands" />
<Copy SourceFiles="$(ConsoleCommandsBin)\manifest.json" DestinationFolder="$(PackagePath)\bundle\Mods\ConsoleCommands" />
<Copy SourceFiles="$(SaveBackupBin)\SaveBackup.dll" DestinationFolder="$(PackagePath)\bundle\Mods\SaveBackup" />
<Copy SourceFiles="$(SaveBackupBin)\SaveBackup.pdb" DestinationFolder="$(PackagePath)\bundle\Mods\SaveBackup" />
<Copy SourceFiles="$(SaveBackupBin)\manifest.json" DestinationFolder="$(PackagePath)\bundle\Mods\SaveBackup" />
<!-- fix errors on Linux/Mac (sample: https://smapi.io/log/mMdFUpgB) -->
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(RootPath)\build\lib\System.Numerics.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(RootPath)\build\lib\System.Runtime.Caching.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy Condition="$(PlatformName) == 'unix'" SourceFiles="$(TargetDir)\assets\System.Numerics.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<Copy Condition="$(PlatformName) == 'unix'" SourceFiles="$(TargetDir)\assets\System.Runtime.Caching.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
<!-- fix Linux/Mac permissions -->
<Exec Condition="$(OS) != 'Windows_NT'" Command="chmod 755 &quot;$(PackagePath)\install on Linux.sh&quot;" />
<Exec Condition="$(OS) != 'Windows_NT'" Command="chmod 755 &quot;$(PackagePath)\install on Mac.command&quot;" />
<Exec Condition="$(PlatformName) == 'unix'" Command="chmod 755 &quot;$(PackagePath)\install on Linux.sh&quot;" />
<Exec Condition="$(PlatformName) == 'unix'" Command="chmod 755 &quot;$(PackagePath)\install on Mac.command&quot;" />
<!-- finalise 'for developers' installer -->
<ItemGroup>

View File

@ -64,7 +64,7 @@ locale | status
---------- | :----------------
default | ✓ [fully translated](../src/SMAPI/i18n/default.json)
Chinese | ✓ [fully translated](../src/SMAPI/i18n/zh.json)
French | ❑ not translated
French | ✓ [fully translated](../src/SMAPI/i18n/fr.json)
German | ✓ [fully translated](../src/SMAPI/i18n/de.json)
Hungarian | ❑ not translated
Italian | ❑ not translated

View File

@ -1,8 +1,49 @@
&larr; [README](README.md)
# Release notes
## 3.2
Released 01 February 2020 for Stardew Valley 1.4.1 or later.
* For players:
* SMAPI now prevents crashes due to invalid schedule data.
* SMAPI now prevents crashes due to invalid building types.
* Added support for persistent `smapi-internal/config.json` overrides (see info in the file).
* Updated minimum game version (1.4 → 1.4.1).
* Fixed 'collection was modified' error when returning to title in rare cases.
* Fixed error when update-checking a mod with a Chucklefish page that has no version.
* Fixed rare error when building/demolishing buildings.
* Fixed SMAPI beta versions not showing update alert on next launch (thanks to danvolchek!).
* For the Console Commands mod:
* Added `performance` command to track mod performance metrics. This is an advanced experimental feature. (Thanks to Drachenkätzchen!)
* Added `test_input` command to view button codes in the console.
* For the Save Backup mod:
* Fixed extra files under `Saves` (e.g. manual backups) not being ignored.
* Fixed Android issue where game files were backed up.
* For modders:
* Added support for `.tmx` map files. (Thanks to [Platonymous for the underlying library](https://github.com/Platonymous/TMXTile)!)
* Added special handling for `Vector2` values in `.json` files, so they work consistently crossplatform.
* Reworked the order that asset editors/loaders are called between multiple mods to support some framework mods like Content Patcher and Json Assets. Note that the order is undefined and should not be depended on.
* Fixed incorrect warning about mods adding invalid schedules in some cases. The validation was unreliable, and has been removed.
* Fixed asset propagation not updating other players' sprites.
* Fixed asset propagation for player sprites not updating recolor maps (e.g. sleeves).
* Fixed asset propagation for marriage dialogue.
* Fixed dialogue asset changes not correctly propagated until the next day.
* Fixed `helper.Data.Read`/`WriteGlobalData` using the `Saves` folder instead of the game's appdata folder. The installer will move existing folders automatically.
* Fixed issue where a mod which implemented `IAssetEditor`/`IAssetLoader` on its entry class could then remove itself from the editor/loader list.
* For SMAPI/tool developers:
* Added internal performance monitoring (thanks to Drachenkätzchen!). This is disabled by default in the current version, but can be enabled using the `performance` console command.
* Added internal support for four-part versions to support SMAPI on Android.
* Rewrote `SemanticVersion` parsing.
* Updated links for the new r/SMAPI subreddit.
* The `/mods` web API endpoint now includes version mappings from the wiki.
* Dropped API support for the pre-3.0 update-check format.
## 3.1
Released 05 January 2019 for Stardew Valley 1.4 or later.
Released 05 January 2019 for Stardew Valley 1.4.1 or later.
* For players:
* Added separate group in 'skipped mods' list for broken dependencies, so it's easier to see what to fix first.
@ -12,13 +53,14 @@ Released 05 January 2019 for Stardew Valley 1.4 or later.
* Fixed compatibility with Linux Mint 18 (thanks to techge!), Arch Linux, and Linux systems with libhybris-utils installed.
* Fixed memory leak when repeatedly loading a save and returning to title.
* Fixed memory leak when mods reload assets.
* Fixes for Console Commands mod:
* added new clothing items;
* fixed spawning new flooring and rings (thanks to Mizzion!);
* fixed spawning custom rings added by mods;
* Fixed errors when some item data is invalid.
* Updated translations. Thanks to L30Bola (added Portuguese), PlussRolf (added Spanish), and shirutan (added Japanese)!
* For the Console Commands mod:
* Added new clothing items.
* Fixed spawning new flooring and rings (thanks to Mizzion!).
* Fixed spawning custom rings added by mods.
* Fixed errors when some item data is invalid.
* For the web UI:
* Added option to edit & reupload in the JSON validator.
* File uploads are now stored in Azure storage instead of Pastebin, due to ongoing Pastebin perfomance issues.

View File

@ -287,7 +287,11 @@ That will create a `Pathoschild.Stardew.ModBuildConfig-<version>.nupkg` file in
which can be uploaded to NuGet or referenced directly.
## Release notes
### Upcoming release
### 3.1
* Added support for semantic versioning 2.0.
* `0Harmony.dll` is now ignored if the mod references Harmony directly (it's bundled with SMAPI).
### 3.0
* Updated for SMAPI 3.0 and Stardew Valley 1.4.
* Added automatic support for `assets` folders.
* Added `$(GameExecutableName)` MSBuild variable.
@ -298,6 +302,7 @@ which can be uploaded to NuGet or referenced directly.
* 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.
### 2.2
* Added support for SMAPI 2.8+ (still compatible with earlier versions).

View File

@ -19,17 +19,8 @@ This document is about SMAPI itself; see also [mod build package](mod-package.md
## Customisation
### Configuration file
You can customise the SMAPI behaviour by editing the `smapi-internal/config.json` file in your game
folder.
Basic fields:
field | purpose
----------------- | -------
`DeveloperMode` | Default `false` (except in _SMAPI for developers_ releases). Whether to enable features intended for mod developers (mainly more detailed console logging).
`CheckForUpdates` | Default `true`. Whether SMAPI should check for a newer version when you load the game. If a new version is available, a small message will appear in the console. This doesn't affect the load time even if your connection is offline or slow, because it happens in the background.
`VerboseLogging` | Default `false`. Whether SMAPI should log more information about the game context.
`ModData` | Internal metadata about SMAPI mods. Changing this isn't recommended and may destabilise your game. See documentation in the file.
You can customise some SMAPI behaviour by editing the `smapi-internal/config.json` file in your
game folder. See documentation in the file for more info.
### Command-line arguments
The SMAPI installer recognises three command-line arguments:

View File

@ -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");
}
}
}

View File

@ -351,6 +351,12 @@ namespace StardewModdingApi.Installer
this.PrintDebug("----------------------------------------------------------------------------");
Console.WriteLine();
/****
** Back up user settings
****/
if (File.Exists(paths.ApiUserConfigPath))
File.Copy(paths.ApiUserConfigPath, paths.BundleApiUserConfigPath);
/****
** Always uninstall old files
****/
@ -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
****/

View File

@ -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>

View File

@ -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>

View File

@ -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")

View File

@ -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 &lt;IgnoreModFilePatterns&gt; 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>

View File

@ -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.");
}
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}
}
}

View File

@ -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);
}
}
}

View File

@ -1,4 +1,4 @@
using System.Linq;
using System.Linq;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
@ -13,19 +13,12 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
private bool InfiniteHealth;
/*********
** 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;
}
}

View File

@ -13,19 +13,12 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
private bool InfiniteMoney;
/*********
** 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;
}
}

View File

@ -1,4 +1,4 @@
using System.Linq;
using System.Linq;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
@ -13,19 +13,12 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
private bool InfiniteStamina;
/*********
** 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;
}
}

View File

@ -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], ' '))
))
);
}
}

View File

@ -1,4 +1,4 @@
using System.Linq;
using System.Linq;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
@ -16,19 +16,12 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
private bool FreezeTime;
/*********
** 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;
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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"
}

View File

@ -199,7 +199,7 @@ namespace StardewModdingAPI.Mods.SaveBackup
/// <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, Func<FileSystemInfo, bool> filter = null, bool copyRoot = true)
private void RecursiveCopy(FileSystemInfo source, DirectoryInfo targetFolder, Func<FileSystemInfo, bool> filter, bool copyRoot = true)
{
if (!targetFolder.Exists)
targetFolder.Create();
@ -229,22 +229,16 @@ namespace StardewModdingAPI.Mods.SaveBackup
/// <param name="entry">The current entry to check under <paramref name="savesFolder"/>.</param>
private bool MatchSaveFolders(DirectoryInfo savesFolder, FileSystemInfo entry)
{
this.Monitor.Log($"Checking {entry.FullName}...");
// only need to filter top-level entries
string parentPath = (entry as FileInfo)?.DirectoryName ?? (entry as DirectoryInfo)?.Parent?.FullName;
if (parentPath != savesFolder.FullName)
{
this.Monitor.Log(" OK: not root path");
return true;
}
// match folders with Name_ID format
bool include =
return
entry is DirectoryInfo
&& ulong.TryParse(entry.Name.Split('_').Last(), out _);
this.Monitor.Log(include ? " OK: name matches save folder format" : " SKIP: not a save folder");
return include;
}
}
}

View File

@ -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"
}

View File

@ -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")]
@ -28,65 +29,17 @@ namespace SMAPI.Tests.Utilities
[TestCase("1.2.3-some-tag.4+build.004", ExpectedResult = "1.2.3-some-tag.4+build.004")]
[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();
}
[Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from the individual numbers.")]
[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")]
[TestCase(1, 2, 3, " ", null, ExpectedResult = "1.2.3")]
[TestCase(1, 2, 3, "0", null, ExpectedResult = "1.2.3-0")]
[TestCase(1, 2, 3, "some-tag.4", null, ExpectedResult = "1.2.3-some-tag.4")]
[TestCase(1, 2, 3, "sOMe-TaG.4", null, ExpectedResult = "1.2.3-sOMe-TaG.4")]
[TestCase(1, 2, 3, "some-tag.4 ", null, ExpectedResult = "1.2.3-some-tag.4")]
[TestCase(1, 2, 3, "some-tag.4 ", "build.004", ExpectedResult = "1.2.3-some-tag.4+build.004")]
[TestCase(1, 2, 0, null, "3.4.5-build.004", ExpectedResult = "1.2.0+3.4.5-build.004")]
public string Constructor_FromParts(int major, int minor, int patch, string prerelease, string build)
{
// act
ISemanticVersion version = new SemanticVersion(major, minor, patch, prerelease, build);
ISemanticVersion version = new SemanticVersion(input);
// 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.");
return version.ToString();
}
[Test(Description = "Assert that the constructor throws the expected exception for invalid versions when constructed from the individual numbers.")]
[TestCase(0, 0, 0, null, null)]
[TestCase(-1, 0, 0, null, null)]
[TestCase(0, -1, 0, null, null)]
[TestCase(0, 0, -1, null, null)]
[TestCase(1, 0, 0, "-tag", null)]
[TestCase(1, 0, 0, "tag spaces", null)]
[TestCase(1, 0, 0, "tag~", null)]
[TestCase(1, 0, 0, null, "build~")]
public void Constructor_FromParts_WithInvalidValues(int major, int minor, int patch, string prerelease, string build)
{
this.AssertAndLogException<FormatException>(() => new SemanticVersion(major, minor, patch, prerelease, build));
}
[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")]
[TestCase(3000, 4000, 5000, ExpectedResult = "3000.4000.5000")]
public string Constructor_FromAssemblyVersion(int major, int minor, int patch)
{
// act
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.");
return version.ToString();
}
/// <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("")]
@ -116,10 +69,127 @@ namespace SMAPI.Tests.Utilities
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")]
[TestCase(1, 2, 3, " ", null, ExpectedResult = "1.2.3")]
[TestCase(1, 2, 3, "0", null, ExpectedResult = "1.2.3-0")]
[TestCase(1, 2, 3, "some-tag.4", null, ExpectedResult = "1.2.3-some-tag.4")]
[TestCase(1, 2, 3, "sOMe-TaG.4", null, ExpectedResult = "1.2.3-sOMe-TaG.4")]
[TestCase(1, 2, 3, "some-tag.4 ", null, ExpectedResult = "1.2.3-some-tag.4")]
[TestCase(1, 2, 3, "some-tag.4 ", "build.004", ExpectedResult = "1.2.3-some-tag.4+build.004")]
[TestCase(1, 2, 0, null, "3.4.5-build.004", ExpectedResult = "1.2.0+3.4.5-build.004")]
public string Constructor_FromParts(int major, int minor, int patch, string prerelease, string build)
{
// act
ISemanticVersion version = new SemanticVersion(major, minor, patch, prerelease, build);
// assert
this.AssertParts(version, major, minor, patch, prerelease, build, nonStandard: false);
return version.ToString();
}
/// <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)]
[TestCase(0, 0, -1, null, null)]
[TestCase(1, 0, 0, "-tag", null)]
[TestCase(1, 0, 0, "tag spaces", null)]
[TestCase(1, 0, 0, "tag~", null)]
[TestCase(1, 0, 0, null, "build~")]
public void Constructor_FromParts_WithInvalidValues(int major, int minor, int patch, string prerelease, string build)
{
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")]
[TestCase(3000, 4000, 5000, ExpectedResult = "3000.4000.5000")]
public string Constructor_FromAssemblyVersion(int major, int minor, int patch)
{
// act
ISemanticVersion version = new SemanticVersion(new Version(major, minor, patch));
// assert
this.AssertParts(version, major, minor, patch, null, null, nonStandard: false);
return version.ToString();
}
/****
** 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>

View File

@ -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();
}
}

View File

@ -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>

View File

@ -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];
}

View File

@ -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;
}
}

View File

@ -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,

View File

@ -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; }

View File

@ -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;
}
}
}

View File

@ -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'" />

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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.");

View File

@ -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,

View File

@ -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

View File

@ -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);
/*********

View File

@ -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 _);
}
}
}

View File

@ -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>

View File

@ -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;
}

View File

@ -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>
<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>
@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>
<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>
}
<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>
<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>
@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!"
))
}
<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)
<div class="area">
@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>
<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>
}
<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>
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>
<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>
<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>
<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>
@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!"
))
}
</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>

View File

@ -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>

View File

@ -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>

View File

@ -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;
}

View File

@ -20,13 +20,13 @@ 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>Android SMAPI's current semantic version.</summary>
public static ISemanticVersion AndroidApiVersion { get; } = new Toolkit.SemanticVersion("3.1.0-experimental");
/// <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;
@ -58,12 +58,18 @@ namespace StardewModdingAPI
/// <summary>The URL of the SMAPI home page.</summary>
internal const string HomePageUrl = "https://github.com/MartyrPher/SMAPI-Android-Installer/releases/latest";
/// <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");

View File

@ -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;

View File

@ -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)
);
}
}
}

View File

@ -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);
}
}
}
}

View File

@ -160,6 +160,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
// unpacked map
case ".tbin":
case ".tmx":
{
// validate
if (typeof(T) != typeof(Map))

View File

@ -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);
}
}
}

View File

@ -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; }
}
}

View File

@ -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);
try
{
this.PerformanceMonitor.Track(this.EventName, this.GetModNameForPerformanceCounters(handler), () => handler.Invoke(null, args));
}
catch (Exception ex)
{
this.LogError(handler, ex);
}
}
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>

View File

@ -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;
}
}

View File

@ -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;
@ -83,6 +84,75 @@ namespace StardewModdingAPI.Framework
return exception;
}
/****
** 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
****/

View File

@ -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>

View File

@ -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;
}
}
}

View File

@ -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";
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -25,6 +25,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;
@ -111,7 +112,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(
@ -135,6 +136,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
@ -155,6 +160,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)
{
@ -163,7 +171,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
@ -196,7 +208,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;
}
@ -226,7 +238,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
@ -243,6 +255,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,
@ -258,6 +271,7 @@ namespace StardewModdingAPI.Framework
new ObjectErrorPatch(),
new LoadContextPatch(this.Reflection, this.GameInstance.OnLoadStageChanged),
new LoadErrorPatch(this.Monitor, this.GameInstance.OnSaveContentRemoved),
new ScheduleErrorPatch(this.MonitorForGame)
new SaveBackupPatch(this.EventManager)
);
@ -313,7 +327,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();
@ -412,6 +426,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);
}
this.GameInstance.IsGameSuspended = true;
new Thread(() =>
{
@ -610,6 +635,8 @@ namespace StardewModdingAPI.Framework
else
this.Monitor.Log(" SMAPI okay.", LogLevel.Trace);
updateFound = response.SuggestedUpdate?.Version;
// show errors
if (response.Errors.Any())
{
@ -812,13 +839,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
@ -867,6 +894,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>

View File

@ -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;
@ -62,6 +63,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
@ -161,11 +165,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;
@ -185,6 +190,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();
@ -326,6 +332,7 @@ namespace StardewModdingAPI.Framework
try
{
this.DeprecationManager.PrintQueued();
this.PerformanceMonitor.PrintQueuedAlerts();
/*********
** First-tick initialization
@ -414,7 +421,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

View File

@ -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"

View File

@ -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>

View File

@ -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)
{
this.Reflection.GetField<int>(villager, "scheduleTimeToTry").SetValue(9999999); // 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;

View File

@ -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
@ -63,11 +65,25 @@ namespace StardewModdingAPI.Patches
/// <param name="gamelocations">The game locations being loaded.</param>
/// <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;
}
}
}
}
}

View File

@ -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;
}
}
}
}

View File

@ -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
{

View File

@ -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.
*/
{

View File

@ -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>

3
src/SMAPI/i18n/fr.json Normal file
View File

@ -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)."
}