diff options
author | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2020-09-07 13:06:27 -0400 |
---|---|---|
committer | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2020-09-07 13:06:27 -0400 |
commit | 5e43bdbf5cd6dbab36c25287c85d42ccfeea2c83 (patch) | |
tree | 0a42305174eb84561a584549cd685c5e95670f36 /src | |
parent | 8da88b8fe5b41739c5cd0df3280b9770fc7f10a4 (diff) | |
parent | f9fac11028354f15d786d5b854608edb10716f79 (diff) | |
download | SMAPI-5e43bdbf5cd6dbab36c25287c85d42ccfeea2c83.tar.gz SMAPI-5e43bdbf5cd6dbab36c25287c85d42ccfeea2c83.tar.bz2 SMAPI-5e43bdbf5cd6dbab36c25287c85d42ccfeea2c83.zip |
Merge branch 'develop' into stable
Diffstat (limited to 'src')
126 files changed, 3632 insertions, 3306 deletions
diff --git a/src/SMAPI.Installer/Enums/ScriptAction.cs b/src/SMAPI.Installer/Enums/ScriptAction.cs index e62b2a7c..27f649a6 100644 --- a/src/SMAPI.Installer/Enums/ScriptAction.cs +++ b/src/SMAPI.Installer/Enums/ScriptAction.cs @@ -9,4 +9,4 @@ /// <summary>Remove SMAPI from the game directory.</summary> Uninstall } -}
\ No newline at end of file +} diff --git a/src/SMAPI.Installer/InteractiveInstaller.cs b/src/SMAPI.Installer/InteractiveInstaller.cs index dc96e2e8..d0ef0b8d 100644 --- a/src/SMAPI.Installer/InteractiveInstaller.cs +++ b/src/SMAPI.Installer/InteractiveInstaller.cs @@ -35,8 +35,6 @@ namespace StardewModdingApi.Installer "SMAPI.ConsoleCommands" }; - - /// <summary>Get the absolute file or folder paths to remove when uninstalling SMAPI.</summary> /// <param name="installDir">The folder for Stardew Valley and SMAPI.</param> /// <param name="modsDir">The folder for SMAPI mods.</param> @@ -84,6 +82,7 @@ namespace StardewModdingApi.Installer foreach (DirectoryInfo modDir in modsDir.EnumerateDirectories()) yield return Path.Combine(modDir.FullName, ".cache"); // 1.4–1.7 } + yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley", "ErrorLogs"); // remove old log files } diff --git a/src/SMAPI.Installer/Program.cs b/src/SMAPI.Installer/Program.cs index dc6c97f4..6c479621 100644 --- a/src/SMAPI.Installer/Program.cs +++ b/src/SMAPI.Installer/Program.cs @@ -3,8 +3,6 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.IO.Compression; using System.Reflection; -using StardewModdingAPI.Internal; -using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingApi.Installer { diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs index ced05a28..c47c8ec4 100644 --- a/src/SMAPI.ModBuildConfig/DeployModTask.cs +++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs @@ -32,9 +32,9 @@ namespace StardewModdingAPI.ModBuildConfig [Required] public string TargetDir { get; set; } - /// <summary>The folder containing the game files.</summary> + /// <summary>The folder containing the game's mod folders.</summary> [Required] - public string GameDir { get; set; } + public string GameModsDir { get; set; } /// <summary>Whether to enable copying the mod files into the game's Mods folder.</summary> [Required] @@ -69,7 +69,7 @@ namespace StardewModdingAPI.ModBuildConfig // deploy mod files if (this.EnableModDeploy) { - string outputPath = Path.Combine(this.GameDir, "Mods", this.EscapeInvalidFilenameCharacters(this.ModFolderName)); + string outputPath = Path.Combine(this.GameModsDir, this.EscapeInvalidFilenameCharacters(this.ModFolderName)); this.Log.LogMessage(MessageImportance.High, $"The mod build package is copying the mod files to {outputPath}..."); this.CreateModFolder(package.GetFiles(), outputPath); } diff --git a/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj b/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj index 5061b01b..5e35b7e9 100644 --- a/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj +++ b/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <RootNamespace>StardewModdingAPI.ModBuildConfig</RootNamespace> - <Version>3.1.0</Version> + <Version>3.2.0</Version> <TargetFramework>net45</TargetFramework> <PlatformTarget>x86</PlatformTarget> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> diff --git a/src/SMAPI.ModBuildConfig/build/smapi.targets b/src/SMAPI.ModBuildConfig/build/smapi.targets index 03db7490..0a0db190 100644 --- a/src/SMAPI.ModBuildConfig/build/smapi.targets +++ b/src/SMAPI.ModBuildConfig/build/smapi.targets @@ -18,6 +18,7 @@ <!-- set default package options --> <ModFolderName Condition="'$(ModFolderName)' == ''">$(MSBuildProjectName)</ModFolderName> <ModZipPath Condition="'$(ModZipPath)' == ''">$(TargetDir)</ModZipPath> + <GameModsPath Condition="'$(GameModsPath)' == ''">$([System.IO.Path]::Combine($(GamePath), 'Mods')</GameModsPath> <EnableModDeploy Condition="'$(EnableModDeploy)' == ''">true</EnableModDeploy> <EnableModZip Condition="'$(EnableModZip)' == ''">true</EnableModZip> <EnableHarmony Condition="'$(EnableHarmony)' == ''">false</EnableHarmony> @@ -86,7 +87,7 @@ ProjectDir="$(ProjectDir)" TargetDir="$(TargetDir)" - GameDir="$(GamePath)" + GameModsDir="$(GameModsPath)" IgnoreModFilePatterns="$(IgnoreModFilePatterns)" /> </Target> diff --git a/src/SMAPI.ModBuildConfig/package.nuspec b/src/SMAPI.ModBuildConfig/package.nuspec index afb03cec..c0b0799a 100644 --- a/src/SMAPI.ModBuildConfig/package.nuspec +++ b/src/SMAPI.ModBuildConfig/package.nuspec @@ -2,7 +2,7 @@ <package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd"> <metadata> <id>Pathoschild.Stardew.ModBuildConfig</id> - <version>3.1.0</version> + <version>3.2.0</version> <title>Build package for SMAPI mods</title> <authors>Pathoschild</authors> <owners>Pathoschild</owners> @@ -14,9 +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.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). + 3.2.0: + - Added option to change `Mods` folder path. + - Rewrote documentation to make it easier to read. </releaseNotes> </metadata> <files> diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json index 1be55776..368f470c 100644 --- a/src/SMAPI.Mods.ConsoleCommands/manifest.json +++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json @@ -1,9 +1,9 @@ { "Name": "Console Commands", "Author": "SMAPI", - "Version": "3.6.2", + "Version": "3.7.0", "Description": "Adds SMAPI console commands that let you manipulate the game.", "UniqueID": "SMAPI.ConsoleCommands", "EntryDll": "ConsoleCommands.dll", - "MinimumApiVersion": "3.6.2" + "MinimumApiVersion": "3.7.0" } diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json index c57ac162..8a95a78f 100644 --- a/src/SMAPI.Mods.SaveBackup/manifest.json +++ b/src/SMAPI.Mods.SaveBackup/manifest.json @@ -1,9 +1,9 @@ { "Name": "Save Backup", "Author": "SMAPI", - "Version": "3.6.2", + "Version": "3.7.0", "Description": "Automatically backs up all your saves once per day into its folder.", "UniqueID": "SMAPI.SaveBackup", "EntryDll": "SaveBackup.dll", - "MinimumApiVersion": "3.6.2" + "MinimumApiVersion": "3.7.0" } diff --git a/src/SMAPI.Tests/Core/ModResolverTests.cs b/src/SMAPI.Tests/Core/ModResolverTests.cs index 45b3673b..78056ef7 100644 --- a/src/SMAPI.Tests/Core/ModResolverTests.cs +++ b/src/SMAPI.Tests/Core/ModResolverTests.cs @@ -154,7 +154,7 @@ namespace SMAPI.Tests.Core new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null); // assert - mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>()), Times.Once, "The validation did not fail the metadata."); + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "The validation did not fail the metadata."); } [Test(Description = "Assert that validation fails when the minimum API version is higher than the current SMAPI version.")] @@ -169,7 +169,7 @@ namespace SMAPI.Tests.Core new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null); // assert - mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>()), Times.Once, "The validation did not fail the metadata."); + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "The validation did not fail the metadata."); } [Test(Description = "Assert that validation fails when the manifest references a DLL that does not exist.")] @@ -183,7 +183,7 @@ namespace SMAPI.Tests.Core new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null); // assert - mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>()), Times.Once, "The validation did not fail the metadata."); + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "The validation did not fail the metadata."); } [Test(Description = "Assert that validation fails when multiple mods have the same unique ID.")] @@ -200,8 +200,8 @@ namespace SMAPI.Tests.Core new ModResolver().ValidateManifests(new[] { modA.Object, modB.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null); // assert - modA.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>()), Times.Once, "The validation did not fail the first mod with a unique ID."); - modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>()), Times.Once, "The validation did not fail the second mod with a unique ID."); + modA.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "The validation did not fail the first mod with a unique ID."); + modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "The validation did not fail the second mod with a unique ID."); } [Test(Description = "Assert that validation fails when the manifest references a DLL that does not exist.")] @@ -367,9 +367,9 @@ namespace SMAPI.Tests.Core Assert.AreEqual(5, mods.Length, 0, "Expected to get the same number of mods input."); Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A."); - modC.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>()), Times.Once, "Mod C was expected to fail since it's part of a dependency loop."); - modD.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>()), Times.Once, "Mod D was expected to fail since it's part of a dependency loop."); - modE.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>()), Times.Once, "Mod E was expected to fail since it's part of a dependency loop."); + modC.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "Mod C was expected to fail since it's part of a dependency loop."); + modD.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "Mod D was expected to fail since it's part of a dependency loop."); + modE.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "Mod E was expected to fail since it's part of a dependency loop."); } [Test(Description = "Assert that dependencies are sorted correctly even if some of the mods failed during metadata loading.")] @@ -408,7 +408,7 @@ namespace SMAPI.Tests.Core // assert Assert.AreEqual(2, mods.Length, 0, "Expected to get the same number of mods input."); - modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>()), Times.Once, "Mod B unexpectedly didn't fail even though it needs a newer version of Mod A."); + modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "Mod B unexpectedly didn't fail even though it needs a newer version of Mod A."); } [Test(Description = "Assert that dependencies are accepted if they meet the minimum version.")] @@ -525,8 +525,8 @@ namespace SMAPI.Tests.Core if (allowStatusChange) { mod - .Setup(p => p.SetStatus(It.IsAny<ModMetadataStatus>(), It.IsAny<string>())) - .Callback<ModMetadataStatus, string>((status, message) => Console.WriteLine($"<{manifest.UniqueID} changed status: [{status}] {message}")) + .Setup(p => p.SetStatus(It.IsAny<ModMetadataStatus>(), It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>())) + .Callback<ModMetadataStatus, ModFailReason, string, string>((status, failReason, message, errorDetails) => Console.WriteLine($"<{manifest.UniqueID} changed status: [{status}] {message}\n{failReason}\n{errorDetails}")) .Returns(mod.Object); } return mod; diff --git a/src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs b/src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs new file mode 100644 index 00000000..ea69a9ea --- /dev/null +++ b/src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs @@ -0,0 +1,285 @@ +using NUnit.Framework; +using StardewModdingAPI.Toolkit.Utilities; + +namespace SMAPI.Tests.Utilities +{ + /// <summary>Unit tests for <see cref="PathUtilities"/>.</summary> + [TestFixture] + internal class PathUtilitiesTests + { + /********* + ** Sample data + *********/ + /// <summary>Sample paths used in unit tests.</summary> + public static readonly SamplePath[] SamplePaths = { + // Windows absolute path + new SamplePath + { + OriginalPath = @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley", + + Segments = new[] { "C:", "Program Files (x86)", "Steam", "steamapps", "common", "Stardew Valley" }, + SegmentsLimit3 = new [] { "C:", "Program Files (x86)", @"Steam\steamapps\common\Stardew Valley" }, + + NormalizedOnWindows = @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley", + NormalizedOnUnix = @"C:/Program Files (x86)/Steam/steamapps/common/Stardew Valley" + }, + + // Windows absolute path (with trailing slash) + new SamplePath + { + OriginalPath = @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\", + + Segments = new[] { "C:", "Program Files (x86)", "Steam", "steamapps", "common", "Stardew Valley" }, + SegmentsLimit3 = new [] { "C:", "Program Files (x86)", @"Steam\steamapps\common\Stardew Valley\" }, + + NormalizedOnWindows = @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\", + NormalizedOnUnix = @"C:/Program Files (x86)/Steam/steamapps/common/Stardew Valley/" + }, + + // Windows relative path + new SamplePath + { + OriginalPath = @"Content\Characters\Dialogue\Abigail", + + Segments = new [] { "Content", "Characters", "Dialogue", "Abigail" }, + SegmentsLimit3 = new [] { "Content", "Characters", @"Dialogue\Abigail" }, + + NormalizedOnWindows = @"Content\Characters\Dialogue\Abigail", + NormalizedOnUnix = @"Content/Characters/Dialogue/Abigail" + }, + + // Windows relative path (with directory climbing) + new SamplePath + { + OriginalPath = @"..\..\Content", + + Segments = new [] { "..", "..", "Content" }, + SegmentsLimit3 = new [] { "..", "..", "Content" }, + + NormalizedOnWindows = @"..\..\Content", + NormalizedOnUnix = @"../../Content" + }, + + // Windows UNC path + new SamplePath + { + OriginalPath = @"\\unc\path", + + Segments = new [] { "unc", "path" }, + SegmentsLimit3 = new [] { "unc", "path" }, + + NormalizedOnWindows = @"\\unc\path", + NormalizedOnUnix = "/unc/path" // there's no good way to normalize this on Unix since UNC paths aren't supported; path normalization is meant for asset names anyway, so this test only ensures it returns some sort of sane value + }, + + // Linux absolute path + new SamplePath + { + OriginalPath = @"/home/.steam/steam/steamapps/common/Stardew Valley", + + Segments = new [] { "home", ".steam", "steam", "steamapps", "common", "Stardew Valley" }, + SegmentsLimit3 = new [] { "home", ".steam", "steam/steamapps/common/Stardew Valley" }, + + NormalizedOnWindows = @"\home\.steam\steam\steamapps\common\Stardew Valley", + NormalizedOnUnix = @"/home/.steam/steam/steamapps/common/Stardew Valley" + }, + + // Linux absolute path (with trailing slash) + new SamplePath + { + OriginalPath = @"/home/.steam/steam/steamapps/common/Stardew Valley/", + + Segments = new [] { "home", ".steam", "steam", "steamapps", "common", "Stardew Valley" }, + SegmentsLimit3 = new [] { "home", ".steam", "steam/steamapps/common/Stardew Valley/" }, + + NormalizedOnWindows = @"\home\.steam\steam\steamapps\common\Stardew Valley\", + NormalizedOnUnix = @"/home/.steam/steam/steamapps/common/Stardew Valley/" + }, + + // Linux absolute path (with ~) + new SamplePath + { + OriginalPath = @"~/.steam/steam/steamapps/common/Stardew Valley", + + Segments = new [] { "~", ".steam", "steam", "steamapps", "common", "Stardew Valley" }, + SegmentsLimit3 = new [] { "~", ".steam", "steam/steamapps/common/Stardew Valley" }, + + NormalizedOnWindows = @"~\.steam\steam\steamapps\common\Stardew Valley", + NormalizedOnUnix = @"~/.steam/steam/steamapps/common/Stardew Valley" + }, + + // Linux relative path + new SamplePath + { + OriginalPath = @"Content/Characters/Dialogue/Abigail", + + Segments = new [] { "Content", "Characters", "Dialogue", "Abigail" }, + SegmentsLimit3 = new [] { "Content", "Characters", "Dialogue/Abigail" }, + + NormalizedOnWindows = @"Content\Characters\Dialogue\Abigail", + NormalizedOnUnix = @"Content/Characters/Dialogue/Abigail" + }, + + // Linux relative path (with directory climbing) + new SamplePath + { + OriginalPath = @"../../Content", + + Segments = new [] { "..", "..", "Content" }, + SegmentsLimit3 = new [] { "..", "..", "Content" }, + + NormalizedOnWindows = @"..\..\Content", + NormalizedOnUnix = @"../../Content" + }, + + // Mixed directory separators + new SamplePath + { + OriginalPath = @"C:\some/mixed\path/separators", + + Segments = new [] { "C:", "some", "mixed", "path", "separators" }, + SegmentsLimit3 = new [] { "C:", "some", @"mixed\path/separators" }, + + NormalizedOnWindows = @"C:\some\mixed\path\separators", + NormalizedOnUnix = @"C:/some/mixed/path/separators" + }, + }; + + + /********* + ** Unit tests + *********/ + /**** + ** GetSegments + ****/ + [Test(Description = "Assert that PathUtilities.GetSegments splits paths correctly.")] + [TestCaseSource(nameof(PathUtilitiesTests.SamplePaths))] + public void GetSegments(SamplePath path) + { + // act + string[] segments = PathUtilities.GetSegments(path.OriginalPath); + + // assert + Assert.AreEqual(path.Segments, segments); + } + + [Test(Description = "Assert that PathUtilities.GetSegments splits paths correctly when given a limit.")] + [TestCaseSource(nameof(PathUtilitiesTests.SamplePaths))] + public void GetSegments_WithLimit(SamplePath path) + { + // act + string[] segments = PathUtilities.GetSegments(path.OriginalPath, 3); + + // assert + Assert.AreEqual(path.SegmentsLimit3, segments); + } + + /**** + ** NormalizePathSeparators + ****/ + [Test(Description = "Assert that PathUtilities.NormalizePathSeparators normalizes paths correctly.")] + [TestCaseSource(nameof(PathUtilitiesTests.SamplePaths))] + public void NormalizePathSeparators(SamplePath path) + { + // act + string normalized = PathUtilities.NormalizePathSeparators(path.OriginalPath); + + // assert +#if SMAPI_FOR_WINDOWS + Assert.AreEqual(path.NormalizedOnWindows, normalized); +#else + Assert.AreEqual(path.NormalizedOnUnix, normalized); +#endif + } + + /**** + ** GetRelativePath + ****/ + [Test(Description = "Assert that PathUtilities.GetRelativePath returns the expected values.")] +#if SMAPI_FOR_WINDOWS + [TestCase( + @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley", + @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\Mods\Automate", + ExpectedResult = @"Mods\Automate" + )] + [TestCase( + @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\Mods\Automate", + @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\Content", + ExpectedResult = @"..\..\Content" + )] + [TestCase( + @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\Mods\Automate", + @"D:\another-drive", + ExpectedResult = @"D:\another-drive" + )] + [TestCase( + @"\\parent\unc", + @"\\parent\unc\path\to\child", + ExpectedResult = @"path\to\child" + )] + [TestCase( + @"\\parent\unc", + @"\\adjacent\unc", + ExpectedResult = @"\\adjacent\unc" + )] + [TestCase( + @"C:\same\path", + @"C:\same\path", + ExpectedResult = @"." + )] + [TestCase( + @"C:\parent", + @"C:\PARENT\child", + ExpectedResult = @"child" + )] +#else + [TestCase( + @"~/.steam/steam/steamapps/common/Stardew Valley", + @"~/.steam/steam/steamapps/common/Stardew Valley/Mods/Automate", + ExpectedResult = @"Mods/Automate" + )] + [TestCase( + @"~/.steam/steam/steamapps/common/Stardew Valley/Mods/Automate", + @"~/.steam/steam/steamapps/common/Stardew Valley/Content", + ExpectedResult = @"../../Content" + )] + [TestCase( + @"~/.steam/steam/steamapps/common/Stardew Valley/Mods/Automate", + @"/mnt/another-drive", + ExpectedResult = @"/mnt/another-drive" + )] + [TestCase( + @"~/same/path", + @"~/same/path", + ExpectedResult = @"." + )] + [TestCase( + @"~/parent", + @"~/PARENT/child", + ExpectedResult = @"child" // note: incorrect on Linux and sometimes MacOS, but not worth the complexity of detecting whether the filesystem is case-sensitive for SMAPI's purposes + )] +#endif + public string GetRelativePath(string sourceDir, string targetPath) + { + return PathUtilities.GetRelativePath(sourceDir, targetPath); + } + + + /********* + ** Private classes + *********/ + public class SamplePath + { + public string OriginalPath { get; set; } + public string[] Segments { get; set; } + public string[] SegmentsLimit3 { get; set; } + public string NormalizedOnWindows { get; set; } + public string NormalizedOnUnix { get; set; } + + public override string ToString() + { + return this.OriginalPath; + } + } + } +} diff --git a/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs b/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs new file mode 100644 index 00000000..b01d8b21 --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs @@ -0,0 +1,163 @@ +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +#if SMAPI_FOR_WINDOWS +using System.Management; +#endif +using System.Runtime.InteropServices; +using StardewModdingAPI.Toolkit.Utilities; + +namespace StardewModdingAPI.Toolkit.Framework +{ + /// <summary>Provides low-level methods for fetching environment information.</summary> + /// <remarks>This is used by the SMAPI core before the toolkit DLL is available; most code should use <see cref="EnvironmentUtility"/> instead.</remarks> + internal static class LowLevelEnvironmentUtility + { + /********* + ** Fields + *********/ + /// <summary>Get the OS name from the system uname command.</summary> + /// <param name="buffer">The buffer to fill with the resulting string.</param> + [DllImport("libc")] + static extern int uname(IntPtr buffer); + + + /********* + ** Public methods + *********/ + /// <summary>Detect the current OS.</summary> + public static string DetectPlatform() + { + switch (Environment.OSVersion.Platform) + { + case PlatformID.MacOSX: + return nameof(Platform.Mac); + + case PlatformID.Unix when LowLevelEnvironmentUtility.IsRunningAndroid(): + return nameof(Platform.Android); + + case PlatformID.Unix when LowLevelEnvironmentUtility.IsRunningMac(): + return nameof(Platform.Mac); + + case PlatformID.Unix: + return nameof(Platform.Linux); + + default: + return nameof(Platform.Windows); + } + } + + + /// <summary>Get the human-readable OS name and version.</summary> + /// <param name="platform">The current platform.</param> + [SuppressMessage("ReSharper", "EmptyGeneralCatchClause", Justification = "Error suppressed deliberately to fallback to default behaviour.")] + public static string GetFriendlyPlatformName(string platform) + { +#if SMAPI_FOR_WINDOWS + try + { + return new ManagementObjectSearcher("SELECT Caption FROM Win32_OperatingSystem") + .Get() + .Cast<ManagementObject>() + .Select(entry => entry.GetPropertyValue("Caption").ToString()) + .FirstOrDefault(); + } + catch { } +#endif + + string name = Environment.OSVersion.ToString(); + switch (platform) + { + case nameof(Platform.Android): + name = $"Android {name}"; + break; + + case nameof(Platform.Mac): + name = $"MacOS {name}"; + break; + } + return name; + } + + /// <summary>Get the name of the Stardew Valley executable.</summary> + /// <param name="platform">The current platform.</param> + public static string GetExecutableName(string platform) + { + return platform == nameof(Platform.Windows) + ? "Stardew Valley.exe" + : "StardewValley.exe"; + } + + /// <summary>Get whether the platform uses Mono.</summary> + /// <param name="platform">The current platform.</param> + public static bool IsMono(string platform) + { + return platform == nameof(Platform.Linux) || platform == nameof(Platform.Mac); + } + + + /********* + ** Private methods + *********/ + /// <summary>Detect whether the code is running on Android.</summary> + /// <remarks> + /// This code is derived from https://stackoverflow.com/a/47521647/262123. It detects Android by calling the + /// <c>getprop</c> system command to check for an Android-specific property. + /// </remarks> + private static bool IsRunningAndroid() + { + using Process process = new Process + { + StartInfo = + { + FileName = "getprop", + Arguments = "ro.build.user", + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + try + { + process.Start(); + string output = process.StandardOutput.ReadToEnd(); + return !string.IsNullOrWhiteSpace(output); + } + catch + { + return false; + } + } + + /// <summary>Detect whether the code is running on Mac.</summary> + /// <remarks> + /// This code is derived from the Mono project (see System.Windows.Forms/System.Windows.Forms/XplatUI.cs). It detects Mac by calling the + /// <c>uname</c> system command and checking the response, which is always 'Darwin' for MacOS. + /// </remarks> + private static bool IsRunningMac() + { + IntPtr buffer = IntPtr.Zero; + try + { + buffer = Marshal.AllocHGlobal(8192); + if (LowLevelEnvironmentUtility.uname(buffer) == 0) + { + string os = Marshal.PtrToStringAnsi(buffer); + return os == "Darwin"; + } + return false; + } + catch + { + return false; // default to Linux + } + finally + { + if (buffer != IntPtr.Zero) + Marshal.FreeHGlobal(buffer); + } + } + } +} diff --git a/src/SMAPI.Toolkit/SemanticVersion.cs b/src/SMAPI.Toolkit/SemanticVersion.cs index 1a76bec3..0f341665 100644 --- a/src/SMAPI.Toolkit/SemanticVersion.cs +++ b/src/SMAPI.Toolkit/SemanticVersion.cs @@ -25,22 +25,22 @@ namespace StardewModdingAPI.Toolkit /********* ** Accessors *********/ - /// <summary>The major version incremented for major API changes.</summary> + /// <inheritdoc /> public int MajorVersion { get; } - /// <summary>The minor version incremented for backwards-compatible changes.</summary> + /// <inheritdoc /> public int MinorVersion { get; } - /// <summary>The patch version for backwards-compatible bug fixes.</summary> + /// <inheritdoc /> 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> + /// <inheritdoc /> public string PrereleaseTag { get; } - /// <summary>Optional build metadata. This is ignored when determining version precedence.</summary> + /// <inheritdoc /> public string BuildMetadata { get; } @@ -103,9 +103,7 @@ namespace StardewModdingAPI.Toolkit this.AssertValid(); } - /// <summary>Get an integer indicating whether this version precedes (less than 0), supersedes (more than 0), or is equivalent to (0) the specified version.</summary> - /// <param name="other">The version to compare with this instance.</param> - /// <exception cref="ArgumentNullException">The <paramref name="other"/> value is null.</exception> + /// <inheritdoc /> public int CompareTo(ISemanticVersion other) { if (other == null) @@ -113,68 +111,55 @@ namespace StardewModdingAPI.Toolkit 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> - /// <returns>true if the current object is equal to the <paramref name="other" /> parameter; otherwise, false.</returns> - /// <param name="other">An object to compare with this object.</param> + /// <inheritdoc /> public bool Equals(ISemanticVersion other) { return other != null && this.CompareTo(other) == 0; } - /// <summary>Whether this is a prerelease version.</summary> + /// <inheritdoc /> public bool IsPrerelease() { return !string.IsNullOrWhiteSpace(this.PrereleaseTag); } - /// <summary>Get whether this version is older than the specified version.</summary> - /// <param name="other">The version to compare with this instance.</param> + /// <inheritdoc /> public bool IsOlderThan(ISemanticVersion other) { return this.CompareTo(other) < 0; } - /// <summary>Get whether this version is older than the specified version.</summary> - /// <param name="other">The version to compare with this instance.</param> - /// <exception cref="FormatException">The specified version is not a valid semantic version.</exception> + /// <inheritdoc /> public bool IsOlderThan(string other) { return this.IsOlderThan(new SemanticVersion(other, allowNonStandard: true)); } - /// <summary>Get whether this version is newer than the specified version.</summary> - /// <param name="other">The version to compare with this instance.</param> + /// <inheritdoc /> public bool IsNewerThan(ISemanticVersion other) { return this.CompareTo(other) > 0; } - /// <summary>Get whether this version is newer than the specified version.</summary> - /// <param name="other">The version to compare with this instance.</param> - /// <exception cref="FormatException">The specified version is not a valid semantic version.</exception> + /// <inheritdoc /> public bool IsNewerThan(string other) { return this.IsNewerThan(new SemanticVersion(other, allowNonStandard: true)); } - /// <summary>Get whether this version is between two specified versions (inclusively).</summary> - /// <param name="min">The minimum version.</param> - /// <param name="max">The maximum version.</param> + /// <inheritdoc /> public bool IsBetween(ISemanticVersion min, ISemanticVersion max) { return this.CompareTo(min) >= 0 && this.CompareTo(max) <= 0; } - /// <summary>Get whether this version is between two specified versions (inclusively).</summary> - /// <param name="min">The minimum version.</param> - /// <param name="max">The maximum version.</param> - /// <exception cref="FormatException">One of the specified versions is not a valid semantic version.</exception> + /// <inheritdoc /> public bool IsBetween(string min, string max) { return this.IsBetween(new SemanticVersion(min, allowNonStandard: true), new SemanticVersion(max, allowNonStandard: true)); } - /// <summary>Get a string representation of the version.</summary> + /// <inheritdoc cref="ISemanticVersion.ToString" /> public override string ToString() { string version = $"{this.MajorVersion}.{this.MinorVersion}.{this.PatchVersion}"; @@ -187,7 +172,7 @@ namespace StardewModdingAPI.Toolkit return version; } - /// <summary>Whether the version uses non-standard extensions, like four-part game versions on some platforms.</summary> + /// <inheritdoc /> public bool IsNonStandard() { return this.PlatformRelease != 0; diff --git a/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs b/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs index 1e490448..4ef578f7 100644 --- a/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs +++ b/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs @@ -1,11 +1,6 @@ using System; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Linq; -#if SMAPI_FOR_WINDOWS -using System.Management; -#endif -using System.Runtime.InteropServices; +using StardewModdingAPI.Toolkit.Framework; namespace StardewModdingAPI.Toolkit.Utilities { @@ -18,11 +13,6 @@ namespace StardewModdingAPI.Toolkit.Utilities /// <summary>The cached platform.</summary> private static Platform? CachedPlatform; - /// <summary>Get the OS name from the system uname command.</summary> - /// <param name="buffer">The buffer to fill with the resulting string.</param> - [DllImport("libc")] - static extern int uname(IntPtr buffer); - /********* ** Public methods @@ -30,7 +20,15 @@ namespace StardewModdingAPI.Toolkit.Utilities /// <summary>Detect the current OS.</summary> public static Platform DetectPlatform() { - return EnvironmentUtility.CachedPlatform ??= EnvironmentUtility.DetectPlatformImpl(); + Platform? platform = EnvironmentUtility.CachedPlatform; + + if (platform == null) + { + string rawPlatform = LowLevelEnvironmentUtility.DetectPlatform(); + EnvironmentUtility.CachedPlatform = platform = (Platform)Enum.Parse(typeof(Platform), rawPlatform, ignoreCase: true); + } + + return platform.Value; } @@ -39,132 +37,21 @@ namespace StardewModdingAPI.Toolkit.Utilities [SuppressMessage("ReSharper", "EmptyGeneralCatchClause", Justification = "Error suppressed deliberately to fallback to default behaviour.")] public static string GetFriendlyPlatformName(Platform platform) { -#if SMAPI_FOR_WINDOWS - try - { - return new ManagementObjectSearcher("SELECT Caption FROM Win32_OperatingSystem") - .Get() - .Cast<ManagementObject>() - .Select(entry => entry.GetPropertyValue("Caption").ToString()) - .FirstOrDefault(); - } - catch { } -#endif - - string name = Environment.OSVersion.ToString(); - switch (platform) - { - case Platform.Android: - name = $"Android {name}"; - break; - - case Platform.Mac: - name = $"MacOS {name}"; - break; - } - return name; + return LowLevelEnvironmentUtility.GetFriendlyPlatformName(platform.ToString()); } /// <summary>Get the name of the Stardew Valley executable.</summary> /// <param name="platform">The current platform.</param> public static string GetExecutableName(Platform platform) { - return platform == Platform.Windows - ? "Stardew Valley.exe" - : "StardewValley.exe"; + return LowLevelEnvironmentUtility.GetExecutableName(platform.ToString()); } /// <summary>Get whether the platform uses Mono.</summary> /// <param name="platform">The current platform.</param> public static bool IsMono(this Platform platform) { - return platform == Platform.Linux || platform == Platform.Mac; - } - - - /********* - ** Private methods - *********/ - /// <summary>Detect the current OS.</summary> - private static Platform DetectPlatformImpl() - { - switch (Environment.OSVersion.Platform) - { - case PlatformID.MacOSX: - return Platform.Mac; - - case PlatformID.Unix when EnvironmentUtility.IsRunningAndroid(): - return Platform.Android; - - case PlatformID.Unix when EnvironmentUtility.IsRunningMac(): - return Platform.Mac; - - case PlatformID.Unix: - return Platform.Linux; - - default: - return Platform.Windows; - } - } - - /// <summary>Detect whether the code is running on Android.</summary> - /// <remarks> - /// This code is derived from https://stackoverflow.com/a/47521647/262123. It detects Android by calling the - /// <c>getprop</c> system command to check for an Android-specific property. - /// </remarks> - private static bool IsRunningAndroid() - { - using Process process = new Process - { - StartInfo = - { - FileName = "getprop", - Arguments = "ro.build.user", - RedirectStandardOutput = true, - UseShellExecute = false, - CreateNoWindow = true - } - }; - - try - { - process.Start(); - string output = process.StandardOutput.ReadToEnd(); - return !string.IsNullOrWhiteSpace(output); - } - catch - { - return false; - } - } - - /// <summary>Detect whether the code is running on Mac.</summary> - /// <remarks> - /// This code is derived from the Mono project (see System.Windows.Forms/System.Windows.Forms/XplatUI.cs). It detects Mac by calling the - /// <c>uname</c> system command and checking the response, which is always 'Darwin' for MacOS. - /// </remarks> - private static bool IsRunningMac() - { - IntPtr buffer = IntPtr.Zero; - try - { - buffer = Marshal.AllocHGlobal(8192); - if (EnvironmentUtility.uname(buffer) == 0) - { - string os = Marshal.PtrToStringAnsi(buffer); - return os == "Darwin"; - } - return false; - } - catch - { - return false; // default to Linux - } - finally - { - if (buffer != IntPtr.Zero) - Marshal.FreeHGlobal(buffer); - } + return LowLevelEnvironmentUtility.IsMono(platform.ToString()); } } } diff --git a/src/SMAPI.Toolkit/Utilities/PathUtilities.cs b/src/SMAPI.Toolkit/Utilities/PathUtilities.cs index e9d71747..bd5fafbc 100644 --- a/src/SMAPI.Toolkit/Utilities/PathUtilities.cs +++ b/src/SMAPI.Toolkit/Utilities/PathUtilities.cs @@ -10,6 +10,13 @@ namespace StardewModdingAPI.Toolkit.Utilities public static class PathUtilities { /********* + ** Fields + *********/ + /// <summary>The root prefix for a Windows UNC path.</summary> + private const string WindowsUncRoot = @"\\"; + + + /********* ** Accessors *********/ /// <summary>The possible directory separator characters in a file path.</summary> @@ -25,6 +32,7 @@ namespace StardewModdingAPI.Toolkit.Utilities /// <summary>Get the segments from a path (e.g. <c>/usr/bin/example</c> => <c>usr</c>, <c>bin</c>, and <c>example</c>).</summary> /// <param name="path">The path to split.</param> /// <param name="limit">The number of segments to match. Any additional segments will be merged into the last returned part.</param> + [Pure] public static string[] GetSegments(string path, int? limit = null) { return limit.HasValue @@ -37,16 +45,20 @@ namespace StardewModdingAPI.Toolkit.Utilities [Pure] public static string NormalizePathSeparators(string path) { - string[] parts = PathUtilities.GetSegments(path); - string normalized = string.Join(PathUtilities.PreferredPathSeparator, parts); - if (path.StartsWith(PathUtilities.PreferredPathSeparator)) - normalized = PathUtilities.PreferredPathSeparator + normalized; // keep root slash - return normalized; + if (string.IsNullOrWhiteSpace(path)) + return path; + + return string.Join(PathUtilities.PreferredPathSeparator, path.Split(PathUtilities.PossiblePathSeparators)); } - /// <summary>Get a directory or file path relative to a given source path.</summary> + /// <summary>Get a directory or file path relative to a given source path. If no relative path is possible (e.g. the paths are on different drives), an absolute path is returned.</summary> /// <param name="sourceDir">The source folder path.</param> /// <param name="targetPath">The target folder or file path.</param> + /// <remarks> + /// + /// NOTE: this is a heuristic implementation that works in the cases SMAPI needs it for, but it doesn't handle all edge cases (e.g. case-sensitivity on Linux, or traversing between UNC paths on Windows). This should be replaced with the more comprehensive <c>Path.GetRelativePath</c> if the game ever migrates to .NET Core. + /// + /// </remarks> [Pure] public static string GetRelativePath(string sourceDir, string targetPath) { @@ -58,13 +70,27 @@ namespace StardewModdingAPI.Toolkit.Utilities // get relative path string relative = PathUtilities.NormalizePathSeparators(Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString())); + + // normalize if (relative == "") - relative = "./"; + relative = "."; + else + { + // trim trailing slash from URL + if (relative.EndsWith(PathUtilities.PreferredPathSeparator)) + relative = relative.Substring(0, relative.Length - PathUtilities.PreferredPathSeparator.Length); + + // fix root + if (relative.StartsWith("file:") && !targetPath.Contains("file:")) + relative = relative.Substring("file:".Length); + } + return relative; } /// <summary>Get whether a path is relative and doesn't try to climb out of its containing folder (e.g. doesn't contain <c>../</c>).</summary> /// <param name="path">The path to check.</param> + [Pure] public static bool IsSafeRelativePath(string path) { if (string.IsNullOrWhiteSpace(path)) @@ -77,6 +103,7 @@ namespace StardewModdingAPI.Toolkit.Utilities /// <summary>Get whether a string is a valid 'slug', containing only basic characters that are safe in all contexts (e.g. filenames, URLs, etc).</summary> /// <param name="str">The string to check.</param> + [Pure] public static bool IsSlug(string str) { return !Regex.IsMatch(str, "[^a-z0-9_.-]", RegexOptions.IgnoreCase); diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index cd5b6779..1956bf29 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -156,7 +156,7 @@ namespace StardewModdingAPI.Web.Controllers // get unofficial version if (wikiEntry?.Compatibility.UnofficialVersion != null && this.IsNewer(wikiEntry.Compatibility.UnofficialVersion, main?.Version) && this.IsNewer(wikiEntry.Compatibility.UnofficialVersion, optional?.Version)) - unofficial = new ModEntryVersionModel(wikiEntry.Compatibility.UnofficialVersion, $"{this.Url.PlainAction("Index", "Mods")}#{wikiEntry.Anchor}"); + unofficial = new ModEntryVersionModel(wikiEntry.Compatibility.UnofficialVersion, $"{this.Url.PlainAction("Index", "Mods", absoluteUrl: true)}#{wikiEntry.Anchor}"); // get unofficial version for beta if (wikiEntry?.HasBetaInfo == true) @@ -166,7 +166,7 @@ namespace StardewModdingAPI.Web.Controllers if (wikiEntry.BetaCompatibility.UnofficialVersion != null) { unofficialForBeta = (wikiEntry.BetaCompatibility.UnofficialVersion != null && this.IsNewer(wikiEntry.BetaCompatibility.UnofficialVersion, main?.Version) && this.IsNewer(wikiEntry.BetaCompatibility.UnofficialVersion, optional?.Version)) - ? new ModEntryVersionModel(wikiEntry.BetaCompatibility.UnofficialVersion, $"{this.Url.PlainAction("Index", "Mods")}#{wikiEntry.Anchor}") + ? new ModEntryVersionModel(wikiEntry.BetaCompatibility.UnofficialVersion, $"{this.Url.PlainAction("Index", "Mods", absoluteUrl: true)}#{wikiEntry.Anchor}") : null; } else @@ -198,9 +198,9 @@ namespace StardewModdingAPI.Web.Controllers List<ModEntryVersionModel> updates = new List<ModEntryVersionModel>(); if (this.IsRecommendedUpdate(installedVersion, main?.Version, useBetaChannel: true)) updates.Add(main); - if (this.IsRecommendedUpdate(installedVersion, optional?.Version, useBetaChannel: installedVersion.IsPrerelease())) + if (this.IsRecommendedUpdate(installedVersion, optional?.Version, useBetaChannel: installedVersion.IsPrerelease() || search.IsBroken)) updates.Add(optional); - if (this.IsRecommendedUpdate(installedVersion, unofficial?.Version, useBetaChannel: search.IsBroken)) + if (this.IsRecommendedUpdate(installedVersion, unofficial?.Version, useBetaChannel: true)) updates.Add(unofficial); if (this.IsRecommendedUpdate(installedVersion, unofficialForBeta?.Version, useBetaChannel: apiVersion.IsPrerelease())) updates.Add(unofficialForBeta); diff --git a/src/SMAPI.Web/Framework/Extensions.cs b/src/SMAPI.Web/Framework/Extensions.cs index 3a246245..5305b142 100644 --- a/src/SMAPI.Web/Framework/Extensions.cs +++ b/src/SMAPI.Web/Framework/Extensions.cs @@ -19,7 +19,7 @@ namespace StardewModdingAPI.Web.Framework /**** ** View helpers ****/ - /// <summary>Get a URL with the absolute path for an action method. Unlike <see cref="IUrlHelper.Action"/>, only the specified <paramref name="values"/> are added to the URL without merging values from the current HTTP request.</summary> + /// <summary>Get a URL for an action method. Unlike <see cref="IUrlHelper.Action"/>, only the specified <paramref name="values"/> are added to the URL without merging values from the current HTTP request.</summary> /// <param name="helper">The URL helper to extend.</param> /// <param name="action">The name of the action method.</param> /// <param name="controller">The name of the controller.</param> diff --git a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json index 6e8a4e52..3fd24e4e 100644 --- a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json +++ b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json @@ -11,9 +11,9 @@ "title": "Format version", "description": "The format version. You should always use the latest version to enable the latest features and avoid obsolete behavior.", "type": "string", - "const": "1.15.0", + "const": "1.17.0", "@errorMessages": { - "const": "Incorrect value '@value'. This should be set to the latest format version, currently '1.15.0'." + "const": "Incorrect value '@value'. This should be set to the latest format version, currently '1.17.0'." } }, "ConfigSchema": { @@ -102,7 +102,7 @@ "title": "Action", "description": "The kind of change to make.", "type": "string", - "enum": [ "Load", "EditImage", "EditData", "EditMap" ] + "enum": [ "Load", "EditImage", "EditData", "EditMap", "Include" ] }, "Target": { "title": "Target asset", @@ -125,21 +125,27 @@ "description": "Whether to apply this patch. Default true. This fields supports immutable tokens (e.g. config tokens) if they return true/false.", "anyOf": [ { + "type": "boolean" + }, + { "type": "string", "enum": [ "true", "false" ] }, { "type": "string", "pattern": "\\{\\{[^{}]+\\}\\}" - }, - { - "type": "boolean" } ], "@errorMessages": { "anyOf": "Invalid value; must be true, false, or a single token which evaluates to true or false." } }, + "Update": { + "title": "Update", + "description": "When the patch should update if it changed. The possible values are 'OnDayStart' and 'OnLocationChange' (defaults to OnDayStart).", + "type": "string", + "enum": [ "OnDayStart", "OnLocationChange" ] + }, "FromFile": { "title": "Source file", "description": "The relative file path in your content pack folder to load instead (like 'assets/dinosaur.png'). This can be a .json (data), .png (image), .tbin or .tmx (map), or .xnb file. This field supports tokens and capitalization doesn't matter.", @@ -327,9 +333,17 @@ } }, "then": { - "required": [ "FromFile" ], + "required": [ "FromFile", "Target" ], "propertyNames": { - "enum": [ "Action", "Target", "LogName", "Enabled", "When", "FromFile" ] + "enum": [ + "Action", + "Enabled", + "FromFile", + "LogName", + "Target", + "Update", + "When" + ] } } }, @@ -340,9 +354,21 @@ } }, "then": { - "required": [ "FromFile" ], + "required": [ "FromFile", "Target" ], "propertyNames": { - "enum": [ "Action", "Target", "LogName", "Enabled", "When", "FromFile", "FromArea", "ToArea", "PatchMode" ] + "enum": [ + "Action", + "Enabled", + "FromFile", + "LogName", + "Target", + "Update", + "When", + + "FromArea", + "PatchMode", + "ToArea" + ] } } }, @@ -354,7 +380,19 @@ }, "then": { "propertyNames": { - "enum": [ "Action", "Target", "LogName", "Enabled", "When", "FromFile", "Fields", "Entries", "MoveEntries" ] + "enum": [ + "Action", + "Enabled", + "FromFile", + "LogName", + "Target", + "Update", + "When", + + "Entries", + "Fields", + "MoveEntries" + ] } } }, @@ -377,16 +415,44 @@ } }, "propertyNames": { - "enum": [ "Action", "Target", "LogName", "Enabled", "When", "FromFile", "FromArea", "ToArea", "MapProperties", "MapTiles" ] + "enum": [ + "Action", + "Enabled", + "FromFile", + "LogName", + "Target", + "Update", + "When", + + "FromArea", + "MapProperties", + "MapTiles", + "ToArea" + ] + } + } + }, + { + "if": { + "properties": { + "Action": { "const": "Include" } + } + }, + "then": { + "required": [ "FromFile" ], + "propertyNames": { + "enum": [ + "Action", + "Enabled", + "FromFile", + "LogName", + "Update", + "When" + ] } } } - ], - - "required": [ "Action", "Target" ], - "@errorMessages": { - "allOf": "$transparent" - } + ] } }, "$schema": { diff --git a/src/SMAPI.sln.DotSettings b/src/SMAPI.sln.DotSettings index 05caa938..76e863cc 100644 --- a/src/SMAPI.sln.DotSettings +++ b/src/SMAPI.sln.DotSettings @@ -3,6 +3,7 @@ <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=MemberCanBeMadeStatic_002ELocal/@EntryIndexedValue">DO_NOT_SHOW</s:String> <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=RedundantNameQualifier/@EntryIndexedValue">HINT</s:String> <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=RedundantTypeArgumentsOfMethod/@EntryIndexedValue">HINT</s:String> + <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=RemoveRedundantBraces/@EntryIndexedValue">DO_NOT_SHOW</s:String> <s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpCodeStyle/StaticQualifier/STATIC_MEMBERS_QUALIFY_MEMBERS/@EntryValue">Field, Property, Event, Method</s:String> <s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpCodeStyle/ThisQualifier/INSTANCE_MEMBERS_QUALIFY_MEMBERS/@EntryValue">Field, Property, Event, Method</s:String> <s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/LINE_FEED_AT_FILE_END/@EntryValue">True</s:Boolean> @@ -53,6 +54,7 @@ <s:Boolean x:Key="/Default/UserDictionary/Words/=rewriter/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=rewriters/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=SMAPI/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=SMAPI_0027s/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=spawnable/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=spritesheet/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=stackable/@EntryIndexedValue">True</s:Boolean> diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index c1c99150..858e832f 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -5,11 +5,42 @@ using System.Reflection; using StardewModdingAPI.Enums; using StardewModdingAPI.Framework; using StardewModdingAPI.Framework.ModLoading; +using StardewModdingAPI.Toolkit.Framework; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; namespace StardewModdingAPI { + /// <summary>Contains constants that are accessed before the game itself has been loaded.</summary> + /// <remarks>Most code should use <see cref="Constants"/> instead of this class directly.</remarks> + internal static class EarlyConstants + { + // + // Note: this class *must not* depend on any external DLL beyond .NET Framework itself. + // That includes the game or SMAPI toolkit, since it's accessed before those are loaded. + // + // Adding an external dependency may seem to work in some cases, but will prevent SMAPI + // from showing a human-readable error if the game isn't available. To test this, just + // rename "Stardew Valley.exe" in the game folder; you should see an error like "Oops! + // SMAPI can't find the game", not a technical exception. + // + + /********* + ** Accessors + *********/ + /// <summary>The path to the game folder.</summary> + public static string ExecutionPath { get; } = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + + /// <summary>The absolute path to the folder containing SMAPI's internal files.</summary> + public static readonly string InternalFilesPath = Path.Combine(EarlyConstants.ExecutionPath, "smapi-internal"); + + /// <summary>The target game platform.</summary> + internal static GamePlatform Platform { get; } = (GamePlatform)Enum.Parse(typeof(GamePlatform), LowLevelEnvironmentUtility.DetectPlatform()); + + /// <summary>The game's assembly name.</summary> + internal static string GameAssemblyName => EarlyConstants.Platform == GamePlatform.Windows ? "Stardew Valley" : "StardewValley"; + } + /// <summary>Contains SMAPI's constants and assumptions.</summary> public static class Constants { @@ -20,19 +51,19 @@ namespace StardewModdingAPI ** Public ****/ /// <summary>SMAPI's current semantic version.</summary> - public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.6.2"); + public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.7.0"); /// <summary>The minimum supported version of Stardew Valley.</summary> 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; + public static ISemanticVersion MaximumGameVersion { get; } = new GameVersion("1.4.5"); /// <summary>The target game platform.</summary> - public static GamePlatform TargetPlatform => (GamePlatform)Constants.Platform; + public static GamePlatform TargetPlatform { get; } = EarlyConstants.Platform; /// <summary>The path to the game folder.</summary> - public static string ExecutionPath { get; } = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + public static string ExecutionPath { get; } = EarlyConstants.ExecutionPath; /// <summary>The directory path containing Stardew Valley's app data.</summary> public static string DataPath { get; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley"); @@ -67,7 +98,7 @@ namespace StardewModdingAPI 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; + internal static readonly string InternalFilesPath = EarlyConstants.InternalFilesPath; /// <summary>The file path for the SMAPI configuration file.</summary> internal static string ApiConfigPath => Path.Combine(Constants.InternalFilesPath, "config.json"); @@ -105,11 +136,8 @@ namespace StardewModdingAPI /// <summary>The game's current semantic version.</summary> internal static ISemanticVersion GameVersion { get; } = new GameVersion(Game1.version); - /// <summary>The target game platform.</summary> - internal static Platform Platform { get; } = EnvironmentUtility.DetectPlatform(); - - /// <summary>The game's assembly name.</summary> - internal static string GameAssemblyName => Constants.Platform == Platform.Windows ? "Stardew Valley" : "StardewValley"; + /// <summary>The target game platform as a SMAPI toolkit constant.</summary> + internal static Platform Platform { get; } = (Platform)Constants.TargetPlatform; /// <summary>The language code for non-translated mod assets.</summary> internal static LocalizedContentManager.LanguageCode DefaultLanguage { get; } = LocalizedContentManager.LanguageCode.en; diff --git a/src/SMAPI/Events/ChangeType.cs b/src/SMAPI/Events/ChangeType.cs index 4b207f08..0fc717df 100644 --- a/src/SMAPI/Events/ChangeType.cs +++ b/src/SMAPI/Events/ChangeType.cs @@ -12,4 +12,4 @@ namespace StardewModdingAPI.Events /// <summary>The stack size changed.</summary> StackChange } -}
\ No newline at end of file +} diff --git a/src/SMAPI/Events/OneSecondUpdateTickedEventArgs.cs b/src/SMAPI/Events/OneSecondUpdateTickedEventArgs.cs index 48e08e5e..608bdc69 100644 --- a/src/SMAPI/Events/OneSecondUpdateTickedEventArgs.cs +++ b/src/SMAPI/Events/OneSecondUpdateTickedEventArgs.cs @@ -10,7 +10,7 @@ namespace StardewModdingAPI.Events ** Accessors *********/ /// <summary>The number of ticks elapsed since the game started, including the current tick.</summary> - public uint Ticks => SGame.TicksElapsed; + public uint Ticks => SCore.TicksElapsed; /********* diff --git a/src/SMAPI/Events/OneSecondUpdateTickingEventArgs.cs b/src/SMAPI/Events/OneSecondUpdateTickingEventArgs.cs index 58cf802a..f30a2245 100644 --- a/src/SMAPI/Events/OneSecondUpdateTickingEventArgs.cs +++ b/src/SMAPI/Events/OneSecondUpdateTickingEventArgs.cs @@ -10,7 +10,7 @@ namespace StardewModdingAPI.Events ** Accessors *********/ /// <summary>The number of ticks elapsed since the game started, excluding the upcoming tick.</summary> - public uint Ticks => SGame.TicksElapsed; + public uint Ticks => SCore.TicksElapsed; /********* diff --git a/src/SMAPI/Events/UnvalidatedUpdateTickedEventArgs.cs b/src/SMAPI/Events/UnvalidatedUpdateTickedEventArgs.cs index 258e2f99..ff635787 100644 --- a/src/SMAPI/Events/UnvalidatedUpdateTickedEventArgs.cs +++ b/src/SMAPI/Events/UnvalidatedUpdateTickedEventArgs.cs @@ -10,7 +10,7 @@ namespace StardewModdingAPI.Events ** Accessors *********/ /// <summary>The number of ticks elapsed since the game started, including the current tick.</summary> - public uint Ticks => SGame.TicksElapsed; + public uint Ticks => SCore.TicksElapsed; /// <summary>Whether <see cref="Ticks"/> is a multiple of 60, which happens approximately once per second.</summary> public bool IsOneSecond => this.Ticks % 60 == 0; diff --git a/src/SMAPI/Events/UnvalidatedUpdateTickingEventArgs.cs b/src/SMAPI/Events/UnvalidatedUpdateTickingEventArgs.cs index e3c8b3ee..4a01c98b 100644 --- a/src/SMAPI/Events/UnvalidatedUpdateTickingEventArgs.cs +++ b/src/SMAPI/Events/UnvalidatedUpdateTickingEventArgs.cs @@ -10,7 +10,7 @@ namespace StardewModdingAPI.Events ** Accessors *********/ /// <summary>The number of ticks elapsed since the game started, excluding the upcoming tick.</summary> - public uint Ticks => SGame.TicksElapsed; + public uint Ticks => SCore.TicksElapsed; /// <summary>Whether <see cref="Ticks"/> is a multiple of 60, which happens approximately once per second.</summary> public bool IsOneSecond => this.Ticks % 60 == 0; diff --git a/src/SMAPI/Events/UpdateTickedEventArgs.cs b/src/SMAPI/Events/UpdateTickedEventArgs.cs index 4f3329ac..ce01ba1e 100644 --- a/src/SMAPI/Events/UpdateTickedEventArgs.cs +++ b/src/SMAPI/Events/UpdateTickedEventArgs.cs @@ -10,7 +10,7 @@ namespace StardewModdingAPI.Events ** Accessors *********/ /// <summary>The number of ticks elapsed since the game started, including the current tick.</summary> - public uint Ticks => SGame.TicksElapsed; + public uint Ticks => SCore.TicksElapsed; /// <summary>Whether <see cref="Ticks"/> is a multiple of 60, which happens approximately once per second.</summary> public bool IsOneSecond => this.Ticks % 60 == 0; diff --git a/src/SMAPI/Events/UpdateTickingEventArgs.cs b/src/SMAPI/Events/UpdateTickingEventArgs.cs index 0d3187cd..483142ec 100644 --- a/src/SMAPI/Events/UpdateTickingEventArgs.cs +++ b/src/SMAPI/Events/UpdateTickingEventArgs.cs @@ -10,7 +10,7 @@ namespace StardewModdingAPI.Events ** Accessors *********/ /// <summary>The number of ticks elapsed since the game started, excluding the upcoming tick.</summary> - public uint Ticks => SGame.TicksElapsed; + public uint Ticks => SCore.TicksElapsed; /// <summary>Whether <see cref="Ticks"/> is a multiple of 60, which happens approximately once per second.</summary> public bool IsOneSecond => this.Ticks % 60 == 0; diff --git a/src/SMAPI/Events/WarpedEventArgs.cs b/src/SMAPI/Events/WarpedEventArgs.cs index 95c53ad9..9afe4a4e 100644 --- a/src/SMAPI/Events/WarpedEventArgs.cs +++ b/src/SMAPI/Events/WarpedEventArgs.cs @@ -22,7 +22,6 @@ namespace StardewModdingAPI.Events public bool IsLocalPlayer => this.Player.IsLocalPlayer; - /********* ** Public methods *********/ diff --git a/src/SMAPI/Framework/CommandManager.cs b/src/SMAPI/Framework/CommandManager.cs index 2b91d394..4a99fd4d 100644 --- a/src/SMAPI/Framework/CommandManager.cs +++ b/src/SMAPI/Framework/CommandManager.cs @@ -119,6 +119,7 @@ namespace StardewModdingAPI.Framework command.Callback.Invoke(name, arguments); return true; } + return false; } diff --git a/src/SMAPI/Framework/Content/AssetData.cs b/src/SMAPI/Framework/Content/AssetData.cs index cacc6078..5c90d83b 100644 --- a/src/SMAPI/Framework/Content/AssetData.cs +++ b/src/SMAPI/Framework/Content/AssetData.cs @@ -16,7 +16,7 @@ namespace StardewModdingAPI.Framework.Content /********* ** Accessors *********/ - /// <summary>The content data being read.</summary> + /// <inheritdoc /> public TValue Data { get; protected set; } @@ -36,10 +36,7 @@ namespace StardewModdingAPI.Framework.Content this.OnDataReplaced = onDataReplaced; } - /// <summary>Replace the entire content value with the given value. This is generally not recommended, since it may break compatibility with other mods or different versions of the game.</summary> - /// <param name="value">The new content value.</param> - /// <exception cref="ArgumentNullException">The <paramref name="value"/> is null.</exception> - /// <exception cref="InvalidCastException">The <paramref name="value"/>'s type is not compatible with the loaded asset's type.</exception> + /// <inheritdoc /> public void ReplaceWith(TValue value) { if (value == null) diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs index 44a97136..5f91610e 100644 --- a/src/SMAPI/Framework/Content/AssetDataForImage.cs +++ b/src/SMAPI/Framework/Content/AssetDataForImage.cs @@ -28,13 +28,7 @@ namespace StardewModdingAPI.Framework.Content public AssetDataForImage(string locale, string assetName, Texture2D data, Func<string, string> getNormalizedPath, Action<Texture2D> onDataReplaced) : base(locale, assetName, data, getNormalizedPath, onDataReplaced) { } - /// <summary>Overwrite part of the image.</summary> - /// <param name="source">The image to patch into the content.</param> - /// <param name="sourceArea">The part of the <paramref name="source"/> to copy (or <c>null</c> to take the whole texture). This must be within the bounds of the <paramref name="source"/> texture.</param> - /// <param name="targetArea">The part of the content to patch (or <c>null</c> to patch the whole texture). The original content within this area will be erased. This must be within the bounds of the existing spritesheet.</param> - /// <param name="patchMode">Indicates how an image should be patched.</param> - /// <exception cref="ArgumentNullException">One of the arguments is null.</exception> - /// <exception cref="ArgumentOutOfRangeException">The <paramref name="targetArea"/> is outside the bounds of the spritesheet.</exception> + /// <inheritdoc /> public void PatchImage(Texture2D source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace) { // get texture @@ -104,10 +98,7 @@ namespace StardewModdingAPI.Framework.Content target.SetData(0, targetArea, sourceData, 0, pixelCount); } - /// <summary>Extend the image if needed to fit the given size. Note that this is an expensive operation, creates a new texture instance, and that extending a spritesheet horizontally may cause game errors or bugs.</summary> - /// <param name="minWidth">The minimum texture width.</param> - /// <param name="minHeight">The minimum texture height.</param> - /// <returns>Whether the texture was resized.</returns> + /// <inheritdoc /> public bool ExtendImage(int minWidth, int minHeight) { if (this.Data.Width >= minWidth && this.Data.Height >= minHeight) diff --git a/src/SMAPI/Framework/Content/AssetDataForMap.cs b/src/SMAPI/Framework/Content/AssetDataForMap.cs index dee5b034..e80c6e53 100644 --- a/src/SMAPI/Framework/Content/AssetDataForMap.cs +++ b/src/SMAPI/Framework/Content/AssetDataForMap.cs @@ -24,10 +24,7 @@ namespace StardewModdingAPI.Framework.Content public AssetDataForMap(string locale, string assetName, Map data, Func<string, string> getNormalizedPath, Action<Map> onDataReplaced) : base(locale, assetName, data, getNormalizedPath, onDataReplaced) { } - /// <summary>Copy layers, tiles, and tilesheets from another map onto the asset.</summary> - /// <param name="source">The map from which to copy.</param> - /// <param name="sourceArea">The tile area within the source map to copy, or <c>null</c> for the entire source map size. This must be within the bounds of the <paramref name="source"/> map.</param> - /// <param name="targetArea">The tile area within the target map to overwrite, or <c>null</c> to patch the whole map. The original content within this area will be erased. This must be within the bounds of the existing map.</param> + /// <inheritdoc /> /// <remarks>Derived from <see cref="StardewValley.GameLocation.ApplyMapOverride"/> with a few changes: /// - can be applied directly to the maps when loading, before the location is created; /// - added support for source/target areas; diff --git a/src/SMAPI/Framework/Content/AssetDataForObject.cs b/src/SMAPI/Framework/Content/AssetDataForObject.cs index f00ba124..b7e8dfeb 100644 --- a/src/SMAPI/Framework/Content/AssetDataForObject.cs +++ b/src/SMAPI/Framework/Content/AssetDataForObject.cs @@ -26,32 +26,25 @@ namespace StardewModdingAPI.Framework.Content public AssetDataForObject(IAssetInfo info, object data, Func<string, string> getNormalizedPath) : this(info.Locale, info.AssetName, data, getNormalizedPath) { } - /// <summary>Get a helper to manipulate the data as a dictionary.</summary> - /// <typeparam name="TKey">The expected dictionary key.</typeparam> - /// <typeparam name="TValue">The expected dictionary balue.</typeparam> - /// <exception cref="InvalidOperationException">The content being read isn't a dictionary.</exception> + /// <inheritdoc /> public IAssetDataForDictionary<TKey, TValue> AsDictionary<TKey, TValue>() { return new AssetDataForDictionary<TKey, TValue>(this.Locale, this.AssetName, this.GetData<IDictionary<TKey, TValue>>(), this.GetNormalizedPath, this.ReplaceWith); } - /// <summary>Get a helper to manipulate the data as an image.</summary> - /// <exception cref="InvalidOperationException">The content being read isn't an image.</exception> + /// <inheritdoc /> public IAssetDataForImage AsImage() { return new AssetDataForImage(this.Locale, this.AssetName, this.GetData<Texture2D>(), this.GetNormalizedPath, this.ReplaceWith); } - /// <summary>Get a helper to manipulate the data as a map.</summary> - /// <exception cref="InvalidOperationException">The content being read isn't a map.</exception> + /// <inheritdoc /> public IAssetDataForMap AsMap() { return new AssetDataForMap(this.Locale, this.AssetName, this.GetData<Map>(), this.GetNormalizedPath, this.ReplaceWith); } - /// <summary>Get the data as a given type.</summary> - /// <typeparam name="TData">The expected data type.</typeparam> - /// <exception cref="InvalidCastException">The data can't be converted to <typeparamref name="TData"/>.</exception> + /// <inheritdoc /> public TData GetData<TData>() { if (!(this.Data is TData)) diff --git a/src/SMAPI/Framework/Content/AssetInfo.cs b/src/SMAPI/Framework/Content/AssetInfo.cs index ed009499..d8106439 100644 --- a/src/SMAPI/Framework/Content/AssetInfo.cs +++ b/src/SMAPI/Framework/Content/AssetInfo.cs @@ -16,13 +16,13 @@ namespace StardewModdingAPI.Framework.Content /********* ** Accessors *********/ - /// <summary>The content's locale code, if the content is localized.</summary> + /// <inheritdoc /> public string Locale { get; } - /// <summary>The normalized asset name being read. The format may change between platforms; see <see cref="AssetNameEquals"/> to compare with a known path.</summary> + /// <inheritdoc /> public string AssetName { get; } - /// <summary>The content data type.</summary> + /// <inheritdoc /> public Type DataType { get; } @@ -42,8 +42,7 @@ namespace StardewModdingAPI.Framework.Content this.GetNormalizedPath = getNormalizedPath; } - /// <summary>Get whether the asset name being loaded matches a given name after normalization.</summary> - /// <param name="path">The expected asset path, relative to the game's content folder and without the .xnb extension or locale suffix (like 'Data\ObjectInformation').</param> + /// <inheritdoc /> public bool AssetNameEquals(string path) { path = this.GetNormalizedPath(path); diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs index 479ffa7f..93371415 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -9,7 +9,6 @@ using Microsoft.Xna.Framework.Content; using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Reflection; -using StardewModdingAPI.Framework.StateTracking.Comparers; using StardewModdingAPI.Metadata; using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; @@ -96,7 +95,7 @@ namespace StardewModdingAPI.Framework this.ContentManagers.Add( this.MainContentManager = new GameContentManager("Game1.content", serviceProvider, rootDirectory, currentCulture, this, monitor, reflection, this.OnDisposing, onLoadingFirstAsset) ); - this.CoreAssets = new CoreAssetPropagator(this.MainContentManager.AssertAndNormalizeAssetName, reflection, monitor); + this.CoreAssets = new CoreAssetPropagator(this.MainContentManager.AssertAndNormalizeAssetName, reflection); } /// <summary>Get a new content manager which handles reading files from the game content folder with support for interception.</summary> @@ -237,7 +236,7 @@ namespace StardewModdingAPI.Framework { foreach (var entry in contentManager.InvalidateCache(predicate, dispose)) { - if (!removedAssets.TryGetValue(entry.Key, out Type type)) + if (!removedAssets.ContainsKey(entry.Key)) removedAssets[entry.Key] = entry.Value.GetType(); } } diff --git a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs index a8de013a..6bc3a505 100644 --- a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs @@ -76,7 +76,7 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param> /// <param name="isNamespaced">Whether this content manager handles managed asset keys (e.g. to load assets from a mod folder).</param> protected BaseContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing, bool isNamespaced) - : base(serviceProvider, rootDirectory, currentCulture) + : base(serviceProvider, rootDirectory, currentCulture) { // init this.Name = name; diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index 26ddb067..12d672cf 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -250,7 +250,7 @@ namespace StardewModdingAPI.Framework.ContentManagers if (pixel.A == byte.MinValue || pixel.A == byte.MaxValue) continue; // no need to change fully transparent/opaque pixels - data[i] = new Color(pixel.R * pixel.A / byte.MaxValue, pixel.G * pixel.A / byte.MaxValue, pixel.B * pixel.A / byte.MaxValue, pixel.A); // slower version: Color.FromNonPremultiplied(data[i].ToVector4()) + data[i] = new Color(pixel.R * pixel.A / byte.MaxValue, pixel.G * pixel.A / byte.MaxValue, pixel.B * pixel.A / byte.MaxValue, pixel.A); // slower version: Color.FromNonPremultiplied(data[i].ToVector4()) } texture.SetData(data); @@ -275,12 +275,12 @@ namespace StardewModdingAPI.Framework.ContentManagers /// * If the location is indoors or the desert, or the image source contains 'path' or 'object', it's loaded /// as-is relative to the <c>Content</c> folder. /// * Else it's loaded from <c>Content\Maps</c> with a seasonal prefix. - /// + /// /// That logic doesn't work well in our case, mainly because we have no location metadata at this point. /// Instead we use a more heuristic approach: check relative to the map file first, then relative to /// <c>Content\Maps</c>, then <c>Content</c>. If the image source filename contains a seasonal prefix, try for a /// seasonal variation and then an exact match. - /// + /// /// While that doesn't exactly match the game logic, it's close enough that it's unlikely to make a difference. /// </remarks> private void FixCustomTilesheetPaths(Map map, string relativeMapPath) diff --git a/src/SMAPI/Framework/ContentPack.cs b/src/SMAPI/Framework/ContentPack.cs index 9c0bb9d1..55c1a0b2 100644 --- a/src/SMAPI/Framework/ContentPack.cs +++ b/src/SMAPI/Framework/ContentPack.cs @@ -1,10 +1,7 @@ using System; using System.IO; -using Microsoft.Xna.Framework.Content; -using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; -using xTile; namespace StardewModdingAPI.Framework { @@ -24,13 +21,13 @@ namespace StardewModdingAPI.Framework /********* ** Accessors *********/ - /// <summary>The full path to the content pack's folder.</summary> + /// <inheritdoc /> public string DirectoryPath { get; } - /// <summary>The content pack's manifest.</summary> + /// <inheritdoc /> public IManifest Manifest { get; } - /// <summary>Provides translations stored in the content pack's <c>i18n</c> folder. See <see cref="IModHelper.Translation"/> for more info.</summary> + /// <inheritdoc /> public ITranslationHelper Translation { get; } @@ -52,8 +49,7 @@ namespace StardewModdingAPI.Framework this.JsonHelper = jsonHelper; } - /// <summary>Get whether a given file exists in the content pack.</summary> - /// <param name="path">The file path to check.</param> + /// <inheritdoc /> public bool HasFile(string path) { this.AssertRelativePath(path, nameof(this.HasFile)); @@ -61,11 +57,7 @@ namespace StardewModdingAPI.Framework return File.Exists(Path.Combine(this.DirectoryPath, path)); } - /// <summary>Read a JSON file from the content pack folder.</summary> - /// <typeparam name="TModel">The model type.</typeparam> - /// <param name="path">The file path relative to the content directory.</param> - /// <returns>Returns the deserialized model, or <c>null</c> if the file doesn't exist or is empty.</returns> - /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> + /// <inheritdoc /> public TModel ReadJsonFile<TModel>(string path) where TModel : class { this.AssertRelativePath(path, nameof(this.ReadJsonFile)); @@ -76,11 +68,7 @@ namespace StardewModdingAPI.Framework : null; } - /// <summary>Save data to a JSON file in the content pack's folder.</summary> - /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> - /// <param name="path">The file path relative to the mod folder.</param> - /// <param name="data">The arbitrary data to save.</param> - /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> + /// <inheritdoc /> public void WriteJsonFile<TModel>(string path, TModel data) where TModel : class { this.AssertRelativePath(path, nameof(this.WriteJsonFile)); @@ -89,19 +77,13 @@ namespace StardewModdingAPI.Framework this.JsonHelper.WriteJsonFile(path, data); } - /// <summary>Load content from the content pack folder (if not already cached), and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary> - /// <typeparam name="T">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, and dictionaries; other types may be supported by the game's content pipeline.</typeparam> - /// <param name="key">The local path to a content file relative to the content pack folder.</param> - /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> - /// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception> + /// <inheritdoc /> public T LoadAsset<T>(string key) { return this.Content.Load<T>(key, ContentSource.ModFolder); } - /// <summary>Get the underlying key in the game's content cache for an asset. This can be used to load custom map tilesheets, but should be avoided when you can use the content API instead. This does not validate whether the asset exists.</summary> - /// <param name="key">The the local path to a content file relative to the content pack folder.</param> - /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> + /// <inheritdoc /> public string GetActualAssetKey(string key) { return this.Content.GetActualAssetKey(key, ContentSource.ModFolder); diff --git a/src/SMAPI/Framework/CursorPosition.cs b/src/SMAPI/Framework/CursorPosition.cs index 2008ccce..80d89994 100644 --- a/src/SMAPI/Framework/CursorPosition.cs +++ b/src/SMAPI/Framework/CursorPosition.cs @@ -8,16 +8,16 @@ namespace StardewModdingAPI.Framework /********* ** Accessors *********/ - /// <summary>The pixel position relative to the top-left corner of the in-game map, adjusted for pixel zoom.</summary> + /// <inheritdoc /> public Vector2 AbsolutePixels { get; } - /// <summary>The pixel position relative to the top-left corner of the visible screen, adjusted for pixel zoom.</summary> + /// <inheritdoc /> public Vector2 ScreenPixels { get; } - /// <summary>The tile position under the cursor relative to the top-left corner of the map.</summary> + /// <inheritdoc /> public Vector2 Tile { get; } - /// <summary>The tile position that the game considers under the cursor for purposes of clicking actions. This may be different than <see cref="Tile"/> if that's too far from the player.</summary> + /// <inheritdoc /> public Vector2 GrabTile { get; } @@ -37,8 +37,7 @@ namespace StardewModdingAPI.Framework this.GrabTile = grabTile; } - /// <summary>Get whether the current object is equal to another object of the same type.</summary> - /// <param name="other">An object to compare with this object.</param> + /// <inheritdoc /> public bool Equals(ICursorPosition other) { return other != null && this.AbsolutePixels == other.AbsolutePixels; diff --git a/src/SMAPI/Framework/DeprecationLevel.cs b/src/SMAPI/Framework/DeprecationLevel.cs index c0044053..12b50952 100644 --- a/src/SMAPI/Framework/DeprecationLevel.cs +++ b/src/SMAPI/Framework/DeprecationLevel.cs @@ -12,4 +12,4 @@ namespace StardewModdingAPI.Framework /// <summary>The code will be removed soon. Deprecation messages should be warnings in the console.</summary> PendingRemoval } -}
\ No newline at end of file +} diff --git a/src/SMAPI/Framework/DeprecationManager.cs b/src/SMAPI/Framework/DeprecationManager.cs index 11fae0b2..94a2da85 100644 --- a/src/SMAPI/Framework/DeprecationManager.cs +++ b/src/SMAPI/Framework/DeprecationManager.cs @@ -107,6 +107,7 @@ namespace StardewModdingAPI.Framework } } } + this.QueuedWarnings.Clear(); } diff --git a/src/SMAPI/Framework/Events/EventManager.cs b/src/SMAPI/Framework/Events/EventManager.cs index b5a12a6e..9092669f 100644 --- a/src/SMAPI/Framework/Events/EventManager.cs +++ b/src/SMAPI/Framework/Events/EventManager.cs @@ -11,7 +11,7 @@ namespace StardewModdingAPI.Framework.Events internal class EventManager { /********* - ** Events (new) + ** Events *********/ /**** ** Display diff --git a/src/SMAPI/Framework/Events/ManagedEvent.cs b/src/SMAPI/Framework/Events/ManagedEvent.cs index 8b25a9b5..f2dfb2ab 100644 --- a/src/SMAPI/Framework/Events/ManagedEvent.cs +++ b/src/SMAPI/Framework/Events/ManagedEvent.cs @@ -70,27 +70,33 @@ namespace StardewModdingAPI.Framework.Events /// <param name="mod">The mod which added the event handler.</param> public void Add(EventHandler<TEventArgs> handler, IModMetadata mod) { - EventPriority priority = handler.Method.GetCustomAttribute<EventPriorityAttribute>()?.Priority ?? EventPriority.Normal; - var managedHandler = new ManagedEventHandler<TEventArgs>(handler, this.RegistrationIndex++, priority, mod); + lock (this.Handlers) + { + EventPriority priority = handler.Method.GetCustomAttribute<EventPriorityAttribute>()?.Priority ?? EventPriority.Normal; + var managedHandler = new ManagedEventHandler<TEventArgs>(handler, this.RegistrationIndex++, priority, mod); - this.Handlers.Add(managedHandler); - this.CachedHandlers = null; - this.HasNewHandlers = true; + this.Handlers.Add(managedHandler); + this.CachedHandlers = null; + this.HasNewHandlers = true; + } } /// <summary>Remove an event handler.</summary> /// <param name="handler">The event handler.</param> public void Remove(EventHandler<TEventArgs> handler) { - // match C# events: if a handler is listed multiple times, remove the last one added - for (int i = this.Handlers.Count - 1; i >= 0; i--) + lock (this.Handlers) { - if (this.Handlers[i].Handler != handler) - continue; + // match C# events: if a handler is listed multiple times, remove the last one added + for (int i = this.Handlers.Count - 1; i >= 0; i--) + { + if (this.Handlers[i].Handler != handler) + continue; - this.Handlers.RemoveAt(i); - this.CachedHandlers = null; - break; + this.Handlers.RemoveAt(i); + this.CachedHandlers = null; + break; + } } } @@ -106,14 +112,17 @@ namespace StardewModdingAPI.Framework.Events // update cached data // (This is debounced here to avoid repeatedly sorting when handlers are added/removed, // and keeping a separate cached list allows changes during enumeration.) - var handlers = this.CachedHandlers; // iterate local copy in case a mod adds/removes a handler while handling the event + var handlers = this.CachedHandlers; // iterate local copy in case a mod adds/removes a handler while handling the event, which will set this field to null if (handlers == null) { - if (this.HasNewHandlers && this.Handlers.Any(p => p.Priority != EventPriority.Normal)) - this.Handlers.Sort(); + lock (this.Handlers) + { + if (this.HasNewHandlers && this.Handlers.Any(p => p.Priority != EventPriority.Normal)) + this.Handlers.Sort(); - this.CachedHandlers = handlers = this.Handlers.ToArray(); - this.HasNewHandlers = false; + this.CachedHandlers = handlers = this.Handlers.ToArray(); + this.HasNewHandlers = false; + } } // raise event diff --git a/src/SMAPI/Framework/Events/ModGameLoopEvents.cs b/src/SMAPI/Framework/Events/ModGameLoopEvents.cs index a0119bf8..1150d641 100644 --- a/src/SMAPI/Framework/Events/ModGameLoopEvents.cs +++ b/src/SMAPI/Framework/Events/ModGameLoopEvents.cs @@ -96,7 +96,6 @@ namespace StardewModdingAPI.Framework.Events /// <summary>Raised after the in-game clock time changes.</summary> public event EventHandler<TimeChangedEventArgs> TimeChanged { - add => this.EventManager.TimeChanged.Add(value, this.Mod); remove => this.EventManager.TimeChanged.Remove(value); } diff --git a/src/SMAPI/Framework/GameVersion.cs b/src/SMAPI/Framework/GameVersion.cs index 3ed60920..b69c6757 100644 --- a/src/SMAPI/Framework/GameVersion.cs +++ b/src/SMAPI/Framework/GameVersion.cs @@ -38,7 +38,7 @@ namespace StardewModdingAPI.Framework public GameVersion(string version) : base(GameVersion.GetSemanticVersionString(version), allowNonStandard: true) { } - /// <summary>Get a string representation of the version.</summary> + /// <inheritdoc /> public override string ToString() { return GameVersion.GetGameVersionString(base.ToString()); diff --git a/src/SMAPI/Framework/IModMetadata.cs b/src/SMAPI/Framework/IModMetadata.cs index 1231b494..70cf0036 100644 --- a/src/SMAPI/Framework/IModMetadata.cs +++ b/src/SMAPI/Framework/IModMetadata.cs @@ -31,12 +31,18 @@ namespace StardewModdingAPI.Framework /// <summary>The metadata resolution status.</summary> ModMetadataStatus Status { get; } + /// <summary>The reason the mod failed to load, if applicable.</summary> + ModFailReason? FailReason { get; } + /// <summary>Indicates non-error issues with the mod.</summary> ModWarning Warnings { get; } /// <summary>The reason the metadata is invalid, if any.</summary> string Error { get; } + /// <summary>A detailed technical message for <see cref="Error"/>, if any.</summary> + public string ErrorDetails { get; } + /// <summary>Whether the mod folder should be ignored. This is <c>true</c> if it was found within a folder whose name starts with a dot.</summary> bool IsIgnored { get; } @@ -62,11 +68,17 @@ namespace StardewModdingAPI.Framework /********* ** Public methods *********/ + /// <summary>Set the mod status to <see cref="ModMetadataStatus.Found"/>.</summary> + /// <returns>Return the instance for chaining.</returns> + IModMetadata SetStatusFound(); + /// <summary>Set the mod status.</summary> /// <param name="status">The metadata resolution status.</param> + /// <param name="reason">The reason a mod could not be loaded.</param> /// <param name="error">The reason the metadata is invalid, if any.</param> + /// <param name="errorDetails">A detailed technical message, if any.</param> /// <returns>Return the instance for chaining.</returns> - IModMetadata SetStatus(ModMetadataStatus status, string error = null); + IModMetadata SetStatus(ModMetadataStatus status, ModFailReason reason, string error, string errorDetails = null); /// <summary>Set a warning flag for the mod.</summary> /// <param name="warning">The warning to set.</param> diff --git a/src/SMAPI/Framework/Input/GamePadStateBuilder.cs b/src/SMAPI/Framework/Input/GamePadStateBuilder.cs index 2657fd12..f5f2d916 100644 --- a/src/SMAPI/Framework/Input/GamePadStateBuilder.cs +++ b/src/SMAPI/Framework/Input/GamePadStateBuilder.cs @@ -20,7 +20,7 @@ namespace StardewModdingAPI.Framework.Input private GamePadState? State; /// <summary>The current button states.</summary> - private IDictionary<SButton, ButtonState> ButtonStates; + private readonly IDictionary<SButton, ButtonState> ButtonStates; /// <summary>The left trigger value.</summary> private float LeftTrigger; @@ -39,33 +39,26 @@ namespace StardewModdingAPI.Framework.Input ** Accessors *********/ /// <summary>Whether the gamepad is currently connected.</summary> - public bool IsConnected { get; private set; } + public bool IsConnected { get; } /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="state">The initial state, or <c>null</c> to get the latest state.</param> - public GamePadStateBuilder(GamePadState? state = null) + /// <param name="state">The initial state.</param> + public GamePadStateBuilder(GamePadState state) { - this.Reset(state); - } - - /// <summary>Reset the tracked state.</summary> - /// <param name="state">The state from which to reset, or <c>null</c> to get the latest state.</param> - public GamePadStateBuilder Reset(GamePadState? state = null) - { - this.State = state ??= GamePad.GetState(PlayerIndex.One); - this.IsConnected = state.Value.IsConnected; + this.State = state; + this.IsConnected = state.IsConnected; if (!this.IsConnected) - return this; + return; - GamePadDPad pad = state.Value.DPad; - GamePadButtons buttons = state.Value.Buttons; - GamePadTriggers triggers = state.Value.Triggers; - GamePadThumbSticks sticks = state.Value.ThumbSticks; + GamePadDPad pad = state.DPad; + GamePadButtons buttons = state.Buttons; + GamePadTriggers triggers = state.Triggers; + GamePadThumbSticks sticks = state.ThumbSticks; this.ButtonStates = new Dictionary<SButton, ButtonState> { [SButton.DPadUp] = pad.Up, @@ -89,8 +82,6 @@ namespace StardewModdingAPI.Framework.Input this.RightTrigger = triggers.Right; this.LeftStickPos = sticks.Left; this.RightStickPos = sticks.Right; - - return this; } /// <summary>Override the states for a set of buttons.</summary> diff --git a/src/SMAPI/Framework/Input/IInputStateBuilder.cs b/src/SMAPI/Framework/Input/IInputStateBuilder.cs index 193e5216..28d62439 100644 --- a/src/SMAPI/Framework/Input/IInputStateBuilder.cs +++ b/src/SMAPI/Framework/Input/IInputStateBuilder.cs @@ -12,10 +12,6 @@ namespace StardewModdingAPI.Framework.Input /********* ** Methods *********/ - /// <summary>Reset the tracked state.</summary> - /// <param name="state">The state from which to reset, or <c>null</c> to get the latest state.</param> - THandler Reset(TState? state = null); - /// <summary>Override the states for a set of buttons.</summary> /// <param name="overrides">The button state overrides.</param> THandler OverrideButtons(IDictionary<SButton, SButtonState> overrides); diff --git a/src/SMAPI/Framework/Input/KeyboardStateBuilder.cs b/src/SMAPI/Framework/Input/KeyboardStateBuilder.cs index f95a28bf..620ad442 100644 --- a/src/SMAPI/Framework/Input/KeyboardStateBuilder.cs +++ b/src/SMAPI/Framework/Input/KeyboardStateBuilder.cs @@ -21,23 +21,14 @@ namespace StardewModdingAPI.Framework.Input ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="state">The initial state, or <c>null</c> to get the latest state.</param> - public KeyboardStateBuilder(KeyboardState? state = null) + /// <param name="state">The initial state.</param> + public KeyboardStateBuilder(KeyboardState state) { - this.Reset(state); - } - - /// <summary>Reset the tracked state.</summary> - /// <param name="state">The state from which to reset, or <c>null</c> to get the latest state.</param> - public KeyboardStateBuilder Reset(KeyboardState? state = null) - { - this.State = state ??= Keyboard.GetState(); + this.State = state; this.PressedButtons.Clear(); - foreach (var button in state.Value.GetPressedKeys()) + foreach (var button in state.GetPressedKeys()) this.PressedButtons.Add(button); - - return this; } /// <summary>Override the states for a set of buttons.</summary> diff --git a/src/SMAPI/Framework/Input/MouseStateBuilder.cs b/src/SMAPI/Framework/Input/MouseStateBuilder.cs index 1cc16ca9..a1ac5492 100644 --- a/src/SMAPI/Framework/Input/MouseStateBuilder.cs +++ b/src/SMAPI/Framework/Input/MouseStateBuilder.cs @@ -13,51 +13,42 @@ namespace StardewModdingAPI.Framework.Input private MouseState? State; /// <summary>The current button states.</summary> - private IDictionary<SButton, ButtonState> ButtonStates; + private readonly IDictionary<SButton, ButtonState> ButtonStates; /// <summary>The mouse wheel scroll value.</summary> - private int ScrollWheelValue; + private readonly int ScrollWheelValue; /********* ** Accessors *********/ /// <summary>The X cursor position.</summary> - public int X { get; private set; } + public int X { get; } /// <summary>The Y cursor position.</summary> - public int Y { get; private set; } + public int Y { get; } /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="state">The initial state, or <c>null</c> to get the latest state.</param> - public MouseStateBuilder(MouseState? state = null) + /// <param name="state">The initial state.</param> + public MouseStateBuilder(MouseState state) { - this.Reset(state); - } - - /// <summary>Reset the tracked state.</summary> - /// <param name="state">The state from which to reset, or <c>null</c> to get the latest state.</param> - public MouseStateBuilder Reset(MouseState? state = null) - { - this.State = state ??= Mouse.GetState(); + this.State = state; this.ButtonStates = new Dictionary<SButton, ButtonState> { - [SButton.MouseLeft] = state.Value.LeftButton, - [SButton.MouseMiddle] = state.Value.MiddleButton, - [SButton.MouseRight] = state.Value.RightButton, - [SButton.MouseX1] = state.Value.XButton1, - [SButton.MouseX2] = state.Value.XButton2 + [SButton.MouseLeft] = state.LeftButton, + [SButton.MouseMiddle] = state.MiddleButton, + [SButton.MouseRight] = state.RightButton, + [SButton.MouseX1] = state.XButton1, + [SButton.MouseX2] = state.XButton2 }; - this.X = state.Value.X; - this.Y = state.Value.Y; - this.ScrollWheelValue = state.Value.ScrollWheelValue; - - return this; + this.X = state.X; + this.Y = state.Y; + this.ScrollWheelValue = state.ScrollWheelValue; } /// <summary>Override the states for a set of buttons.</summary> diff --git a/src/SMAPI/Framework/Input/SInputState.cs b/src/SMAPI/Framework/Input/SInputState.cs index 333f5726..3dfeb152 100644 --- a/src/SMAPI/Framework/Input/SInputState.cs +++ b/src/SMAPI/Framework/Input/SInputState.cs @@ -29,21 +29,24 @@ namespace StardewModdingAPI.Framework.Input /// <summary>Whether there are new overrides in <see cref="CustomPressedKeys"/> or <see cref="CustomReleasedKeys"/> that haven't been applied to the previous state.</summary> private bool HasNewOverrides; + /// <summary>The game tick when the input state was last updated.</summary> + private uint? LastUpdateTick; + /********* ** Accessors *********/ - /// <summary>The controller state as of the last update.</summary> - public GamePadState LastController { get; private set; } + /// <summary>The controller state as of the last update, with overrides applied.</summary> + public GamePadState ControllerState { get; private set; } - /// <summary>The keyboard state as of the last update.</summary> - public KeyboardState LastKeyboard { get; private set; } + /// <summary>The keyboard state as of the last update, with overrides applied.</summary> + public KeyboardState KeyboardState { get; private set; } - /// <summary>The mouse state as of the last update.</summary> - public MouseState LastMouse { get; private set; } + /// <summary>The mouse state as of the last update, with overrides applied.</summary> + public MouseState MouseState { get; private set; } /// <summary>The buttons which were pressed, held, or released as of the last update.</summary> - public IDictionary<SButton, SButtonState> LastButtonStates { get; private set; } = new Dictionary<SButton, SButtonState>(); + public IDictionary<SButton, SButtonState> ButtonStates { get; private set; } = new Dictionary<SButton, SButtonState>(); /// <summary>The cursor position on the screen adjusted for the zoom level.</summary> public ICursorPosition CursorPosition => this.CursorPositionImpl; @@ -52,54 +55,26 @@ namespace StardewModdingAPI.Framework.Input /********* ** Public methods *********/ - /// <summary>Get a copy of the current state.</summary> - public SInputState Clone() - { - return new SInputState - { - LastButtonStates = this.LastButtonStates, - LastController = this.LastController, - LastKeyboard = this.LastKeyboard, - LastMouse = this.LastMouse, - CursorPositionImpl = this.CursorPositionImpl - }; - } - - /// <summary>Override the state for a button.</summary> - /// <param name="button">The button to override.</param> - /// <param name="setDown">Whether to mark it pressed; else mark it released.</param> - public void OverrideButton(SButton button, bool setDown) + /// <summary>Update the current button states for the given tick. This does nothing if the input has already been updated for this tick (e.g. because SMAPI updated it before the game update).</summary> + public override void Update() { - bool changed = setDown - ? this.CustomPressedKeys.Add(button) | this.CustomReleasedKeys.Remove(button) - : this.CustomPressedKeys.Remove(button) | this.CustomReleasedKeys.Add(button); + // skip if already updated + if (this.LastUpdateTick == SCore.TicksElapsed) + return; + this.LastUpdateTick = SCore.TicksElapsed; - if (changed) - this.HasNewOverrides = true; - } + // update base state + base.Update(); - /// <summary>Get whether a mod has indicated the key was already handled, so the game shouldn't handle it.</summary> - /// <param name="button">The button to check.</param> - public bool IsSuppressed(SButton button) - { - return this.CustomReleasedKeys.Contains(button); - } - - /// <summary>This method is called by the game, and does nothing since SMAPI will already have updated by that point.</summary> - [Obsolete("This method should only be called by the game itself.")] - public override void Update() { } - - /// <summary>Update the current button states for the given tick.</summary> - public void TrueUpdate() - { + // update SMAPI extended data try { float zoomMultiplier = (1f / Game1.options.zoomLevel); // get real values - var controller = new GamePadStateBuilder(); - var keyboard = new KeyboardStateBuilder(); - var mouse = new MouseStateBuilder(); + var controller = new GamePadStateBuilder(base.GetGamePadState()); + var keyboard = new KeyboardStateBuilder(base.GetKeyboardState()); + var mouse = new MouseStateBuilder(base.GetMouseState()); Vector2 cursorAbsolutePos = new Vector2((mouse.X * zoomMultiplier) + Game1.viewport.X, (mouse.Y * zoomMultiplier) + Game1.viewport.Y); Vector2? playerTilePos = Context.IsPlayerFree ? Game1.player.getTileLocation() : (Vector2?)null; HashSet<SButton> reallyDown = new HashSet<SButton>(this.GetPressedButtons(keyboard, mouse, controller)); @@ -124,18 +99,18 @@ namespace StardewModdingAPI.Framework.Input var pressedButtons = hasOverrides ? new HashSet<SButton>(this.GetPressedButtons(keyboard, mouse, controller)) : reallyDown; - var activeButtons = this.DeriveStates(this.LastButtonStates, pressedButtons); + var activeButtons = this.DeriveStates(this.ButtonStates, pressedButtons); // update this.HasNewOverrides = false; - this.LastController = controller.GetState(); - this.LastKeyboard = keyboard.GetState(); - this.LastMouse = mouse.GetState(); - this.LastButtonStates = activeButtons; + this.ControllerState = controller.GetState(); + this.KeyboardState = keyboard.GetState(); + this.MouseState = mouse.GetState(); + this.ButtonStates = activeButtons; if (cursorAbsolutePos != this.CursorPositionImpl?.AbsolutePixels || playerTilePos != this.LastPlayerTile) { this.LastPlayerTile = playerTilePos; - this.CursorPositionImpl = this.GetCursorPosition(this.LastMouse, cursorAbsolutePos, zoomMultiplier); + this.CursorPositionImpl = this.GetCursorPosition(this.MouseState, cursorAbsolutePos, zoomMultiplier); } } catch (InvalidOperationException) @@ -144,53 +119,67 @@ namespace StardewModdingAPI.Framework.Input } } - /// <summary>Apply input overrides to the current state.</summary> - public void ApplyOverrides() - { - if (this.HasNewOverrides) - { - var controller = new GamePadStateBuilder(this.LastController); - var keyboard = new KeyboardStateBuilder(this.LastKeyboard); - var mouse = new MouseStateBuilder(this.LastMouse); - - if (this.ApplyOverrides(pressed: this.CustomPressedKeys, released: this.CustomReleasedKeys, controller, keyboard, mouse)) - { - this.LastController = controller.GetState(); - this.LastKeyboard = keyboard.GetState(); - this.LastMouse = mouse.GetState(); - } - } - } - /// <summary>Get the gamepad state visible to the game.</summary> - [Obsolete("This method should only be called by the game itself.")] public override GamePadState GetGamePadState() { - if (Game1.options.gamepadMode == Options.GamepadModes.ForceOff) - return new GamePadState(); - - return this.LastController; + return this.ControllerState; } /// <summary>Get the keyboard state visible to the game.</summary> - [Obsolete("This method should only be called by the game itself.")] public override KeyboardState GetKeyboardState() { - return this.LastKeyboard; + return this.KeyboardState; } /// <summary>Get the keyboard state visible to the game.</summary> - [Obsolete("This method should only be called by the game itself.")] public override MouseState GetMouseState() { - return this.LastMouse; + return this.MouseState; + } + + /// <summary>Override the state for a button.</summary> + /// <param name="button">The button to override.</param> + /// <param name="setDown">Whether to mark it pressed; else mark it released.</param> + public void OverrideButton(SButton button, bool setDown) + { + bool changed = setDown + ? this.CustomPressedKeys.Add(button) | this.CustomReleasedKeys.Remove(button) + : this.CustomPressedKeys.Remove(button) | this.CustomReleasedKeys.Add(button); + + if (changed) + this.HasNewOverrides = true; + } + + /// <summary>Get whether a mod has indicated the key was already handled, so the game shouldn't handle it.</summary> + /// <param name="button">The button to check.</param> + public bool IsSuppressed(SButton button) + { + return this.CustomReleasedKeys.Contains(button); + } + + /// <summary>Apply input overrides to the current state.</summary> + public void ApplyOverrides() + { + if (this.HasNewOverrides) + { + var controller = new GamePadStateBuilder(this.ControllerState); + var keyboard = new KeyboardStateBuilder(this.KeyboardState); + var mouse = new MouseStateBuilder(this.MouseState); + + if (this.ApplyOverrides(pressed: this.CustomPressedKeys, released: this.CustomReleasedKeys, controller, keyboard, mouse)) + { + this.ControllerState = controller.GetState(); + this.KeyboardState = keyboard.GetState(); + this.MouseState = mouse.GetState(); + } + } } /// <summary>Get whether a given button was pressed or held.</summary> /// <param name="button">The button to check.</param> public bool IsDown(SButton button) { - return this.GetState(this.LastButtonStates, button).IsDown(); + return this.GetState(this.ButtonStates, button).IsDown(); } /// <summary>Get whether any of the given buttons were pressed or held.</summary> @@ -204,7 +193,7 @@ namespace StardewModdingAPI.Framework.Input /// <param name="button">The button to check.</param> public SButtonState GetState(SButton button) { - return this.GetState(this.LastButtonStates, button); + return this.GetState(this.ButtonStates, button); } @@ -305,7 +294,9 @@ namespace StardewModdingAPI.Framework.Input /// <param name="button">The button to check.</param> private SButtonState GetState(IDictionary<SButton, SButtonState> activeButtons, SButton button) { - return activeButtons.TryGetValue(button, out SButtonState state) ? state : SButtonState.None; + return activeButtons.TryGetValue(button, out SButtonState state) + ? state + : SButtonState.None; } /// <summary>Get the buttons pressed in the given stats.</summary> diff --git a/src/SMAPI/Framework/InternalExtensions.cs b/src/SMAPI/Framework/InternalExtensions.cs index 8b45e196..b6704f26 100644 --- a/src/SMAPI/Framework/InternalExtensions.cs +++ b/src/SMAPI/Framework/InternalExtensions.cs @@ -164,9 +164,9 @@ namespace StardewModdingAPI.Framework // get field name const string fieldName = #if SMAPI_FOR_WINDOWS - "inBeginEndPair"; + "inBeginEndPair"; #else - "_beginCalled"; + "_beginCalled"; #endif // get result diff --git a/src/SMAPI/Framework/Logging/ConsoleInterceptionManager.cs b/src/SMAPI/Framework/Logging/ConsoleInterceptionManager.cs deleted file mode 100644 index ef42e536..00000000 --- a/src/SMAPI/Framework/Logging/ConsoleInterceptionManager.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System; - -namespace StardewModdingAPI.Framework.Logging -{ - /// <summary>Manages console output interception.</summary> - internal class ConsoleInterceptionManager : IDisposable - { - /********* - ** Fields - *********/ - /// <summary>The intercepting console writer.</summary> - private readonly InterceptingTextWriter Output; - - - /********* - ** Accessors - *********/ - /// <summary>The event raised when a message is written to the console directly.</summary> - public event Action<string> OnMessageIntercepted; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - public ConsoleInterceptionManager() - { - // redirect output through interceptor - this.Output = new InterceptingTextWriter(Console.Out); - this.Output.OnMessageIntercepted += line => this.OnMessageIntercepted?.Invoke(line); - Console.SetOut(this.Output); - } - - /// <summary>Get an exclusive lock and write to the console output without interception.</summary> - /// <param name="action">The action to perform within the exclusive write block.</param> - public void ExclusiveWriteWithoutInterception(Action action) - { - lock (Console.Out) - { - try - { - this.Output.ShouldIntercept = false; - action(); - } - finally - { - this.Output.ShouldIntercept = true; - } - } - } - - /// <summary>Release all resources.</summary> - public void Dispose() - { - Console.SetOut(this.Output.Out); - this.Output.Dispose(); - } - } -} diff --git a/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs b/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs index 9ca61b59..d99f1dd2 100644 --- a/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs +++ b/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs @@ -8,17 +8,21 @@ namespace StardewModdingAPI.Framework.Logging internal class InterceptingTextWriter : TextWriter { /********* + ** Fields + *********/ + /// <summary>Prefixing a message with this character indicates that the console interceptor should write the string without intercepting it. (The character itself is not written.)</summary> + private readonly char IgnoreChar; + + + /********* ** Accessors *********/ /// <summary>The underlying console output.</summary> public TextWriter Out { get; } - /// <summary>The character encoding in which the output is written.</summary> + /// <inheritdoc /> public override Encoding Encoding => this.Out.Encoding; - /// <summary>Whether to intercept console output.</summary> - public bool ShouldIntercept { get; set; } - /// <summary>The event raised when a message is written to the console directly.</summary> public event Action<string> OnMessageIntercepted; @@ -28,36 +32,53 @@ namespace StardewModdingAPI.Framework.Logging *********/ /// <summary>Construct an instance.</summary> /// <param name="output">The underlying output writer.</param> - public InterceptingTextWriter(TextWriter output) + /// <param name="ignoreChar">Prefixing a message with this character indicates that the console interceptor should write the string without intercepting it. (The character itself is not written.)</param> + public InterceptingTextWriter(TextWriter output, char ignoreChar) { this.Out = output; + this.IgnoreChar = ignoreChar; } - /// <summary>Writes a subarray of characters to the text string or stream.</summary> - /// <param name="buffer">The character array to write data from.</param> - /// <param name="index">The character position in the buffer at which to start retrieving data.</param> - /// <param name="count">The number of characters to write.</param> + /// <inheritdoc /> public override void Write(char[] buffer, int index, int count) { - if (this.ShouldIntercept) - this.OnMessageIntercepted?.Invoke(new string(buffer, index, count).TrimEnd('\r', '\n')); - else + if (buffer.Length == 0) this.Out.Write(buffer, index, count); + else if (buffer[0] == this.IgnoreChar) + this.Out.Write(buffer, index + 1, count - 1); + else if (this.IsEmptyOrNewline(buffer)) + this.Out.Write(buffer, index, count); + else + this.OnMessageIntercepted?.Invoke(new string(buffer, index, count).TrimEnd('\r', '\n')); } - /// <summary>Writes a character to the text string or stream.</summary> - /// <param name="ch">The character to write to the text stream.</param> - /// <remarks>Console log messages from the game should be caught by <see cref="Write(char[],int,int)"/>. This method passes through anything that bypasses that method for some reason, since it's better to show it to users than hide it from everyone.</remarks> + /// <inheritdoc /> public override void Write(char ch) { this.Out.Write(ch); } - /// <summary>Releases the unmanaged resources used by the <see cref="T:System.IO.TextWriter" /> and optionally releases the managed resources.</summary> - /// <param name="disposing">true to release both managed and unmanaged resources; false to release only unmanaged resources.</param> + /// <inheritdoc /> protected override void Dispose(bool disposing) { this.OnMessageIntercepted = null; } + + + /********* + ** Private methods + *********/ + /// <summary>Get whether a buffer represents a line break.</summary> + /// <param name="buffer">The buffer to check.</param> + private bool IsEmptyOrNewline(char[] buffer) + { + foreach (char ch in buffer) + { + if (ch != '\n' && ch != '\r') + return false; + } + + return true; + } } } diff --git a/src/SMAPI/Framework/Logging/LogManager.cs b/src/SMAPI/Framework/Logging/LogManager.cs new file mode 100644 index 00000000..094dd749 --- /dev/null +++ b/src/SMAPI/Framework/Logging/LogManager.cs @@ -0,0 +1,600 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using StardewModdingAPI.Framework.Commands; +using StardewModdingAPI.Framework.ModLoading; +using StardewModdingAPI.Internal.ConsoleWriting; +using StardewModdingAPI.Toolkit.Framework.ModData; +using StardewModdingAPI.Toolkit.Utilities; + +namespace StardewModdingAPI.Framework.Logging +{ + /// <summary>Manages the SMAPI console window and log file.</summary> + internal class LogManager : IDisposable + { + /********* + ** Fields + *********/ + /// <summary>The log file to which to write messages.</summary> + private readonly LogFileManager LogFile; + + /// <summary>Prefixing a low-level message with this character indicates that the console interceptor should write the string without intercepting it. (The character itself is not written.)</summary> + private readonly char IgnoreChar = '\u200B'; + + /// <summary>Get a named monitor instance.</summary> + private readonly Func<string, Monitor> GetMonitorImpl; + + /// <summary>Regex patterns which match console non-error messages to suppress from the console and log.</summary> + private readonly Regex[] SuppressConsolePatterns = + { + new Regex(@"^TextBox\.Selected is now '(?:True|False)'\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^(?:FRUIT )?TREE: IsClient:(?:True|False) randomOutput: \d+$", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^loadPreferences\(\); begin", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^savePreferences\(\); async=", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^DebugOutput:\s+(?:added CLOUD|added cricket|dismount tile|Ping|playerPos)", RegexOptions.Compiled | RegexOptions.CultureInvariant) + }; + + /// <summary>Regex patterns which match console messages to show a more friendly error for.</summary> + private readonly ReplaceLogPattern[] ReplaceConsolePatterns = + { + // Steam not loaded + new ReplaceLogPattern( + search: new Regex(@"^System\.InvalidOperationException: Steamworks is not initialized\.[\s\S]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant), + replacement: +#if SMAPI_FOR_WINDOWS + "Oops! Steam achievements won't work because Steam isn't loaded. You can launch the game through Steam to fix that (see 'Part 2: Configure Steam' in the install guide for more info: https://smapi.io/install).", +#else + "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( + search: new Regex(@"^System\.IO\.FileNotFoundException: [^\n]+\n[^:]+: '[^\n]+[/\\]Saves[/\\]([^'\r\n]+)[/\\]([^'\r\n]+)'[\s\S]+LoadGameMenu\.FindSaveGames[\s\S]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant), + replacement: "The game can't find the '$2' file for your '$1' save. See https://stardewvalleywiki.com/Saves#Troubleshooting for help.", + logLevel: LogLevel.Error + ) + }; + + + /********* + ** Accessors + *********/ + /// <summary>The core logger and monitor for SMAPI.</summary> + public Monitor Monitor { get; } + + /// <summary>The core logger and monitor on behalf of the game.</summary> + public Monitor MonitorForGame { get; } + + + /********* + ** Public methods + *********/ + /**** + ** Initialization + ****/ + /// <summary>Construct an instance.</summary> + /// <param name="logPath">The log file path to write.</param> + /// <param name="colorConfig">The colors to use for text written to the SMAPI console.</param> + /// <param name="writeToConsole">Whether to output log messages to the console.</param> + /// <param name="isVerbose">Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed.</param> + /// <param name="isDeveloperMode">Whether to enable full console output for developers.</param> + public LogManager(string logPath, ColorSchemeConfig colorConfig, bool writeToConsole, bool isVerbose, bool isDeveloperMode) + { + // init construction logic + this.GetMonitorImpl = name => new Monitor(name, this.IgnoreChar, this.LogFile, colorConfig, isVerbose) + { + WriteToConsole = writeToConsole, + ShowTraceInConsole = isDeveloperMode, + ShowFullStampInConsole = isDeveloperMode + }; + + // init fields + this.LogFile = new LogFileManager(logPath); + this.Monitor = this.GetMonitor("SMAPI"); + this.MonitorForGame = this.GetMonitor("game"); + + // redirect direct console output + var output = new InterceptingTextWriter(Console.Out, this.IgnoreChar); + if (writeToConsole) + output.OnMessageIntercepted += message => this.HandleConsoleMessage(this.MonitorForGame, message); + Console.SetOut(output); + } + + /// <summary>Get a monitor instance derived from SMAPI's current settings.</summary> + /// <param name="name">The name of the module which will log messages with this instance.</param> + public Monitor GetMonitor(string name) + { + return this.GetMonitorImpl(name); + } + + /// <summary>Set the title of the SMAPI console window.</summary> + /// <param name="title">The new window title.</param> + public void SetConsoleTitle(string title) + { + Console.Title = title; + } + + /**** + ** Console input + ****/ + /// <summary>Run a loop handling console input.</summary> + [SuppressMessage("ReSharper", "FunctionNeverReturns", Justification = "The thread is aborted when the game exits.")] + public void RunConsoleInputLoop(CommandManager commandManager, Action reloadTranslations, Action<string> handleInput, Func<bool> continueWhile) + { + // prepare console + this.Monitor.Log("Type 'help' for help, or 'help <cmd>' for a command's usage", LogLevel.Info); + commandManager + .Add(new HelpCommand(commandManager), this.Monitor) + .Add(new HarmonySummaryCommand(), this.Monitor) + .Add(new ReloadI18nCommand(reloadTranslations), this.Monitor); + + // start handling command line input + Thread inputThread = new Thread(() => + { + while (true) + { + // get input + string input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input)) + continue; + + // handle command + this.Monitor.LogUserInput(input); + handleInput(input); + } + }); + inputThread.Start(); + + // keep console thread alive while the game is running + while (continueWhile()) + Thread.Sleep(1000 / 10); + if (inputThread.ThreadState == ThreadState.Running) + inputThread.Abort(); + } + + /// <summary>Show a 'press any key to exit' message, and exit when they press a key.</summary> + public void PressAnyKeyToExit() + { + this.Monitor.Log("Game has ended. Press any key to exit.", LogLevel.Info); + this.PressAnyKeyToExit(showMessage: false); + } + + /// <summary>Show a 'press any key to exit' message, and exit when they press a key.</summary> + /// <param name="showMessage">Whether to print a 'press any key to exit' message to the console.</param> + public void PressAnyKeyToExit(bool showMessage) + { + if (showMessage) + this.Monitor.Log("Game has ended. Press any key to exit."); + Thread.Sleep(100); + Console.ReadKey(); + Environment.Exit(0); + } + + /**** + ** Crash/update handling + ****/ + /// <summary>Create a crash marker and duplicate the log into the crash log.</summary> + public void WriteCrashLog() + { + try + { + File.WriteAllText(Constants.FatalCrashMarker, string.Empty); + File.Copy(this.LogFile.Path, Constants.FatalCrashLog, overwrite: true); + } + catch (Exception ex) + { + this.Monitor.Log($"SMAPI failed trying to track the crash details: {ex.GetLogSummary()}", LogLevel.Error); + } + } + + /// <summary>Write an update alert marker file.</summary> + /// <param name="version">The new version found.</param> + public void WriteUpdateMarker(string version) + { + File.WriteAllText(Constants.UpdateMarker, version); + } + + /// <summary>Check whether SMAPI crashed or detected an update during the last session, and display them in the SMAPI console.</summary> + public void HandleMarkerFiles() + { + // show update alert + if (File.Exists(Constants.UpdateMarker)) + { + string rawUpdateFound = File.ReadAllText(Constants.UpdateMarker); + if (SemanticVersion.TryParse(rawUpdateFound, out ISemanticVersion updateFound)) + { + if (Constants.ApiVersion.IsPrerelease() && updateFound.IsNewerThan(Constants.ApiVersion)) + { + this.Monitor.Log("A new version of SMAPI was detected last time you played.", LogLevel.Error); + this.Monitor.Log($"You can update to {updateFound}: https://smapi.io.", LogLevel.Error); + this.Monitor.Log("Press any key to continue playing anyway. (This only appears when using a SMAPI beta.)", LogLevel.Info); + Console.ReadKey(); + } + } + File.Delete(Constants.UpdateMarker); + } + + // show details if game crashed during last session + if (File.Exists(Constants.FatalCrashMarker)) + { + 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(); + File.Delete(Constants.FatalCrashLog); + File.Delete(Constants.FatalCrashMarker); + } + } + + /// <summary>Log a fatal exception which prevents SMAPI from launching.</summary> + /// <param name="exception">The exception details.</param> + public void LogFatalLaunchError(Exception exception) + { + switch (exception) + { + // audio crash + case InvalidOperationException ex when ex.Source == "Microsoft.Xna.Framework.Xact" && ex.StackTrace.Contains("Microsoft.Xna.Framework.Audio.AudioEngine..ctor"): + this.Monitor.Log("The game couldn't load audio. Do you have speakers or headphones plugged in?", LogLevel.Error); + this.Monitor.Log($"Technical details: {ex.GetLogSummary()}"); + break; + + // missing content folder exception + case FileNotFoundException ex when ex.Message == "Could not find file 'C:\\Program Files (x86)\\Steam\\SteamApps\\common\\Stardew Valley\\Content\\XACT\\FarmerSounds.xgs'.": // path in error is hardcoded regardless of install path + this.Monitor.Log("The game can't find its Content\\XACT\\FarmerSounds.xgs file. You can usually fix this by resetting your content files (see https://smapi.io/troubleshoot#reset-content ), or by uninstalling and reinstalling the game.", LogLevel.Error); + this.Monitor.Log($"Technical details: {ex.GetLogSummary()}"); + break; + + // generic exception + default: + this.MonitorForGame.Log($"The game failed to launch: {exception.GetLogSummary()}", LogLevel.Error); + break; + } + } + + /**** + ** General log output + ****/ + /// <summary>Log the initial header with general SMAPI and system details.</summary> + /// <param name="modsPath">The path from which mods will be loaded.</param> + /// <param name="customSettings">The custom SMAPI settings.</param> + public void LogIntro(string modsPath, IDictionary<string, object> customSettings) + { + // init logging + this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} on {EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform)}", LogLevel.Info); + this.Monitor.Log($"Mods go here: {modsPath}", LogLevel.Info); + if (modsPath != Constants.DefaultModsPath) + this.Monitor.Log("(Using custom --mods-path argument.)"); + this.Monitor.Log($"Log started at {DateTime.UtcNow:s} UTC"); + + // log custom settings + if (customSettings.Any()) + this.Monitor.Log($"Loaded with custom settings: {string.Join(", ", customSettings.OrderBy(p => p.Key).Select(p => $"{p.Key}: {p.Value}"))}"); + } + + /// <summary>Log details for settings that don't match the default.</summary> + /// <param name="isDeveloperMode">Whether to enable full console output for developers.</param> + /// <param name="checkForUpdates">Whether to check for newer versions of SMAPI and mods on startup.</param> + public void LogSettingsHeader(bool isDeveloperMode, bool checkForUpdates) + { + if (isDeveloperMode) + this.Monitor.Log($"You have SMAPI for developers, so the console will be much more verbose. You can disable developer mode by installing the non-developer version of SMAPI, or by editing {Constants.ApiConfigPath}.", LogLevel.Info); + if (!checkForUpdates) + this.Monitor.Log($"You configured SMAPI to not check for updates. Running an old version of SMAPI is not recommended. You can enable update checks by reinstalling SMAPI or editing {Constants.ApiConfigPath}.", LogLevel.Warn); + if (!this.Monitor.WriteToConsole) + this.Monitor.Log("Writing to the terminal is disabled because the --no-terminal argument was received. This usually means launching the terminal failed.", LogLevel.Warn); + this.Monitor.VerboseLog("Verbose logging enabled."); + } + + /// <summary>Log info about loaded mods.</summary> + /// <param name="loaded">The full list of loaded content packs and mods.</param> + /// <param name="loadedContentPacks">The loaded content packs.</param> + /// <param name="loadedMods">The loaded mods.</param> + /// <param name="skippedMods">The mods which could not be loaded.</param> + /// <param name="logParanoidWarnings">Whether to log issues for mods which directly use potentially sensitive .NET APIs like file or shell access.</param> + public void LogModInfo(IModMetadata[] loaded, IModMetadata[] loadedContentPacks, IModMetadata[] loadedMods, IModMetadata[] skippedMods, bool logParanoidWarnings) + { + // log loaded mods + this.Monitor.Log($"Loaded {loadedMods.Length} mods" + (loadedMods.Length > 0 ? ":" : "."), LogLevel.Info); + foreach (IModMetadata metadata in loadedMods.OrderBy(p => p.DisplayName)) + { + IManifest manifest = metadata.Manifest; + this.Monitor.Log( + $" {metadata.DisplayName} {manifest.Version}" + + (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "") + + (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""), + LogLevel.Info + ); + } + + this.Monitor.Newline(); + + // log loaded content packs + if (loadedContentPacks.Any()) + { + string GetModDisplayName(string id) => loadedMods.FirstOrDefault(p => p.HasID(id))?.DisplayName; + + this.Monitor.Log($"Loaded {loadedContentPacks.Length} content packs:", LogLevel.Info); + foreach (IModMetadata metadata in loadedContentPacks.OrderBy(p => p.DisplayName)) + { + IManifest manifest = metadata.Manifest; + this.Monitor.Log( + $" {metadata.DisplayName} {manifest.Version}" + + (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "") + + $" | for {GetModDisplayName(metadata.Manifest.ContentPackFor.UniqueID)}" + + (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""), + LogLevel.Info + ); + } + + this.Monitor.Newline(); + } + + // log mod warnings + this.LogModWarnings(loaded, skippedMods, logParanoidWarnings); + } + + /// <inheritdoc /> + public void Dispose() + { + this.LogFile.Dispose(); + } + + + /********* + ** Protected methods + *********/ + /// <summary>Redirect messages logged directly to the console to the given monitor.</summary> + /// <param name="gameMonitor">The monitor with which to log messages as the game.</param> + /// <param name="message">The message to log.</param> + private void HandleConsoleMessage(IMonitor gameMonitor, string message) + { + // detect exception + LogLevel level = message.Contains("Exception") ? LogLevel.Error : LogLevel.Trace; + + // ignore suppressed message + if (level != LogLevel.Error && this.SuppressConsolePatterns.Any(p => p.IsMatch(message))) + return; + + // show friendly error if applicable + foreach (ReplaceLogPattern entry in this.ReplaceConsolePatterns) + { + string newMessage = entry.Search.Replace(message, entry.Replacement); + if (message != newMessage) + { + gameMonitor.Log(newMessage, entry.LogLevel); + gameMonitor.Log(message); + return; + } + } + + // forward to monitor + gameMonitor.Log(message, level); + } + + /// <summary>Write a summary of mod warnings to the console and log.</summary> + /// <param name="mods">The loaded mods.</param> + /// <param name="skippedMods">The mods which could not be loaded.</param> + /// <param name="logParanoidWarnings">Whether to log issues for mods which directly use potentially sensitive .NET APIs like file or shell access.</param> + private void LogModWarnings(IEnumerable<IModMetadata> mods, IModMetadata[] skippedMods, bool logParanoidWarnings) + { + // get mods with warnings + IModMetadata[] modsWithWarnings = mods.Where(p => p.Warnings != ModWarning.None).ToArray(); + if (!modsWithWarnings.Any() && !skippedMods.Any()) + return; + + // log intro + { + int count = modsWithWarnings.Length + skippedMods.Length; + this.Monitor.Log($"Found {count} mod{(count == 1 ? "" : "s")} with warnings:", LogLevel.Info); + } + + // log skipped mods + if (skippedMods.Any()) + { + // get logging logic + HashSet<string> loggedDuplicateIds = new HashSet<string>(); + void LogSkippedMod(IModMetadata mod) + { + string message = $" - {mod.DisplayName}{(mod.Manifest?.Version != null ? " " + mod.Manifest.Version.ToString() : "")} because {mod.Error}"; + + // handle duplicate mods + // (log first duplicate only, don't show redundant version) + if (mod.FailReason == ModFailReason.Duplicate && mod.HasManifest()) + { + if (!loggedDuplicateIds.Add(mod.Manifest.UniqueID)) + return; // already logged + + message = $" - {mod.DisplayName} because {mod.Error}"; + } + + // log message + this.Monitor.Log(message, LogLevel.Error); + if (mod.ErrorDetails != null) + this.Monitor.Log($" ({mod.ErrorDetails})"); + } + + // find skipped dependencies + IModMetadata[] skippedDependencies; + { + HashSet<string> skippedDependencyIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + HashSet<string> skippedModIds = new HashSet<string>(from mod in skippedMods where mod.HasID() select mod.Manifest.UniqueID, StringComparer.OrdinalIgnoreCase); + foreach (IModMetadata mod in skippedMods) + { + foreach (string requiredId in skippedModIds.Intersect(mod.GetRequiredModIds())) + skippedDependencyIds.Add(requiredId); + } + skippedDependencies = skippedMods.Where(p => p.HasID() && skippedDependencyIds.Contains(p.Manifest.UniqueID)).ToArray(); + } + + // log skipped mods + this.Monitor.Log(" Skipped mods", LogLevel.Error); + this.Monitor.Log(" " + "".PadRight(50, '-'), LogLevel.Error); + this.Monitor.Log(" These mods could not be added to your game.", LogLevel.Error); + this.Monitor.Newline(); + + if (skippedDependencies.Any()) + { + foreach (IModMetadata mod in skippedDependencies.OrderBy(p => p.DisplayName)) + LogSkippedMod(mod); + this.Monitor.Newline(); + } + + foreach (IModMetadata mod in skippedMods.OrderBy(p => p.DisplayName)) + LogSkippedMod(mod); + this.Monitor.Newline(); + } + + // log warnings + if (modsWithWarnings.Any()) + { + // broken code + this.LogModWarningGroup(modsWithWarnings, ModWarning.BrokenCodeLoaded, LogLevel.Error, "Broken mods", + "These mods have broken code, but you configured SMAPI to load them anyway. This may cause bugs,", + "errors, or crashes in-game." + ); + + // changes serializer + this.LogModWarningGroup(modsWithWarnings, ModWarning.ChangesSaveSerializer, LogLevel.Warn, "Changed save serializer", + "These mods change the save serializer. They may corrupt your save files, or make them unusable if", + "you uninstall these mods." + ); + + // patched game code + this.LogModWarningGroup(modsWithWarnings, ModWarning.PatchesGame, LogLevel.Info, "Patched game code", + "These mods directly change the game code. They're more likely to cause errors or bugs in-game; if", + "your game has issues, try removing these first. Otherwise you can ignore this warning." + ); + + // unvalidated update tick + this.LogModWarningGroup(modsWithWarnings, ModWarning.UsesUnvalidatedUpdateTick, LogLevel.Info, "Bypassed safety checks", + "These mods bypass SMAPI's normal safety checks, so they're more likely to cause errors or save", + "corruption. If your game has issues, try removing these first." + ); + + // paranoid warnings + if (logParanoidWarnings) + { + this.LogModWarningGroup( + modsWithWarnings, + match: mod => mod.HasUnsuppressedWarnings(ModWarning.AccessesConsole, ModWarning.AccessesFilesystem, ModWarning.AccessesShell), + level: LogLevel.Debug, + heading: "Direct system access", + blurb: new[] + { + "You enabled paranoid warnings and these mods directly access the filesystem, shells/processes, or", + "SMAPI console. (This is usually legitimate and innocent usage; this warning is only useful for", + "further investigation.)" + }, + modLabel: mod => + { + List<string> labels = new List<string>(); + if (mod.HasUnsuppressedWarnings(ModWarning.AccessesConsole)) + labels.Add("console"); + if (mod.HasUnsuppressedWarnings(ModWarning.AccessesFilesystem)) + labels.Add("files"); + if (mod.HasUnsuppressedWarnings(ModWarning.AccessesShell)) + labels.Add("shells/processes"); + + return $"{mod.DisplayName} ({string.Join(", ", labels)})"; + } + ); + } + + // no update keys + this.LogModWarningGroup(modsWithWarnings, ModWarning.NoUpdateKeys, LogLevel.Debug, "No update keys", + "These mods have no update keys in their manifest. SMAPI may not notify you about updates for these", + "mods. Consider notifying the mod authors about this problem." + ); + + // not crossplatform + this.LogModWarningGroup(modsWithWarnings, ModWarning.UsesDynamic, LogLevel.Debug, "Not crossplatform", + "These mods use the 'dynamic' keyword, and won't work on Linux/Mac." + ); + } + } + + /// <summary>Write a mod warning group to the console and log.</summary> + /// <param name="mods">The mods to search.</param> + /// <param name="match">Matches mods to include in the warning group.</param> + /// <param name="level">The log level for the logged messages.</param> + /// <param name="heading">A brief heading label for the group.</param> + /// <param name="blurb">A detailed explanation of the warning, split into lines.</param> + /// <param name="modLabel">Formats the mod label, or <c>null</c> to use the <see cref="IModMetadata.DisplayName"/>.</param> + private void LogModWarningGroup(IModMetadata[] mods, Func<IModMetadata, bool> match, LogLevel level, string heading, string[] blurb, Func<IModMetadata, string> modLabel = null) + { + // get matching mods + string[] modLabels = mods + .Where(match) + .Select(mod => modLabel?.Invoke(mod) ?? mod.DisplayName) + .OrderBy(p => p) + .ToArray(); + if (!modLabels.Any()) + return; + + // log header/blurb + this.Monitor.Log(" " + heading, level); + this.Monitor.Log(" " + "".PadRight(50, '-'), level); + foreach (string line in blurb) + this.Monitor.Log(" " + line, level); + this.Monitor.Newline(); + + // log mod list + foreach (string label in modLabels) + this.Monitor.Log($" - {label}", level); + + this.Monitor.Newline(); + } + + /// <summary>Write a mod warning group to the console and log.</summary> + /// <param name="mods">The mods to search.</param> + /// <param name="warning">The mod warning to match.</param> + /// <param name="level">The log level for the logged messages.</param> + /// <param name="heading">A brief heading label for the group.</param> + /// <param name="blurb">A detailed explanation of the warning, split into lines.</param> + private void LogModWarningGroup(IModMetadata[] mods, ModWarning warning, LogLevel level, string heading, params string[] blurb) + { + this.LogModWarningGroup(mods, mod => mod.HasUnsuppressedWarnings(warning), level, heading, blurb); + } + + + /********* + ** Protected types + *********/ + /// <summary>A console log pattern to replace with a different message.</summary> + private class ReplaceLogPattern + { + /********* + ** Accessors + *********/ + /// <summary>The regex pattern matching the portion of the message to replace.</summary> + public Regex Search { get; } + + /// <summary>The replacement string.</summary> + public string Replacement { get; } + + /// <summary>The log level for the new message.</summary> + public LogLevel LogLevel { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="search">The regex pattern matching the portion of the message to replace.</param> + /// <param name="replacement">The replacement string.</param> + /// <param name="logLevel">The log level for the new message.</param> + public ReplaceLogPattern(Regex search, string replacement, LogLevel logLevel) + { + this.Search = search; + this.Replacement = replacement; + this.LogLevel = logLevel; + } + } + } +} diff --git a/src/SMAPI/Framework/ModHelpers/BaseHelper.cs b/src/SMAPI/Framework/ModHelpers/BaseHelper.cs index 16032da1..5a3d4bed 100644 --- a/src/SMAPI/Framework/ModHelpers/BaseHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/BaseHelper.cs @@ -1,4 +1,4 @@ -namespace StardewModdingAPI.Framework.ModHelpers +namespace StardewModdingAPI.Framework.ModHelpers { /// <summary>The common base class for mod helpers.</summary> internal abstract class BaseHelper : IModLinked @@ -6,7 +6,7 @@ /********* ** Accessors *********/ - /// <summary>The unique ID of the mod for which the helper was created.</summary> + /// <inheritdoc /> public string ModID { get; } diff --git a/src/SMAPI/Framework/ModHelpers/CommandHelper.cs b/src/SMAPI/Framework/ModHelpers/CommandHelper.cs index e9d53d84..600f867f 100644 --- a/src/SMAPI/Framework/ModHelpers/CommandHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/CommandHelper.cs @@ -28,23 +28,14 @@ namespace StardewModdingAPI.Framework.ModHelpers this.CommandManager = commandManager; } - /// <summary>Add a console command.</summary> - /// <param name="name">The command name, which the user must type to trigger it.</param> - /// <param name="documentation">The human-readable documentation shown when the player runs the built-in 'help' command.</param> - /// <param name="callback">The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user.</param> - /// <exception cref="ArgumentNullException">The <paramref name="name"/> or <paramref name="callback"/> is null or empty.</exception> - /// <exception cref="FormatException">The <paramref name="name"/> is not a valid format.</exception> - /// <exception cref="ArgumentException">There's already a command with that name.</exception> + /// <inheritdoc /> public ICommandHelper Add(string name, string documentation, Action<string, string[]> callback) { this.CommandManager.Add(this.Mod, name, documentation, callback); return this; } - /// <summary>Trigger a command.</summary> - /// <param name="name">The command name.</param> - /// <param name="arguments">The command arguments.</param> - /// <returns>Returns whether a matching command was triggered.</returns> + /// <inheritdoc /> public bool Trigger(string name, string[] arguments) { return this.CommandManager.Trigger(name, arguments); diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs index 23e45fd1..5fd8f5e9 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs @@ -5,13 +5,10 @@ using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Contracts; using System.IO; using System.Linq; -using Microsoft.Xna.Framework.Content; -using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Exceptions; using StardewValley; -using xTile; namespace StardewModdingAPI.Framework.ModHelpers { @@ -40,10 +37,10 @@ namespace StardewModdingAPI.Framework.ModHelpers /********* ** Accessors *********/ - /// <summary>The game's current locale code (like <c>pt-BR</c>).</summary> + /// <inheritdoc /> public string CurrentLocale => this.GameContentManager.GetLocale(); - /// <summary>The game's current locale as an enum value.</summary> + /// <inheritdoc /> public LocalizedContentManager.LanguageCode CurrentLocaleConstant => this.GameContentManager.Language; /// <summary>The observable implementation of <see cref="AssetEditors"/>.</summary> @@ -52,10 +49,10 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <summary>The observable implementation of <see cref="AssetLoaders"/>.</summary> internal ObservableCollection<IAssetLoader> ObservableAssetLoaders { get; } = new ObservableCollection<IAssetLoader>(); - /// <summary>Interceptors which provide the initial versions of matching content assets.</summary> + /// <inheritdoc /> public IList<IAssetLoader> AssetLoaders => this.ObservableAssetLoaders; - /// <summary>Interceptors which edit matching content assets after they're loaded.</summary> + /// <inheritdoc /> public IList<IAssetEditor> AssetEditors => this.ObservableAssetEditors; @@ -80,12 +77,7 @@ namespace StardewModdingAPI.Framework.ModHelpers this.Monitor = monitor; } - /// <summary>Load content from the game folder or mod folder (if not already cached), and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary> - /// <typeparam name="T">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, and dictionaries; other types may be supported by the game's content pipeline.</typeparam> - /// <param name="key">The asset key to fetch (if the <paramref name="source"/> is <see cref="ContentSource.GameContent"/>), or the local path to a content file relative to the mod folder.</param> - /// <param name="source">Where to search for a matching content asset.</param> - /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> - /// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception> + /// <inheritdoc /> public T Load<T>(string key, ContentSource source = ContentSource.ModFolder) { try @@ -109,18 +101,14 @@ namespace StardewModdingAPI.Framework.ModHelpers } } - /// <summary>Normalize an asset name so it's consistent with those generated by the game. This is mainly useful for string comparisons like <see cref="string.StartsWith(string)"/> on generated asset names, and isn't necessary when passing asset names into other content helper methods.</summary> - /// <param name="assetName">The asset key.</param> + /// <inheritdoc /> [Pure] public string NormalizeAssetName(string assetName) { return this.ModContentManager.AssertAndNormalizeAssetName(assetName); } - /// <summary>Get the underlying key in the game's content cache for an asset. This can be used to load custom map tilesheets, but should be avoided when you can use the content API instead. This does not validate whether the asset exists.</summary> - /// <param name="key">The asset key to fetch (if the <paramref name="source"/> is <see cref="ContentSource.GameContent"/>), or the local path to a content file relative to the mod folder.</param> - /// <param name="source">Where to search for a matching content asset.</param> - /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> + /// <inheritdoc /> public string GetActualAssetKey(string key, ContentSource source = ContentSource.ModFolder) { switch (source) @@ -136,10 +124,7 @@ namespace StardewModdingAPI.Framework.ModHelpers } } - /// <summary>Remove an asset from the content cache so it's reloaded on the next request. This will reload core game assets if needed, but references to the former asset will still show the previous content.</summary> - /// <param name="key">The asset key to invalidate in the content folder.</param> - /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> - /// <returns>Returns whether the given asset key was cached.</returns> + /// <inheritdoc /> public bool InvalidateCache(string key) { string actualKey = this.GetActualAssetKey(key, ContentSource.GameContent); @@ -147,28 +132,21 @@ namespace StardewModdingAPI.Framework.ModHelpers return this.ContentCore.InvalidateCache(asset => asset.AssetNameEquals(actualKey)).Any(); } - /// <summary>Remove all assets of the given type from the cache so they're reloaded on the next request. <b>This can be a very expensive operation and should only be used in very specific cases.</b> This will reload core game assets if needed, but references to the former assets will still show the previous content.</summary> - /// <typeparam name="T">The asset type to remove from the cache.</typeparam> - /// <returns>Returns whether any assets were invalidated.</returns> + /// <inheritdoc /> public bool InvalidateCache<T>() { this.Monitor.Log($"Requested cache invalidation for all assets of type {typeof(T)}. This is an expensive operation and should be avoided if possible.", LogLevel.Trace); return this.ContentCore.InvalidateCache((key, type) => typeof(T).IsAssignableFrom(type)).Any(); } - /// <summary>Remove matching assets from the content cache so they're reloaded on the next request. This will reload core game assets if needed, but references to the former asset will still show the previous content.</summary> - /// <param name="predicate">A predicate matching the assets to invalidate.</param> - /// <returns>Returns whether any cache entries were invalidated.</returns> + /// <inheritdoc /> public bool InvalidateCache(Func<IAssetInfo, bool> predicate) { this.Monitor.Log("Requested cache invalidation for all assets matching a predicate.", LogLevel.Trace); return this.ContentCore.InvalidateCache(predicate).Any(); } - /// <summary>Get a patch helper for arbitrary data.</summary> - /// <typeparam name="T">The data type.</typeparam> - /// <param name="data">The asset data.</param> - /// <param name="assetName">The asset name. This is only used for tracking purposes and has no effect on the patch helper.</param> + /// <inheritdoc /> public IAssetData GetPatchHelper<T>(T data, string assetName = null) { if (data == null) diff --git a/src/SMAPI/Framework/ModHelpers/ContentPackHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentPackHelper.cs index acdd82a0..d39abc7d 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentPackHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentPackHelper.cs @@ -32,27 +32,20 @@ namespace StardewModdingAPI.Framework.ModHelpers this.CreateContentPack = createContentPack; } - /// <summary>Get all content packs loaded for this mod.</summary> + /// <inheritdoc /> public IEnumerable<IContentPack> GetOwned() { return this.ContentPacks.Value; } - /// <summary>Create a temporary content pack to read files from a directory, using randomized manifest fields. This will generate fake manifest data; any <c>manifest.json</c> in the directory will be ignored. Temporary content packs will not appear in the SMAPI log and update checks will not be performed.</summary> - /// <param name="directoryPath">The absolute directory path containing the content pack files.</param> + /// <inheritdoc /> public IContentPack CreateFake(string directoryPath) { string id = Guid.NewGuid().ToString("N"); return this.CreateTemporary(directoryPath, id, id, id, id, new SemanticVersion(1, 0, 0)); } - /// <summary>Create a temporary content pack to read files from a directory. Temporary content packs will not appear in the SMAPI log and update checks will not be performed.</summary> - /// <param name="directoryPath">The absolute directory path containing the content pack files.</param> - /// <param name="id">The content pack's unique ID.</param> - /// <param name="name">The content pack name.</param> - /// <param name="description">The content pack description.</param> - /// <param name="author">The content pack author's name.</param> - /// <param name="version">The content pack version.</param> + /// <inheritdoc /> public IContentPack CreateTemporary(string directoryPath, string id, string name, string description, string author, ISemanticVersion version) { // validate diff --git a/src/SMAPI/Framework/ModHelpers/DataHelper.cs b/src/SMAPI/Framework/ModHelpers/DataHelper.cs index 6cde849c..c232a6dd 100644 --- a/src/SMAPI/Framework/ModHelpers/DataHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/DataHelper.cs @@ -39,11 +39,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /**** ** JSON file ****/ - /// <summary>Read data from a JSON file in the mod's folder.</summary> - /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> - /// <param name="path">The file path relative to the mod folder.</param> - /// <returns>Returns the deserialized model, or <c>null</c> if the file doesn't exist or is empty.</returns> - /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> + /// <inheritdoc /> public TModel ReadJsonFile<TModel>(string path) where TModel : class { if (!PathUtilities.IsSafeRelativePath(path)) @@ -55,11 +51,7 @@ namespace StardewModdingAPI.Framework.ModHelpers : null; } - /// <summary>Save data to a JSON file in the mod's folder.</summary> - /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> - /// <param name="path">The file path relative to the mod folder.</param> - /// <param name="data">The arbitrary data to save.</param> - /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> + /// <inheritdoc /> public void WriteJsonFile<TModel>(string path, TModel data) where TModel : class { if (!PathUtilities.IsSafeRelativePath(path)) @@ -72,11 +64,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /**** ** Save file ****/ - /// <summary>Read arbitrary data stored in the current save slot. This is only possible if a save has been loaded.</summary> - /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> - /// <param name="key">The unique key identifying the data.</param> - /// <returns>Returns the parsed data, or <c>null</c> if the entry doesn't exist or is empty.</returns> - /// <exception cref="InvalidOperationException">The player hasn't loaded a save file yet or isn't the main player.</exception> + /// <inheritdoc /> public TModel ReadSaveData<TModel>(string key) where TModel : class { if (Context.LoadStage == LoadStage.None) @@ -94,11 +82,7 @@ namespace StardewModdingAPI.Framework.ModHelpers return null; } - /// <summary>Save arbitrary data to the current save slot. This is only possible if a save has been loaded, and the data will be lost if the player exits without saving the current day.</summary> - /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> - /// <param name="key">The unique key identifying the data.</param> - /// <param name="model">The arbitrary data to save.</param> - /// <exception cref="InvalidOperationException">The player hasn't loaded a save file yet or isn't the main player.</exception> + /// <inheritdoc /> public void WriteSaveData<TModel>(string key, TModel model) where TModel : class { if (Context.LoadStage == LoadStage.None) @@ -123,10 +107,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /**** ** Global app data ****/ - /// <summary>Read arbitrary data stored on the local computer, synchronised by GOG/Steam if applicable.</summary> - /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> - /// <param name="key">The unique key identifying the data.</param> - /// <returns>Returns the parsed data, or <c>null</c> if the entry doesn't exist or is empty.</returns> + /// <inheritdoc /> public TModel ReadGlobalData<TModel>(string key) where TModel : class { string path = this.GetGlobalDataPath(key); @@ -135,10 +116,7 @@ namespace StardewModdingAPI.Framework.ModHelpers : null; } - /// <summary>Save arbitrary data to the local computer, synchronised by GOG/Steam if applicable.</summary> - /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> - /// <param name="key">The unique key identifying the data.</param> - /// <param name="data">The arbitrary data to save.</param> + /// <inheritdoc /> public void WriteGlobalData<TModel>(string key, TModel data) where TModel : class { string path = this.GetGlobalDataPath(key); diff --git a/src/SMAPI/Framework/ModHelpers/InputHelper.cs b/src/SMAPI/Framework/ModHelpers/InputHelper.cs index 134ba8d1..09ce3c65 100644 --- a/src/SMAPI/Framework/ModHelpers/InputHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/InputHelper.cs @@ -24,35 +24,31 @@ namespace StardewModdingAPI.Framework.ModHelpers this.InputState = inputState; } - /// <summary>Get the current cursor position.</summary> + /// <inheritdoc /> public ICursorPosition GetCursorPosition() { return this.InputState.CursorPosition; } - /// <summary>Get whether a button is currently pressed.</summary> - /// <param name="button">The button.</param> + /// <inheritdoc /> public bool IsDown(SButton button) { return this.InputState.IsDown(button); } - /// <summary>Get whether a button is currently suppressed, so the game won't see it.</summary> - /// <param name="button">The button.</param> + /// <inheritdoc /> public bool IsSuppressed(SButton button) { return this.InputState.IsSuppressed(button); } - /// <summary>Prevent the game from handling a button press. This doesn't prevent other mods from receiving the event.</summary> - /// <param name="button">The button to suppress.</param> + /// <inheritdoc /> public void Suppress(SButton button) { this.InputState.OverrideButton(button, setDown: false); } - /// <summary>Get the state of a button.</summary> - /// <param name="button">The button to check.</param> + /// <inheritdoc /> public SButtonState GetState(SButton button) { return this.InputState.GetState(button); diff --git a/src/SMAPI/Framework/ModHelpers/ModHelper.cs b/src/SMAPI/Framework/ModHelpers/ModHelper.cs index 25401e23..d9fc8621 100644 --- a/src/SMAPI/Framework/ModHelpers/ModHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModHelper.cs @@ -2,7 +2,6 @@ using System; using System.IO; using StardewModdingAPI.Events; using StardewModdingAPI.Framework.Input; -using StardewModdingAPI.Toolkit.Serialization; namespace StardewModdingAPI.Framework.ModHelpers { @@ -12,37 +11,37 @@ namespace StardewModdingAPI.Framework.ModHelpers /********* ** Accessors *********/ - /// <summary>The full path to the mod's folder.</summary> + /// <inheritdoc /> public string DirectoryPath { get; } - /// <summary>Manages access to events raised by SMAPI, which let your mod react when something happens in the game.</summary> + /// <inheritdoc /> public IModEvents Events { get; } - /// <summary>An API for loading content assets.</summary> + /// <inheritdoc /> public IContentHelper Content { get; } - /// <summary>An API for managing content packs.</summary> + /// <inheritdoc /> public IContentPackHelper ContentPacks { get; } - /// <summary>An API for reading and writing persistent mod data.</summary> + /// <inheritdoc /> public IDataHelper Data { get; } - /// <summary>An API for checking and changing input state.</summary> + /// <inheritdoc /> public IInputHelper Input { get; } - /// <summary>An API for accessing private game code.</summary> + /// <inheritdoc /> public IReflectionHelper Reflection { get; } - /// <summary>an API for fetching metadata about loaded mods.</summary> + /// <inheritdoc /> public IModRegistry ModRegistry { get; } - /// <summary>An API for managing console commands.</summary> + /// <inheritdoc /> public ICommandHelper ConsoleCommands { get; } - /// <summary>Provides multiplayer utilities.</summary> + /// <inheritdoc /> public IMultiplayerHelper Multiplayer { get; } - /// <summary>An API for reading translations stored in the mod's <c>i18n</c> folder, with one file per locale (like <c>en.json</c>) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like <c>pt-BR.json</c> < <c>pt.json</c> < <c>default.json</c>).</summary> + /// <inheritdoc /> public ITranslationHelper Translation { get; } @@ -90,8 +89,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /**** ** Mod config file ****/ - /// <summary>Read the mod's configuration file (and create it if needed).</summary> - /// <typeparam name="TConfig">The config class type. This should be a plain class that has public properties for the settings you want. These can be complex types.</typeparam> + /// <inheritdoc /> public TConfig ReadConfig<TConfig>() where TConfig : class, new() { @@ -100,9 +98,7 @@ namespace StardewModdingAPI.Framework.ModHelpers return config; } - /// <summary>Save to the mod's configuration file.</summary> - /// <typeparam name="TConfig">The config class type.</typeparam> - /// <param name="config">The config settings to save.</param> + /// <inheritdoc /> public void WriteConfig<TConfig>(TConfig config) where TConfig : class, new() { @@ -112,7 +108,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /**** ** Disposal ****/ - /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + /// <inheritdoc /> public void Dispose() { // nothing to dispose yet diff --git a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs index f42cb085..ef1ad30c 100644 --- a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs @@ -38,28 +38,25 @@ namespace StardewModdingAPI.Framework.ModHelpers this.Monitor = monitor; } - /// <summary>Get metadata for all loaded mods.</summary> + /// <inheritdoc /> public IEnumerable<IModInfo> GetAll() { return this.Registry.GetAll(); } - /// <summary>Get metadata for a loaded mod.</summary> - /// <param name="uniqueID">The mod's unique ID.</param> - /// <returns>Returns the matching mod's metadata, or <c>null</c> if not found.</returns> + /// <inheritdoc /> public IModInfo Get(string uniqueID) { return this.Registry.Get(uniqueID); } - /// <summary>Get whether a mod has been loaded.</summary> - /// <param name="uniqueID">The mod's unique ID.</param> + /// <inheritdoc /> public bool IsLoaded(string uniqueID) { return this.Registry.Get(uniqueID) != null; } - /// <summary>Get the API provided by a mod, or <c>null</c> if it has none. This signature requires using the <see cref="IModHelper.Reflection"/> API to access the API's properties and methods.</summary> + /// <inheritdoc /> public object GetApi(string uniqueID) { // validate ready @@ -76,9 +73,7 @@ namespace StardewModdingAPI.Framework.ModHelpers return mod?.Api; } - /// <summary>Get the API provided by a mod, mapped to a given interface which specifies the expected properties and methods. If the mod has no API or it's not compatible with the given interface, get <c>null</c>.</summary> - /// <typeparam name="TInterface">The interface which matches the properties and methods you intend to access.</typeparam> - /// <param name="uniqueID">The mod's unique ID.</param> + /// <inheritdoc /> public TInterface GetApi<TInterface>(string uniqueID) where TInterface : class { // get raw API diff --git a/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs b/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs index c62dd121..a7ce8692 100644 --- a/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using StardewModdingAPI.Framework.Networking; using StardewValley; @@ -27,21 +26,19 @@ namespace StardewModdingAPI.Framework.ModHelpers this.Multiplayer = multiplayer; } - /// <summary>Get a new multiplayer ID.</summary> + /// <inheritdoc /> public long GetNewID() { return this.Multiplayer.getNewID(); } - /// <summary>Get the locations which are being actively synced from the host.</summary> + /// <inheritdoc /> public IEnumerable<GameLocation> GetActiveLocations() { return this.Multiplayer.activeLocations(); } - /// <summary>Get a connected player.</summary> - /// <param name="id">The player's unique ID.</param> - /// <returns>Returns the connected player, or <c>null</c> if no such player is connected.</returns> + /// <inheritdoc /> public IMultiplayerPeer GetConnectedPlayer(long id) { return this.Multiplayer.Peers.TryGetValue(id, out MultiplayerPeer peer) @@ -49,19 +46,13 @@ namespace StardewModdingAPI.Framework.ModHelpers : null; } - /// <summary>Get all connected players.</summary> + /// <inheritdoc /> public IEnumerable<IMultiplayerPeer> GetConnectedPlayers() { return this.Multiplayer.Peers.Values; } - /// <summary>Send a message to mods installed by connected players.</summary> - /// <typeparam name="TMessage">The data type. This can be a class with a default constructor, or a value type.</typeparam> - /// <param name="message">The data to send over the network.</param> - /// <param name="messageType">A message type which receiving mods can use to decide whether it's the one they want to handle, like <c>SetPlayerLocation</c>. This doesn't need to be globally unique, since mods should check the originating mod ID.</param> - /// <param name="modIDs">The mod IDs which should receive the message on the destination computers, or <c>null</c> for all mods. Specifying mod IDs is recommended to improve performance, unless it's a general-purpose broadcast.</param> - /// <param name="playerIDs">The <see cref="Farmer.UniqueMultiplayerID" /> values for the players who should receive the message, or <c>null</c> for all players. If you don't need to broadcast to all players, specifying player IDs is recommended to reduce latency.</param> - /// <exception cref="ArgumentNullException">The <paramref name="message"/> or <paramref name="messageType" /> is null.</exception> + /// <inheritdoc /> public void SendMessage<TMessage>(TMessage message, string messageType, string[] modIDs = null, long[] playerIDs = null) { this.Multiplayer.BroadcastModMessage( diff --git a/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs b/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs index 916c215d..5a4ea742 100644 --- a/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs @@ -32,11 +32,7 @@ namespace StardewModdingAPI.Framework.ModHelpers this.Reflector = reflector; } - /// <summary>Get an instance field.</summary> - /// <typeparam name="TValue">The field type.</typeparam> - /// <param name="obj">The object which has the field.</param> - /// <param name="name">The field name.</param> - /// <param name="required">Whether to throw an exception if the field is not found.</param> + /// <inheritdoc /> public IReflectedField<TValue> GetField<TValue>(object obj, string name, bool required = true) { return this.AssertAccessAllowed( @@ -44,11 +40,7 @@ namespace StardewModdingAPI.Framework.ModHelpers ); } - /// <summary>Get a static field.</summary> - /// <typeparam name="TValue">The field type.</typeparam> - /// <param name="type">The type which has the field.</param> - /// <param name="name">The field name.</param> - /// <param name="required">Whether to throw an exception if the field is not found.</param> + /// <inheritdoc /> public IReflectedField<TValue> GetField<TValue>(Type type, string name, bool required = true) { return this.AssertAccessAllowed( @@ -56,11 +48,7 @@ namespace StardewModdingAPI.Framework.ModHelpers ); } - /// <summary>Get an instance property.</summary> - /// <typeparam name="TValue">The property type.</typeparam> - /// <param name="obj">The object which has the property.</param> - /// <param name="name">The property name.</param> - /// <param name="required">Whether to throw an exception if the property is not found.</param> + /// <inheritdoc /> public IReflectedProperty<TValue> GetProperty<TValue>(object obj, string name, bool required = true) { return this.AssertAccessAllowed( @@ -68,11 +56,7 @@ namespace StardewModdingAPI.Framework.ModHelpers ); } - /// <summary>Get a static property.</summary> - /// <typeparam name="TValue">The property type.</typeparam> - /// <param name="type">The type which has the property.</param> - /// <param name="name">The property name.</param> - /// <param name="required">Whether to throw an exception if the property is not found.</param> + /// <inheritdoc /> public IReflectedProperty<TValue> GetProperty<TValue>(Type type, string name, bool required = true) { return this.AssertAccessAllowed( @@ -80,10 +64,7 @@ namespace StardewModdingAPI.Framework.ModHelpers ); } - /// <summary>Get an instance method.</summary> - /// <param name="obj">The object which has the method.</param> - /// <param name="name">The field name.</param> - /// <param name="required">Whether to throw an exception if the field is not found.</param> + /// <inheritdoc /> public IReflectedMethod GetMethod(object obj, string name, bool required = true) { return this.AssertAccessAllowed( @@ -91,10 +72,7 @@ namespace StardewModdingAPI.Framework.ModHelpers ); } - /// <summary>Get a static method.</summary> - /// <param name="type">The type which has the method.</param> - /// <param name="name">The field name.</param> - /// <param name="required">Whether to throw an exception if the field is not found.</param> + /// <inheritdoc /> public IReflectedMethod GetMethod(Type type, string name, bool required = true) { return this.AssertAccessAllowed( diff --git a/src/SMAPI/Framework/ModHelpers/TranslationHelper.cs b/src/SMAPI/Framework/ModHelpers/TranslationHelper.cs index be7768e8..a88ca9c9 100644 --- a/src/SMAPI/Framework/ModHelpers/TranslationHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/TranslationHelper.cs @@ -16,10 +16,10 @@ namespace StardewModdingAPI.Framework.ModHelpers /********* ** Accessors *********/ - /// <summary>The current locale.</summary> + /// <inheritdoc /> public string Locale => this.Translator.Locale; - /// <summary>The game's current language code.</summary> + /// <inheritdoc /> public LocalizedContentManager.LanguageCode LocaleEnum => this.Translator.LocaleEnum; @@ -37,22 +37,19 @@ namespace StardewModdingAPI.Framework.ModHelpers this.Translator.SetLocale(locale, languageCode); } - /// <summary>Get all translations for the current locale.</summary> + /// <inheritdoc /> public IEnumerable<Translation> GetTranslations() { return this.Translator.GetTranslations(); } - /// <summary>Get a translation for the current locale.</summary> - /// <param name="key">The translation key.</param> + /// <inheritdoc /> public Translation Get(string key) { return this.Translator.Get(key); } - /// <summary>Get a translation for the current locale.</summary> - /// <param name="key">The translation key.</param> - /// <param name="tokens">An object containing token key/value pairs. This can be an anonymous object (like <c>new { value = 42, name = "Cranberries" }</c>), a dictionary, or a class instance.</param> + /// <inheritdoc /> public Translation Get(string key, object tokens) { return this.Translator.Get(key, tokens); diff --git a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs index f8c901e0..9fb5384e 100644 --- a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs +++ b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs @@ -76,10 +76,9 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="mod">The mod for which the assembly is being loaded.</param> /// <param name="assemblyPath">The assembly file path.</param> /// <param name="assumeCompatible">Assume the mod is compatible, even if incompatible code is detected.</param> - /// <param name="rewriteInParallel">Whether to enable experimental parallel rewriting.</param> /// <returns>Returns the rewrite metadata for the preprocessed assembly.</returns> /// <exception cref="IncompatibleInstructionException">An incompatible CIL instruction was found while rewriting the assembly.</exception> - public Assembly Load(IModMetadata mod, string assemblyPath, bool assumeCompatible, bool rewriteInParallel) + public Assembly Load(IModMetadata mod, string assemblyPath, bool assumeCompatible) { // get referenced local assemblies AssemblyParseResult[] assemblies; @@ -109,7 +108,7 @@ namespace StardewModdingAPI.Framework.ModLoading continue; // rewrite assembly - bool changed = this.RewriteAssembly(mod, assembly.Definition, loggedMessages, logPrefix: " ", rewriteInParallel); + bool changed = this.RewriteAssembly(mod, assembly.Definition, loggedMessages, logPrefix: " "); // detect broken assembly reference foreach (AssemblyNameReference reference in assembly.Definition.MainModule.AssemblyReferences) @@ -263,10 +262,9 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="assembly">The assembly to rewrite.</param> /// <param name="loggedMessages">The messages that have already been logged for this mod.</param> /// <param name="logPrefix">A string to prefix to log messages.</param> - /// <param name="rewriteInParallel">Whether to enable experimental parallel rewriting.</param> /// <returns>Returns whether the assembly was modified.</returns> /// <exception cref="IncompatibleInstructionException">An incompatible CIL instruction was found while rewriting the assembly.</exception> - private bool RewriteAssembly(IModMetadata mod, AssemblyDefinition assembly, HashSet<string> loggedMessages, string logPrefix, bool rewriteInParallel) + private bool RewriteAssembly(IModMetadata mod, AssemblyDefinition assembly, HashSet<string> loggedMessages, string logPrefix) { ModuleDefinition module = assembly.MainModule; string filename = $"{assembly.Name.Name}.dll"; @@ -294,6 +292,19 @@ namespace StardewModdingAPI.Framework.ModLoading IEnumerable<TypeReference> typeReferences = module.GetTypeReferences().OrderBy(p => p.FullName); foreach (TypeReference type in typeReferences) this.ChangeTypeScope(type); + + // rewrite types using custom attributes + foreach (TypeDefinition type in module.GetTypes()) + { + foreach (var attr in type.CustomAttributes) + { + foreach (var conField in attr.ConstructorArguments) + { + if (conField.Value is TypeReference typeRef) + this.ChangeTypeScope(typeRef); + } + } + } } // find or rewrite code @@ -307,15 +318,15 @@ namespace StardewModdingAPI.Framework.ModLoading rewritten |= handler.Handle(module, type, replaceWith); return rewritten; }, - rewriteInstruction: (ref Instruction instruction, ILProcessor cil, Action<Instruction> replaceWith) => + rewriteInstruction: (ref Instruction instruction, ILProcessor cil) => { bool rewritten = false; foreach (IInstructionHandler handler in handlers) - rewritten |= handler.Handle(module, cil, instruction, replaceWith); + rewritten |= handler.Handle(module, cil, instruction); return rewritten; } ); - bool anyRewritten = rewriter.RewriteModule(rewriteInParallel); + bool anyRewritten = rewriter.RewriteModule(); // handle rewrite flags foreach (IInstructionHandler handler in handlers) @@ -398,10 +409,10 @@ namespace StardewModdingAPI.Framework.ModLoading if (handler.Phrases.Any()) { foreach (string message in handler.Phrases) - this.Monitor.LogOnce(template.Replace("$phrase", message)); + this.Monitor.LogOnce(loggedMessages, template.Replace("$phrase", message)); } else - this.Monitor.LogOnce(template.Replace("$phrase", handler.DefaultPhrase ?? handler.GetType().Name)); + this.Monitor.LogOnce(loggedMessages, template.Replace("$phrase", handler.DefaultPhrase ?? handler.GetType().Name)); } /// <summary>Get the correct reference to use for compatibility with the current platform.</summary> diff --git a/src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs index e1476b73..01ed153b 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs @@ -1,4 +1,3 @@ -using System; using Mono.Cecil; using Mono.Cecil.Cil; using StardewModdingAPI.Framework.ModLoading.Framework; @@ -36,13 +35,8 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders this.Result = result; } - /// <summary>Rewrite a CIL instruction reference if needed.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The CIL instruction to handle.</param> - /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> - /// <returns>Returns whether the instruction was changed.</returns> - public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) + /// <inheritdoc /> + public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction) { if (!this.Flags.Contains(this.Result) && this.IsMatch(instruction)) this.MarkFlag(this.Result); diff --git a/src/SMAPI/Framework/ModLoading/Finders/FieldFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/FieldFinder.cs index c157ed9b..2c062243 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/FieldFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/FieldFinder.cs @@ -1,4 +1,3 @@ -using System; using Mono.Cecil; using Mono.Cecil.Cil; using StardewModdingAPI.Framework.ModLoading.Framework; @@ -36,13 +35,8 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders this.Result = result; } - /// <summary>Rewrite a CIL instruction reference if needed.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The CIL instruction to handle.</param> - /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> - /// <returns>Returns whether the instruction was changed.</returns> - public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) + /// <inheritdoc /> + public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction) { if (!this.Flags.Contains(this.Result) && RewriteHelper.IsFieldReferenceTo(instruction, this.FullTypeName, this.FieldName)) this.MarkFlag(this.Result); diff --git a/src/SMAPI/Framework/ModLoading/Finders/MethodFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/MethodFinder.cs index 82c93a7c..d2340f01 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/MethodFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/MethodFinder.cs @@ -1,4 +1,3 @@ -using System; using Mono.Cecil; using Mono.Cecil.Cil; using StardewModdingAPI.Framework.ModLoading.Framework; @@ -36,13 +35,8 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders this.Result = result; } - /// <summary>Rewrite a CIL instruction reference if needed.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The CIL instruction to handle.</param> - /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> - /// <returns>Returns whether the instruction was changed.</returns> - public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) + /// <inheritdoc /> + public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction) { if (!this.Flags.Contains(this.Result) && this.IsMatch(instruction)) this.MarkFlag(this.Result); diff --git a/src/SMAPI/Framework/ModLoading/Finders/PropertyFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/PropertyFinder.cs index c96d61a2..99344848 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/PropertyFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/PropertyFinder.cs @@ -1,4 +1,3 @@ -using System; using Mono.Cecil; using Mono.Cecil.Cil; using StardewModdingAPI.Framework.ModLoading.Framework; @@ -36,13 +35,8 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders this.Result = result; } - /// <summary>Rewrite a CIL instruction reference if needed.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The CIL instruction to handle.</param> - /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> - /// <returns>Returns whether the instruction was changed.</returns> - public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) + /// <inheritdoc /> + public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction) { if (!this.Flags.Contains(this.Result) && this.IsMatch(instruction)) this.MarkFlag(this.Result); diff --git a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs index a67cfa4f..b01a3240 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Linq; using Mono.Cecil; @@ -29,13 +28,8 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders this.ValidateReferencesToAssemblies = new HashSet<string>(validateReferencesToAssemblies); } - /// <summary>Rewrite a CIL instruction reference if needed.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The CIL instruction to handle.</param> - /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> - /// <returns>Returns whether the instruction was changed.</returns> - public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) + /// <inheritdoc /> + public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction) { // field reference FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); diff --git a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs index ebb62948..b64a255e 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Generic; -using System.Linq; using Mono.Cecil; using Mono.Cecil.Cil; using StardewModdingAPI.Framework.ModLoading.Framework; @@ -29,20 +27,15 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders this.ValidateReferencesToAssemblies = new HashSet<string>(validateReferencesToAssemblies); } - /// <summary>Rewrite a CIL instruction reference if needed.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The CIL instruction to handle.</param> - /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> - /// <returns>Returns whether the instruction was changed.</returns> - public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) + /// <inheritdoc /> + public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction) { // field reference FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); if (fieldRef != null && this.ShouldValidate(fieldRef.DeclaringType)) { - FieldDefinition target = fieldRef.DeclaringType.Resolve()?.Fields.FirstOrDefault(p => p.Name == fieldRef.Name); - if (target == null) + FieldDefinition target = fieldRef.Resolve(); + if (target == null || target.HasConstant) { this.MarkFlag(InstructionHandleResult.NotCompatible, $"reference to {fieldRef.DeclaringType.FullName}.{fieldRef.Name} (no such field)"); return false; @@ -56,7 +49,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders MethodDefinition target = methodRef.Resolve(); if (target == null) { - string phrase = null; + string phrase; if (this.IsProperty(methodRef)) phrase = $"reference to {methodRef.DeclaringType.FullName}.{methodRef.Name.Substring(4)} (no such property)"; else if (methodRef.Name == ".ctor") diff --git a/src/SMAPI/Framework/ModLoading/Finders/TypeAssemblyFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/TypeAssemblyFinder.cs index a1ade536..24ab2eca 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/TypeAssemblyFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/TypeAssemblyFinder.cs @@ -35,11 +35,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders this.ShouldIgnore = shouldIgnore; } - /// <summary>Rewrite a type reference if needed.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="type">The type definition to handle.</param> - /// <param name="replaceWith">Replaces the type reference with a new one.</param> - /// <returns>Returns whether the type was changed.</returns> + /// <inheritdoc /> public override bool Handle(ModuleDefinition module, TypeReference type, Action<TypeReference> replaceWith) { if (type.Scope.Name == this.AssemblyName && this.ShouldIgnore?.Invoke(type) != true) diff --git a/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs index c285414a..bbd081e8 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs @@ -35,11 +35,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders this.ShouldIgnore = shouldIgnore; } - /// <summary>Rewrite a type reference if needed.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="type">The type definition to handle.</param> - /// <param name="replaceWith">Replaces the type reference with a new one.</param> - /// <returns>Returns whether the type was changed.</returns> + /// <inheritdoc /> public override bool Handle(ModuleDefinition module, TypeReference type, Action<TypeReference> replaceWith) { if (type.FullName == this.FullTypeName && this.ShouldIgnore?.Invoke(type) != true) diff --git a/src/SMAPI/Framework/ModLoading/Framework/BaseInstructionHandler.cs b/src/SMAPI/Framework/ModLoading/Framework/BaseInstructionHandler.cs index fde37d68..624113b3 100644 --- a/src/SMAPI/Framework/ModLoading/Framework/BaseInstructionHandler.cs +++ b/src/SMAPI/Framework/ModLoading/Framework/BaseInstructionHandler.cs @@ -11,36 +11,27 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework /********* ** Accessors *********/ - /// <summary>A brief noun phrase indicating what the handler matches, used if <see cref="Phrases"/> is empty.</summary> + /// <inheritdoc /> public string DefaultPhrase { get; } - /// <summary>The rewrite flags raised for the current module.</summary> + /// <inheritdoc /> public ISet<InstructionHandleResult> Flags { get; } = new HashSet<InstructionHandleResult>(); - /// <summary>The brief noun phrases indicating what the handler matched for the current module.</summary> + /// <inheritdoc /> public ISet<string> Phrases { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase); /********* ** Public methods *********/ - /// <summary>Rewrite a type reference if needed.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="type">The type definition to handle.</param> - /// <param name="replaceWith">Replaces the type reference with a new one.</param> - /// <returns>Returns whether the type was changed.</returns> + /// <inheritdoc /> public virtual bool Handle(ModuleDefinition module, TypeReference type, Action<TypeReference> replaceWith) { return false; } - /// <summary>Rewrite a CIL instruction reference if needed.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The CIL instruction to handle.</param> - /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> - /// <returns>Returns whether the instruction was changed.</returns> - public virtual bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) + /// <inheritdoc /> + public virtual bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction) { return false; } @@ -50,7 +41,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework ** Protected methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="defaultPhrase">A brief noun phrase indicating what the handler matches.</param> + /// <param name="defaultPhrase">A brief noun phrase indicating what the handler matches, used if <see cref="Phrases"/> is empty.</param> protected BaseInstructionHandler(string defaultPhrase) { this.DefaultPhrase = defaultPhrase; diff --git a/src/SMAPI/Framework/ModLoading/Framework/RecursiveRewriter.cs b/src/SMAPI/Framework/ModLoading/Framework/RecursiveRewriter.cs index 34c78c7d..ea29550a 100644 --- a/src/SMAPI/Framework/ModLoading/Framework/RecursiveRewriter.cs +++ b/src/SMAPI/Framework/ModLoading/Framework/RecursiveRewriter.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Threading; -using System.Threading.Tasks; using Mono.Cecil; using Mono.Cecil.Cil; using Mono.Collections.Generic; @@ -24,9 +22,8 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework /// <summary>Rewrite a CIL instruction in the assembly code.</summary> /// <param name="instruction">The current CIL instruction.</param> /// <param name="cil">The CIL instruction processor.</param> - /// <param name="replaceWith">Replaces the CIL instruction with the given instruction.</param> /// <returns>Returns whether the instruction was changed.</returns> - public delegate bool RewriteInstructionDelegate(ref Instruction instruction, ILProcessor cil, Action<Instruction> replaceWith); + public delegate bool RewriteInstructionDelegate(ref Instruction instruction, ILProcessor cil); /********* @@ -57,59 +54,24 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework } /// <summary>Rewrite the loaded module code.</summary> - /// <param name="rewriteInParallel">Whether to enable experimental parallel rewriting.</param> /// <returns>Returns whether the module was modified.</returns> - public bool RewriteModule(bool rewriteInParallel) + public bool RewriteModule() { IEnumerable<TypeDefinition> types = this.Module.GetTypes().Where(type => type.BaseType != null); // skip special types like <Module> - // experimental parallel rewriting - // This may cause intermittent startup errors and is disabled by default: https://github.com/Pathoschild/SMAPI/issues/721 - if (rewriteInParallel) - { - int typesChanged = 0; - Exception exception = null; - - Parallel.ForEach(types, type => - { - if (exception != null) - return; - - bool changed = false; - try - { - changed = this.RewriteTypeDefinition(type); - } - catch (Exception ex) - { - exception ??= ex; - } - - if (changed) - Interlocked.Increment(ref typesChanged); - }); + bool changed = false; - return exception == null - ? typesChanged > 0 - : throw new Exception($"Rewriting {this.Module.Name} failed.", exception); + try + { + foreach (var type in types) + changed |= this.RewriteTypeDefinition(type); } - - // non-parallel rewriting + catch (Exception ex) { - bool changed = false; - - try - { - foreach (var type in types) - changed |= this.RewriteTypeDefinition(type); - } - catch (Exception ex) - { - throw new Exception($"Rewriting {this.Module.Name} failed.", ex); - } - - return changed; + throw new Exception($"Rewriting {this.Module.Name} failed.", ex); } + + return changed; } @@ -198,12 +160,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework // instruction itself // (should be done after the above type rewrites to ensure valid types) - rewritten |= this.RewriteInstructionImpl(ref instruction, cil, newInstruction => - { - rewritten = true; - cil.Replace(instruction, newInstruction); - instruction = newInstruction; - }); + rewritten |= this.RewriteInstructionImpl(ref instruction, cil); return rewritten; } diff --git a/src/SMAPI/Framework/ModLoading/Framework/RewriteHelper.cs b/src/SMAPI/Framework/ModLoading/Framework/RewriteHelper.cs index 36058b86..207b6445 100644 --- a/src/SMAPI/Framework/ModLoading/Framework/RewriteHelper.cs +++ b/src/SMAPI/Framework/ModLoading/Framework/RewriteHelper.cs @@ -59,12 +59,30 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework : null; } + /// <summary>Get the CIL instruction to load a value onto the stack.</summary> + /// <param name="rawValue">The constant value to inject.</param> + /// <returns>Returns the instruction, or <c>null</c> if the value type isn't supported.</returns> + public static Instruction GetLoadValueInstruction(object rawValue) + { + return rawValue switch + { + null => Instruction.Create(OpCodes.Ldnull), + bool value => Instruction.Create(value ? OpCodes.Ldc_I4_1 : OpCodes.Ldc_I4_0), + int value => Instruction.Create(OpCodes.Ldc_I4, value), // int32 + long value => Instruction.Create(OpCodes.Ldc_I8, value), // int64 + float value => Instruction.Create(OpCodes.Ldc_R4, value), // float32 + double value => Instruction.Create(OpCodes.Ldc_R8, value), // float64 + string value => Instruction.Create(OpCodes.Ldstr, value), + _ => null + }; + } + /// <summary>Get whether a type matches a type reference.</summary> /// <param name="type">The defined type.</param> /// <param name="reference">The type reference.</param> public static bool IsSameType(Type type, TypeReference reference) { - // + // // duplicated by IsSameType(TypeReference, TypeReference) below // @@ -139,7 +157,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework /// <param name="reference">The method reference.</param> public static bool HasMatchingSignature(MethodBase definition, MethodReference reference) { - // + // // duplicated by HasMatchingSignature(MethodDefinition, MethodReference) below // @@ -165,7 +183,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework /// <param name="reference">The method reference.</param> public static bool HasMatchingSignature(MethodDefinition definition, MethodReference reference) { - // + // // duplicated by HasMatchingSignature(MethodBase, MethodReference) above // diff --git a/src/SMAPI/Framework/ModLoading/IInstructionHandler.cs b/src/SMAPI/Framework/ModLoading/IInstructionHandler.cs index e6de6785..17c9ba68 100644 --- a/src/SMAPI/Framework/ModLoading/IInstructionHandler.cs +++ b/src/SMAPI/Framework/ModLoading/IInstructionHandler.cs @@ -35,8 +35,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="module">The assembly module containing the instruction.</param> /// <param name="cil">The CIL processor.</param> /// <param name="instruction">The CIL instruction to handle.</param> - /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> /// <returns>Returns whether the instruction was changed.</returns> - bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith); + bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction); } } diff --git a/src/SMAPI/Framework/ModLoading/ModFailReason.cs b/src/SMAPI/Framework/ModLoading/ModFailReason.cs new file mode 100644 index 00000000..cd4623e7 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/ModFailReason.cs @@ -0,0 +1,27 @@ +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>Indicates why a mod could not be loaded.</summary> + internal enum ModFailReason + { + /// <summary>The mod has been disabled by prefixing its folder with a dot.</summary> + DisabledByDotConvention, + + /// <summary>Multiple copies of the mod are installed.</summary> + Duplicate, + + /// <summary>The mod has incompatible code instructions, needs a newer SMAPI version, or is marked 'assume broken' in the SMAPI metadata list.</summary> + Incompatible, + + /// <summary>The mod's manifest is missing or invalid.</summary> + InvalidManifest, + + /// <summary>The mod was deemed compatible, but SMAPI failed when it tried to load it.</summary> + LoadFailed, + + /// <summary>The mod requires other mods which aren't installed, or its dependencies have a circular reference.</summary> + MissingDependencies, + + /// <summary>The mod is marked obsolete in the SMAPI metadata list.</summary> + Obsolete + } +} diff --git a/src/SMAPI/Framework/ModLoading/ModMetadata.cs b/src/SMAPI/Framework/ModLoading/ModMetadata.cs index 3ad1bd38..18d2b112 100644 --- a/src/SMAPI/Framework/ModLoading/ModMetadata.cs +++ b/src/SMAPI/Framework/ModLoading/ModMetadata.cs @@ -16,55 +16,61 @@ namespace StardewModdingAPI.Framework.ModLoading /********* ** Accessors *********/ - /// <summary>The mod's display name.</summary> + /// <inheritdoc /> public string DisplayName { get; } - /// <summary>The root path containing mods.</summary> + /// <inheritdoc /> public string RootPath { get; } - /// <summary>The mod's full directory path within the <see cref="RootPath"/>.</summary> + /// <inheritdoc /> public string DirectoryPath { get; } - /// <summary>The <see cref="DirectoryPath"/> relative to the <see cref="RootPath"/>.</summary> + /// <inheritdoc /> public string RelativeDirectoryPath { get; } - /// <summary>The mod manifest.</summary> + /// <inheritdoc /> public IManifest Manifest { get; } - /// <summary>Metadata about the mod from SMAPI's internal data (if any).</summary> + /// <inheritdoc /> public ModDataRecordVersionedFields DataRecord { get; } - /// <summary>The metadata resolution status.</summary> + /// <inheritdoc /> public ModMetadataStatus Status { get; private set; } - /// <summary>Indicates non-error issues with the mod.</summary> + /// <inheritdoc /> + public ModFailReason? FailReason { get; private set; } + + /// <inheritdoc /> public ModWarning Warnings { get; private set; } - /// <summary>The reason the metadata is invalid, if any.</summary> + /// <inheritdoc /> public string Error { get; private set; } - /// <summary>Whether the mod folder should be ignored. This is <c>true</c> if it was found within a folder whose name starts with a dot.</summary> + /// <inheritdoc /> + public string ErrorDetails { get; private set; } + + /// <inheritdoc /> public bool IsIgnored { get; } - /// <summary>The mod instance (if loaded and <see cref="IsContentPack"/> is false).</summary> + /// <inheritdoc /> public IMod Mod { get; private set; } - /// <summary>The content pack instance (if loaded and <see cref="IsContentPack"/> is true).</summary> + /// <inheritdoc /> public IContentPack ContentPack { get; private set; } - /// <summary>The translations for this mod (if loaded).</summary> + /// <inheritdoc /> public TranslationHelper Translations { get; private set; } - /// <summary>Writes messages to the console and log file as this mod.</summary> + /// <inheritdoc /> public IMonitor Monitor { get; private set; } - /// <summary>The mod-provided API (if any).</summary> + /// <inheritdoc /> public object Api { get; private set; } - /// <summary>The update-check metadata for this mod (if any).</summary> + /// <inheritdoc /> public ModEntryModel UpdateCheckData { get; private set; } - /// <summary>Whether the mod is a content pack.</summary> + /// <inheritdoc /> public bool IsContentPack => this.Manifest?.ContentPackFor != null; @@ -89,28 +95,32 @@ namespace StardewModdingAPI.Framework.ModLoading this.IsIgnored = isIgnored; } - /// <summary>Set the mod status.</summary> - /// <param name="status">The metadata resolution status.</param> - /// <param name="error">The reason the metadata is invalid, if any.</param> - /// <returns>Return the instance for chaining.</returns> - public IModMetadata SetStatus(ModMetadataStatus status, string error = null) + /// <inheritdoc /> + public IModMetadata SetStatusFound() + { + this.SetStatus(ModMetadataStatus.Found, ModFailReason.Incompatible, null); + this.FailReason = null; + return this; + } + + /// <inheritdoc /> + public IModMetadata SetStatus(ModMetadataStatus status, ModFailReason reason, string error, string errorDetails = null) { this.Status = status; + this.FailReason = reason; this.Error = error; + this.ErrorDetails = errorDetails; return this; } - /// <summary>Set a warning flag for the mod.</summary> - /// <param name="warning">The warning to set.</param> + /// <inheritdoc /> public IModMetadata SetWarning(ModWarning warning) { this.Warnings |= warning; return this; } - /// <summary>Set the mod instance.</summary> - /// <param name="mod">The mod instance to set.</param> - /// <param name="translations">The translations for this mod (if loaded).</param> + /// <inheritdoc /> public IModMetadata SetMod(IMod mod, TranslationHelper translations) { if (this.ContentPack != null) @@ -122,10 +132,7 @@ namespace StardewModdingAPI.Framework.ModLoading return this; } - /// <summary>Set the mod instance.</summary> - /// <param name="contentPack">The contentPack instance to set.</param> - /// <param name="monitor">Writes messages to the console and log file.</param> - /// <param name="translations">The translations for this mod (if loaded).</param> + /// <inheritdoc /> public IModMetadata SetMod(IContentPack contentPack, IMonitor monitor, TranslationHelper translations) { if (this.Mod != null) @@ -137,29 +144,27 @@ namespace StardewModdingAPI.Framework.ModLoading return this; } - /// <summary>Set the mod-provided API instance.</summary> - /// <param name="api">The mod-provided API.</param> + /// <inheritdoc /> public IModMetadata SetApi(object api) { this.Api = api; return this; } - /// <summary>Set the update-check metadata for this mod.</summary> - /// <param name="data">The update-check metadata.</param> + /// <inheritdoc /> public IModMetadata SetUpdateData(ModEntryModel data) { this.UpdateCheckData = data; return this; } - /// <summary>Whether the mod manifest was loaded (regardless of whether the mod itself was loaded).</summary> + /// <inheritdoc /> public bool HasManifest() { return this.Manifest != null; } - /// <summary>Whether the mod has an ID (regardless of whether the ID is valid or the mod itself was loaded).</summary> + /// <inheritdoc /> public bool HasID() { return @@ -167,8 +172,7 @@ namespace StardewModdingAPI.Framework.ModLoading && !string.IsNullOrWhiteSpace(this.Manifest.UniqueID); } - /// <summary>Whether the mod has the given ID.</summary> - /// <param name="id">The mod ID to check.</param> + /// <inheritdoc /> public bool HasID(string id) { return @@ -176,8 +180,7 @@ namespace StardewModdingAPI.Framework.ModLoading && string.Equals(this.Manifest.UniqueID.Trim(), id?.Trim(), StringComparison.OrdinalIgnoreCase); } - /// <summary>Get the defined update keys.</summary> - /// <param name="validOnly">Only return valid update keys.</param> + /// <inheritdoc /> public IEnumerable<UpdateKey> GetUpdateKeys(bool validOnly = false) { foreach (string rawKey in this.Manifest?.UpdateKeys ?? new string[0]) @@ -188,8 +191,7 @@ namespace StardewModdingAPI.Framework.ModLoading } } - /// <summary>Get the mod IDs that must be installed to load this mod.</summary> - /// <param name="includeOptional">Whether to include optional dependencies.</param> + /// <inheritdoc /> public IEnumerable<string> GetRequiredModIds(bool includeOptional = false) { HashSet<string> required = new HashSet<string>(StringComparer.OrdinalIgnoreCase); @@ -209,14 +211,13 @@ namespace StardewModdingAPI.Framework.ModLoading yield return this.Manifest.ContentPackFor.UniqueID; } - /// <summary>Whether the mod has at least one valid update key set.</summary> + /// <inheritdoc /> public bool HasValidUpdateKeys() { return this.GetUpdateKeys(validOnly: true).Any(); } - /// <summary>Get whether the mod has any of the given warnings which haven't been suppressed in the <see cref="IModMetadata.DataRecord"/>.</summary> - /// <param name="warnings">The warnings to check.</param> + /// <inheritdoc /> public bool HasUnsuppressedWarnings(params ModWarning[] warnings) { return warnings.Any(warning => @@ -225,7 +226,7 @@ namespace StardewModdingAPI.Framework.ModLoading ); } - /// <summary>Get a relative path which includes the root folder name.</summary> + /// <inheritdoc /> public string GetRelativePathWithRoot() { string rootFolderName = Path.GetFileName(this.RootPath) ?? ""; diff --git a/src/SMAPI/Framework/ModLoading/ModResolver.cs b/src/SMAPI/Framework/ModLoading/ModResolver.cs index 8bbeb2a3..08df7b76 100644 --- a/src/SMAPI/Framework/ModLoading/ModResolver.cs +++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs @@ -43,8 +43,13 @@ namespace StardewModdingAPI.Framework.ModLoading ? ModMetadataStatus.Found : ModMetadataStatus.Failed; - yield return new ModMetadata(folder.DisplayName, folder.Directory.FullName, rootPath, manifest, dataRecord, isIgnored: shouldIgnore) - .SetStatus(status, shouldIgnore ? "disabled by dot convention" : folder.ManifestParseErrorText); + var metadata = new ModMetadata(folder.DisplayName, folder.Directory.FullName, rootPath, manifest, dataRecord, isIgnored: shouldIgnore); + if (shouldIgnore) + metadata.SetStatus(status, ModFailReason.DisabledByDotConvention, "disabled by dot convention"); + else + metadata.SetStatus(status, ModFailReason.InvalidManifest, folder.ManifestParseErrorText); + + yield return metadata; } } @@ -67,7 +72,7 @@ namespace StardewModdingAPI.Framework.ModLoading switch (mod.DataRecord?.Status) { case ModStatus.Obsolete: - mod.SetStatus(ModMetadataStatus.Failed, $"it's obsolete: {mod.DataRecord.StatusReasonPhrase}"); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.Obsolete, $"it's obsolete: {mod.DataRecord.StatusReasonPhrase}"); continue; case ModStatus.AssumeBroken: @@ -97,7 +102,7 @@ namespace StardewModdingAPI.Framework.ModLoading error += $"version newer than {mod.DataRecord.StatusUpperVersion}"; error += " at " + string.Join(" or ", updateUrls); - mod.SetStatus(ModMetadataStatus.Failed, error); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.Incompatible, error); } continue; } @@ -105,7 +110,7 @@ namespace StardewModdingAPI.Framework.ModLoading // validate SMAPI version if (mod.Manifest.MinimumApiVersion?.IsNewerThan(apiVersion) == true) { - mod.SetStatus(ModMetadataStatus.Failed, $"it needs SMAPI {mod.Manifest.MinimumApiVersion} or later. Please update SMAPI to the latest version to use this mod."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.Incompatible, $"it needs SMAPI {mod.Manifest.MinimumApiVersion} or later. Please update SMAPI to the latest version to use this mod."); continue; } @@ -117,12 +122,12 @@ namespace StardewModdingAPI.Framework.ModLoading // validate field presence if (!hasDll && !isContentPack) { - mod.SetStatus(ModMetadataStatus.Failed, $"its manifest has no {nameof(IManifest.EntryDll)} or {nameof(IManifest.ContentPackFor)} field; must specify one."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has no {nameof(IManifest.EntryDll)} or {nameof(IManifest.ContentPackFor)} field; must specify one."); continue; } if (hasDll && isContentPack) { - mod.SetStatus(ModMetadataStatus.Failed, $"its manifest sets both {nameof(IManifest.EntryDll)} and {nameof(IManifest.ContentPackFor)}, which are mutually exclusive."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest sets both {nameof(IManifest.EntryDll)} and {nameof(IManifest.ContentPackFor)}, which are mutually exclusive."); continue; } @@ -132,14 +137,14 @@ namespace StardewModdingAPI.Framework.ModLoading // invalid filename format if (mod.Manifest.EntryDll.Intersect(Path.GetInvalidFileNameChars()).Any()) { - mod.SetStatus(ModMetadataStatus.Failed, $"its manifest has invalid filename '{mod.Manifest.EntryDll}' for the EntryDLL field."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has invalid filename '{mod.Manifest.EntryDll}' for the EntryDLL field."); continue; } // invalid path if (!File.Exists(Path.Combine(mod.DirectoryPath, mod.Manifest.EntryDll))) { - mod.SetStatus(ModMetadataStatus.Failed, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist."); continue; } @@ -147,7 +152,7 @@ namespace StardewModdingAPI.Framework.ModLoading string actualFilename = new DirectoryInfo(mod.DirectoryPath).GetFiles(mod.Manifest.EntryDll).FirstOrDefault()?.Name; if (actualFilename != mod.Manifest.EntryDll) { - mod.SetStatus(ModMetadataStatus.Failed, $"its {nameof(IManifest.EntryDll)} value '{mod.Manifest.EntryDll}' doesn't match the actual file capitalization '{actualFilename}'. The capitalization must match for crossplatform compatibility."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its {nameof(IManifest.EntryDll)} value '{mod.Manifest.EntryDll}' doesn't match the actual file capitalization '{actualFilename}'. The capitalization must match for crossplatform compatibility."); continue; } } @@ -158,7 +163,7 @@ namespace StardewModdingAPI.Framework.ModLoading // invalid content pack ID if (string.IsNullOrWhiteSpace(mod.Manifest.ContentPackFor.UniqueID)) { - mod.SetStatus(ModMetadataStatus.Failed, $"its manifest declares {nameof(IManifest.ContentPackFor)} without its required {nameof(IManifestContentPackFor.UniqueID)} field."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest declares {nameof(IManifest.ContentPackFor)} without its required {nameof(IManifestContentPackFor.UniqueID)} field."); continue; } } @@ -177,14 +182,14 @@ namespace StardewModdingAPI.Framework.ModLoading if (missingFields.Any()) { - mod.SetStatus(ModMetadataStatus.Failed, $"its manifest is missing required fields ({string.Join(", ", missingFields)})."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest is missing required fields ({string.Join(", ", missingFields)})."); continue; } } // validate ID format if (!PathUtilities.IsSlug(mod.Manifest.UniqueID)) - mod.SetStatus(ModMetadataStatus.Failed, "its manifest specifies an invalid ID (IDs must only contain letters, numbers, underscores, periods, or hyphens)."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, "its manifest specifies an invalid ID (IDs must only contain letters, numbers, underscores, periods, or hyphens)."); } // validate IDs are unique @@ -199,13 +204,8 @@ namespace StardewModdingAPI.Framework.ModLoading if (mod.Status == ModMetadataStatus.Failed) continue; // don't replace metadata error - string folderList = string.Join(", ", - from entry in @group - let relativePath = entry.GetRelativePathWithRoot() - orderby relativePath - select $"{relativePath} ({entry.Manifest.Version})" - ); - mod.SetStatus(ModMetadataStatus.Failed, $"you have multiple copies of this mod installed. Found in folders: {folderList}."); + string folderList = string.Join(", ", group.Select(p => p.GetRelativePathWithRoot()).OrderBy(p => p)); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.Duplicate, $"you have multiple copies of this mod installed. To fix this, delete these folders and reinstall the mod: {folderList}."); } } } @@ -298,7 +298,7 @@ namespace StardewModdingAPI.Framework.ModLoading if (failedModNames.Any()) { sortedMods.Push(mod); - mod.SetStatus(ModMetadataStatus.Failed, $"it requires mods which aren't installed ({string.Join(", ", failedModNames)})."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.MissingDependencies, $"it requires mods which aren't installed ({string.Join(", ", failedModNames)})."); return states[mod] = ModDependencyStatus.Failed; } } @@ -315,7 +315,7 @@ namespace StardewModdingAPI.Framework.ModLoading if (failedLabels.Any()) { sortedMods.Push(mod); - mod.SetStatus(ModMetadataStatus.Failed, $"it needs newer versions of some mods: {string.Join(", ", failedLabels)}."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.MissingDependencies, $"it needs newer versions of some mods: {string.Join(", ", failedLabels)}."); return states[mod] = ModDependencyStatus.Failed; } } @@ -338,7 +338,7 @@ namespace StardewModdingAPI.Framework.ModLoading if (states[requiredMod] == ModDependencyStatus.Checking) { sortedMods.Push(mod); - mod.SetStatus(ModMetadataStatus.Failed, $"its dependencies have a circular reference: {string.Join(" => ", subchain.Select(p => p.DisplayName))} => {requiredMod.DisplayName})."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.MissingDependencies, $"its dependencies have a circular reference: {string.Join(" => ", subchain.Select(p => p.DisplayName))} => {requiredMod.DisplayName})."); return states[mod] = ModDependencyStatus.Failed; } @@ -354,7 +354,7 @@ namespace StardewModdingAPI.Framework.ModLoading // failed, which means this mod can't be loaded either case ModDependencyStatus.Failed: sortedMods.Push(mod); - mod.SetStatus(ModMetadataStatus.Failed, $"it needs the '{requiredMod.DisplayName}' mod, which couldn't be loaded."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.MissingDependencies, $"it needs the '{requiredMod.DisplayName}' mod, which couldn't be loaded."); return states[mod] = ModDependencyStatus.Failed; // unexpected status diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs index 8043b13a..0b679e9d 100644 --- a/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs +++ b/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs @@ -26,26 +26,31 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="type">The type whose field to rewrite.</param> + /// <param name="fromType">The type whose field to rewrite.</param> /// <param name="fromFieldName">The field name to rewrite.</param> + /// <param name="toType">The new type which will have the field.</param> /// <param name="toFieldName">The new field name to reference.</param> - public FieldReplaceRewriter(Type type, string fromFieldName, string toFieldName) - : base(defaultPhrase: $"{type.FullName}.{fromFieldName} field") + public FieldReplaceRewriter(Type fromType, string fromFieldName, Type toType, string toFieldName) + : base(defaultPhrase: $"{fromType.FullName}.{fromFieldName} field") { - this.Type = type; + this.Type = fromType; this.FromFieldName = fromFieldName; - this.ToField = type.GetField(toFieldName); + this.ToField = toType.GetField(toFieldName); if (this.ToField == null) - throw new InvalidOperationException($"The {type.FullName} class doesn't have a {toFieldName} field."); + throw new InvalidOperationException($"The {toType.FullName} class doesn't have a {toFieldName} field."); } - /// <summary>Rewrite a CIL instruction reference if needed.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The CIL instruction to handle.</param> - /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> - /// <returns>Returns whether the instruction was changed.</returns> - public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) + /// <summary>Construct an instance.</summary> + /// <param name="type">The type whose field to rewrite.</param> + /// <param name="fromFieldName">The field name to rewrite.</param> + /// <param name="toFieldName">The new field name to reference.</param> + public FieldReplaceRewriter(Type type, string fromFieldName, string toFieldName) + : this(type, fromFieldName, type, toFieldName) + { + } + + /// <inheritdoc /> + public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction) { // get field reference FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); @@ -53,8 +58,8 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters return false; // replace with new field - FieldReference newRef = module.ImportReference(this.ToField); - replaceWith(cil.Create(instruction.OpCode, newRef)); + instruction.Operand = module.ImportReference(this.ToField); + return this.MarkRewritten(); } } diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/FieldToPropertyRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/FieldToPropertyRewriter.cs deleted file mode 100644 index c3b5854e..00000000 --- a/src/SMAPI/Framework/ModLoading/Rewriters/FieldToPropertyRewriter.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using Mono.Cecil; -using Mono.Cecil.Cil; -using StardewModdingAPI.Framework.ModLoading.Framework; - -namespace StardewModdingAPI.Framework.ModLoading.Rewriters -{ - /// <summary>Rewrites field references into property references.</summary> - internal class FieldToPropertyRewriter : BaseInstructionHandler - { - /********* - ** Fields - *********/ - /// <summary>The type containing the field to which references should be rewritten.</summary> - private readonly Type Type; - - /// <summary>The field name to which references should be rewritten.</summary> - private readonly string FromFieldName; - - /// <summary>The new property name.</summary> - private readonly string ToPropertyName; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="type">The type whose field to which references should be rewritten.</param> - /// <param name="fieldName">The field name to rewrite.</param> - /// <param name="propertyName">The property name (if different).</param> - public FieldToPropertyRewriter(Type type, string fieldName, string propertyName) - : base(defaultPhrase: $"{type.FullName}.{fieldName} field") - { - this.Type = type; - this.FromFieldName = fieldName; - this.ToPropertyName = propertyName; - } - - /// <summary>Construct an instance.</summary> - /// <param name="type">The type whose field to which references should be rewritten.</param> - /// <param name="fieldName">The field name to rewrite.</param> - public FieldToPropertyRewriter(Type type, string fieldName) - : this(type, fieldName, fieldName) { } - - /// <summary>Rewrite a CIL instruction reference if needed.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The CIL instruction to handle.</param> - /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> - /// <returns>Returns whether the instruction was changed.</returns> - public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) - { - // get field ref - FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); - if (!RewriteHelper.IsFieldReferenceTo(fieldRef, this.Type.FullName, this.FromFieldName)) - return false; - - // replace with property - string methodPrefix = instruction.OpCode == OpCodes.Ldsfld || instruction.OpCode == OpCodes.Ldfld ? "get" : "set"; - MethodReference propertyRef = module.ImportReference(this.Type.GetMethod($"{methodPrefix}_{this.ToPropertyName}")); - replaceWith(cil.Create(OpCodes.Call, propertyRef)); - return this.MarkRewritten(); - } - } -} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/Harmony1AssemblyRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/Harmony1AssemblyRewriter.cs index b30d686e..4b3675bc 100644 --- a/src/SMAPI/Framework/ModLoading/Rewriters/Harmony1AssemblyRewriter.cs +++ b/src/SMAPI/Framework/ModLoading/Rewriters/Harmony1AssemblyRewriter.cs @@ -25,11 +25,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters public Harmony1AssemblyRewriter() : base(defaultPhrase: "Harmony 1.x") { } - /// <summary>Rewrite a type reference if needed.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="type">The type definition to handle.</param> - /// <param name="replaceWith">Replaces the type reference with a new one.</param> - /// <returns>Returns whether the type was changed.</returns> + /// <inheritdoc /> public override bool Handle(ModuleDefinition module, TypeReference type, Action<TypeReference> replaceWith) { // rewrite Harmony 1.x type to Harmony 2.0 type @@ -45,12 +41,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters return false; } - /// <summary>Rewrite a CIL instruction reference if needed.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The CIL instruction to handle.</param> - /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> - /// <returns>Returns whether the instruction was changed.</returns> + /// <inheritdoc /> public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) { // rewrite Harmony 1.x methods to Harmony 2.0 diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicFieldRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicFieldRewriter.cs new file mode 100644 index 00000000..ca04205c --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicFieldRewriter.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; +using System.Linq; +using Mono.Cecil; +using Mono.Cecil.Cil; +using StardewModdingAPI.Framework.ModLoading.Framework; + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters +{ + /// <summary>Automatically fix references to fields that have been replaced by a property or const field.</summary> + internal class HeuristicFieldRewriter : BaseInstructionHandler + { + /********* + ** Fields + *********/ + /// <summary>The assembly names to which to rewrite broken references.</summary> + private readonly HashSet<string> RewriteReferencesToAssemblies; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="rewriteReferencesToAssemblies">The assembly names to which to rewrite broken references.</param> + public HeuristicFieldRewriter(string[] rewriteReferencesToAssemblies) + : base(defaultPhrase: "field changed to property") // ignored since we specify phrases + { + this.RewriteReferencesToAssemblies = new HashSet<string>(rewriteReferencesToAssemblies); + } + + /// <inheritdoc /> + public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction) + { + // get field ref + FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); + if (fieldRef == null || !this.ShouldValidate(fieldRef.DeclaringType)) + return false; + + // skip if not broken + FieldDefinition fieldDefinition = fieldRef.Resolve(); + if (fieldDefinition != null && !fieldDefinition.HasConstant) + return false; + + // rewrite if possible + TypeDefinition declaringType = fieldRef.DeclaringType.Resolve(); + bool isRead = instruction.OpCode == OpCodes.Ldsfld || instruction.OpCode == OpCodes.Ldfld; + return + this.TryRewriteToProperty(module, instruction, fieldRef, declaringType, isRead) + || this.TryRewriteToConstField(instruction, fieldDefinition); + } + + + /********* + ** Private methods + *********/ + /// <summary>Whether references to the given type should be validated.</summary> + /// <param name="type">The type reference.</param> + private bool ShouldValidate(TypeReference type) + { + return type != null && this.RewriteReferencesToAssemblies.Contains(type.Scope.Name); + } + + /// <summary>Try rewriting the field into a matching property.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="instruction">The CIL instruction to rewrite.</param> + /// <param name="fieldRef">The field reference.</param> + /// <param name="declaringType">The type on which the field was defined.</param> + /// <param name="isRead">Whether the field is being read; else it's being written to.</param> + private bool TryRewriteToProperty(ModuleDefinition module, Instruction instruction, FieldReference fieldRef, TypeDefinition declaringType, bool isRead) + { + // get equivalent property + PropertyDefinition property = declaringType.Properties.FirstOrDefault(p => p.Name == fieldRef.Name); + MethodDefinition method = isRead ? property?.GetMethod : property?.SetMethod; + if (method == null) + return false; + + // rewrite field to property + instruction.OpCode = OpCodes.Call; + instruction.Operand = module.ImportReference(method); + + this.Phrases.Add($"{fieldRef.DeclaringType.Name}.{fieldRef.Name} (field => property)"); + return this.MarkRewritten(); + } + + /// <summary>Try rewriting the field into a matching const field.</summary> + /// <param name="instruction">The CIL instruction to rewrite.</param> + /// <param name="field">The field definition.</param> + private bool TryRewriteToConstField(Instruction instruction, FieldDefinition field) + { + // must have been a static field read, and the new field must be const + if (instruction.OpCode != OpCodes.Ldsfld || field?.HasConstant != true) + return false; + + // get opcode for value type + Instruction loadInstruction = RewriteHelper.GetLoadValueInstruction(field.Constant); + if (loadInstruction == null) + return false; + + // rewrite to constant + instruction.OpCode = loadInstruction.OpCode; + instruction.Operand = loadInstruction.Operand; + + this.Phrases.Add($"{field.DeclaringType.Name}.{field.Name} (field => const)"); + return this.MarkRewritten(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicMethodRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicMethodRewriter.cs new file mode 100644 index 00000000..e133b6fa --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicMethodRewriter.cs @@ -0,0 +1,109 @@ +using System.Collections.Generic; +using System.Linq; +using Mono.Cecil; +using Mono.Cecil.Cil; +using StardewModdingAPI.Framework.ModLoading.Framework; + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters +{ + /// <summary>Automatically fix references to methods that had extra optional parameters added.</summary> + internal class HeuristicMethodRewriter : BaseInstructionHandler + { + /********* + ** Fields + *********/ + /// <summary>The assembly names to which to rewrite broken references.</summary> + private readonly HashSet<string> RewriteReferencesToAssemblies; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="rewriteReferencesToAssemblies">The assembly names to which to rewrite broken references.</param> + public HeuristicMethodRewriter(string[] rewriteReferencesToAssemblies) + : base(defaultPhrase: "methods with missing parameters") // ignored since we specify phrases + { + this.RewriteReferencesToAssemblies = new HashSet<string>(rewriteReferencesToAssemblies); + } + + /// <inheritdoc /> + public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction) + { + // get method ref + MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + if (methodRef == null || !this.ShouldValidate(methodRef.DeclaringType)) + return false; + + // skip if not broken + if (methodRef.Resolve() != null) + return false; + + // get type + var type = methodRef.DeclaringType.Resolve(); + if (type == null) + return false; + + // get method definition + MethodDefinition method = null; + foreach (var match in type.Methods.Where(p => p.Name == methodRef.Name)) + { + // reference matches initial parameters of definition + if (methodRef.Parameters.Count >= match.Parameters.Count || !this.InitialParametersMatch(methodRef, match)) + continue; + + // all remaining parameters in definition are optional + if (!match.Parameters.Skip(methodRef.Parameters.Count).All(p => p.IsOptional)) + continue; + + method = match; + break; + } + if (method == null) + return false; + + // get instructions to inject parameter values + var loadInstructions = method.Parameters.Skip(methodRef.Parameters.Count) + .Select(p => RewriteHelper.GetLoadValueInstruction(p.Constant)) + .ToArray(); + if (loadInstructions.Any(p => p == null)) + return false; // SMAPI needs to load the value onto the stack before the method call, but the optional parameter type wasn't recognized + + // rewrite method reference + foreach (Instruction loadInstruction in loadInstructions) + cil.InsertBefore(instruction, loadInstruction); + instruction.Operand = module.ImportReference(method); + + this.Phrases.Add($"{methodRef.DeclaringType.Name}.{methodRef.Name} (added missing optional parameters)"); + return this.MarkRewritten(); + } + + + /********* + ** Private methods + *********/ + /// <summary>Whether references to the given type should be validated.</summary> + /// <param name="type">The type reference.</param> + private bool ShouldValidate(TypeReference type) + { + return type != null && this.RewriteReferencesToAssemblies.Contains(type.Scope.Name); + } + + /// <summary>Get whether every parameter in the method reference matches the exact order and type of the parameters in the method definition. This ignores extra parameters in the definition.</summary> + /// <param name="methodRef">The method reference whose parameters to check.</param> + /// <param name="method">The method definition whose parameters to check against.</param> + private bool InitialParametersMatch(MethodReference methodRef, MethodDefinition method) + { + if (methodRef.Parameters.Count > method.Parameters.Count) + return false; + + for (int i = 0; i < methodRef.Parameters.Count; i++) + { + if (!RewriteHelper.IsSameType(methodRef.Parameters[i].ParameterType, method.Parameters[i].ParameterType)) + return false; + } + + return true; + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs index b8e53f40..9933e2ca 100644 --- a/src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs +++ b/src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs @@ -40,13 +40,8 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters public MethodParentRewriter(Type fromType, Type toType, string nounPhrase = null) : this(fromType.FullName, toType, nounPhrase) { } - /// <summary>Rewrite a CIL instruction reference if needed.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The CIL instruction to handle.</param> - /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> - /// <returns>Returns whether the instruction was changed.</returns> - public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) + /// <inheritdoc /> + public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction) { // get method ref MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StaticFieldToConstantRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StaticFieldToConstantRewriter.cs deleted file mode 100644 index 6ef18b26..00000000 --- a/src/SMAPI/Framework/ModLoading/Rewriters/StaticFieldToConstantRewriter.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System; -using Mono.Cecil; -using Mono.Cecil.Cil; -using StardewModdingAPI.Framework.ModLoading.Framework; - -namespace StardewModdingAPI.Framework.ModLoading.Rewriters -{ - /// <summary>Rewrites static field references into constant values.</summary> - /// <typeparam name="TValue">The constant value type.</typeparam> - internal class StaticFieldToConstantRewriter<TValue> : BaseInstructionHandler - { - /********* - ** Fields - *********/ - /// <summary>The type containing the field to which references should be rewritten.</summary> - private readonly Type Type; - - /// <summary>The field name to which references should be rewritten.</summary> - private readonly string FromFieldName; - - /// <summary>The constant value to replace with.</summary> - private readonly TValue Value; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="type">The type whose field to which references should be rewritten.</param> - /// <param name="fieldName">The field name to rewrite.</param> - /// <param name="value">The constant value to replace with.</param> - public StaticFieldToConstantRewriter(Type type, string fieldName, TValue value) - : base(defaultPhrase: $"{type.FullName}.{fieldName} field") - { - this.Type = type; - this.FromFieldName = fieldName; - this.Value = value; - } - - /// <summary>Rewrite a CIL instruction reference if needed.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The CIL instruction to handle.</param> - /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> - /// <returns>Returns whether the instruction was changed.</returns> - public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) - { - // get field reference - FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); - if (!RewriteHelper.IsFieldReferenceTo(fieldRef, this.Type.FullName, this.FromFieldName)) - return false; - - // rewrite to constant - replaceWith(this.CreateConstantInstruction(cil, this.Value)); - return this.MarkRewritten(); - } - - - /********* - ** Private methods - *********/ - /// <summary>Create a CIL constant value instruction.</summary> - /// <param name="cil">The CIL processor.</param> - /// <param name="value">The constant value to set.</param> - private Instruction CreateConstantInstruction(ILProcessor cil, object value) - { - if (typeof(TValue) == typeof(int)) - return cil.Create(OpCodes.Ldc_I4, (int)value); - if (typeof(TValue) == typeof(string)) - return cil.Create(OpCodes.Ldstr, (string)value); - throw new NotSupportedException($"Rewriting to constant values of type {typeof(TValue)} isn't currently supported."); - } - } -} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs index c2120444..ad5cb96f 100644 --- a/src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs +++ b/src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs @@ -35,11 +35,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters this.ShouldIgnore = shouldIgnore; } - /// <summary>Rewrite a type reference if needed.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="type">The type definition to handle.</param> - /// <param name="replaceWith">Replaces the type reference with a new one.</param> - /// <returns>Returns whether the type was changed.</returns> + /// <inheritdoc /> public override bool Handle(ModuleDefinition module, TypeReference type, Action<TypeReference> replaceWith) { // check type reference diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs index 1c682f96..3a3f6960 100644 --- a/src/SMAPI/Framework/Models/SConfig.cs +++ b/src/SMAPI/Framework/Models/SConfig.cs @@ -16,7 +16,6 @@ namespace StardewModdingAPI.Framework.Models { [nameof(CheckForUpdates)] = true, [nameof(ParanoidWarnings)] = Constants.IsDebugBuild, - [nameof(RewriteInParallel)] = Constants.IsDebugBuild, [nameof(UseBetaChannel)] = Constants.ApiVersion.IsPrerelease(), [nameof(GitHubProjectName)] = "Pathoschild/SMAPI", [nameof(WebApiBaseUrl)] = "https://smapi.io/api/", @@ -41,9 +40,6 @@ namespace StardewModdingAPI.Framework.Models /// <summary>Whether to check for newer versions of SMAPI and mods on startup.</summary> public bool CheckForUpdates { get; set; } - /// <summary>Whether to enable experimental parallel rewriting.</summary> - public bool RewriteInParallel { get; set; } = (bool)SConfig.DefaultValues[nameof(SConfig.RewriteInParallel)]; - /// <summary>Whether to add a section to the 'mod issues' list for mods which which directly use potentially sensitive .NET APIs like file or shell access.</summary> public bool ParanoidWarnings { get; set; } = (bool)SConfig.DefaultValues[nameof(SConfig.ParanoidWarnings)]; diff --git a/src/SMAPI/Framework/Monitor.cs b/src/SMAPI/Framework/Monitor.cs index 44eeabe6..533420a5 100644 --- a/src/SMAPI/Framework/Monitor.cs +++ b/src/SMAPI/Framework/Monitor.cs @@ -18,8 +18,8 @@ namespace StardewModdingAPI.Framework /// <summary>Handles writing text to the console.</summary> private readonly IConsoleWriter ConsoleWriter; - /// <summary>Manages access to the console output.</summary> - private readonly ConsoleInterceptionManager ConsoleInterceptor; + /// <summary>Prefixing a message with this character indicates that the console interceptor should write the string without intercepting it. (The character itself is not written.)</summary> + private readonly char IgnoreChar; /// <summary>The log file to which to write messages.</summary> private readonly LogFileManager LogFile; @@ -34,7 +34,7 @@ namespace StardewModdingAPI.Framework /********* ** Accessors *********/ - /// <summary>Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed.</summary> + /// <inheritdoc /> public bool IsVerbose { get; } /// <summary>Whether to show the full log stamps (with time/level/logger) in the console. If false, shows a simplified stamp with only the logger.</summary> @@ -52,11 +52,11 @@ namespace StardewModdingAPI.Framework *********/ /// <summary>Construct an instance.</summary> /// <param name="source">The name of the module which logs messages using this instance.</param> - /// <param name="consoleInterceptor">Intercepts access to the console output.</param> + /// <param name="ignoreChar">A character which indicates the message should not be intercepted if it appears as the first character of a string written to the console. The character itself is not logged in that case.</param> /// <param name="logFile">The log file to which to write messages.</param> /// <param name="colorConfig">The colors to use for text written to the SMAPI console.</param> /// <param name="isVerbose">Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed.</param> - public Monitor(string source, ConsoleInterceptionManager consoleInterceptor, LogFileManager logFile, ColorSchemeConfig colorConfig, bool isVerbose) + public Monitor(string source, char ignoreChar, LogFileManager logFile, ColorSchemeConfig colorConfig, bool isVerbose) { // validate if (string.IsNullOrWhiteSpace(source)) @@ -66,29 +66,24 @@ namespace StardewModdingAPI.Framework this.Source = source; this.LogFile = logFile ?? throw new ArgumentNullException(nameof(logFile), "The log file manager cannot be null."); this.ConsoleWriter = new ColorfulConsoleWriter(Constants.Platform, colorConfig); - this.ConsoleInterceptor = consoleInterceptor; + this.IgnoreChar = ignoreChar; this.IsVerbose = isVerbose; } - /// <summary>Log a message for the player or developer.</summary> - /// <param name="message">The message to log.</param> - /// <param name="level">The log severity level.</param> + /// <inheritdoc /> public void Log(string message, LogLevel level = LogLevel.Trace) { this.LogImpl(this.Source, message, (ConsoleLogLevel)level); } - /// <summary>Log a message for the player or developer, but only if it hasn't already been logged since the last game launch.</summary> - /// <param name="message">The message to log.</param> - /// <param name="level">The log severity level.</param> + /// <inheritdoc /> public void LogOnce(string message, LogLevel level = LogLevel.Trace) { if (this.LogOnceCache.Add($"{message}|{level}")) this.LogImpl(this.Source, message, (ConsoleLogLevel)level); } - /// <summary>Log a message that only appears when <see cref="IMonitor.IsVerbose"/> is enabled.</summary> - /// <param name="message">The message to log.</param> + /// <inheritdoc /> public void VerboseLog(string message) { if (this.IsVerbose) @@ -99,7 +94,7 @@ namespace StardewModdingAPI.Framework internal void Newline() { if (this.WriteToConsole) - this.ConsoleInterceptor.ExclusiveWriteWithoutInterception(Console.WriteLine); + Console.WriteLine(); this.LogFile.WriteLine(""); } @@ -136,12 +131,7 @@ namespace StardewModdingAPI.Framework // write to console if (this.WriteToConsole && (this.ShowTraceInConsole || level != ConsoleLogLevel.Trace)) - { - this.ConsoleInterceptor.ExclusiveWriteWithoutInterception(() => - { - this.ConsoleWriter.WriteLine(consoleMessage, level); - }); - } + this.ConsoleWriter.WriteLine(this.IgnoreChar + consoleMessage, level); // write to log file this.LogFile.WriteLine(fullMessage); diff --git a/src/SMAPI/Framework/Networking/MultiplayerPeer.cs b/src/SMAPI/Framework/Networking/MultiplayerPeer.cs index 6b45b04a..5eda71f6 100644 --- a/src/SMAPI/Framework/Networking/MultiplayerPeer.cs +++ b/src/SMAPI/Framework/Networking/MultiplayerPeer.cs @@ -18,25 +18,25 @@ namespace StardewModdingAPI.Framework.Networking /********* ** Accessors *********/ - /// <summary>The player's unique ID.</summary> + /// <inheritdoc /> public long PlayerID { get; } - /// <summary>Whether this is a connection to the host player.</summary> + /// <inheritdoc /> public bool IsHost { get; } - /// <summary>Whether the player has SMAPI installed.</summary> + /// <inheritdoc /> public bool HasSmapi => this.ApiVersion != null; - /// <summary>The player's OS platform, if <see cref="HasSmapi"/> is true.</summary> + /// <inheritdoc /> public GamePlatform? Platform { get; } - /// <summary>The installed version of Stardew Valley, if <see cref="HasSmapi"/> is true.</summary> + /// <inheritdoc /> public ISemanticVersion GameVersion { get; } - /// <summary>The installed version of SMAPI, if <see cref="HasSmapi"/> is true.</summary> + /// <inheritdoc /> public ISemanticVersion ApiVersion { get; } - /// <summary>The installed mods, if <see cref="HasSmapi"/> is true.</summary> + /// <inheritdoc /> public IEnumerable<IMultiplayerPeerMod> Mods { get; } @@ -62,9 +62,7 @@ namespace StardewModdingAPI.Framework.Networking this.SendMessageImpl = sendMessage; } - /// <summary>Get metadata for a mod installed by the player.</summary> - /// <param name="id">The unique mod ID.</param> - /// <returns>Returns the mod info, or <c>null</c> if the player doesn't have that mod.</returns> + /// <inheritdoc /> public IMultiplayerPeerMod GetMod(string id) { if (string.IsNullOrWhiteSpace(id) || this.Mods == null || !this.Mods.Any()) diff --git a/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs b/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs index 1b324bcd..8087dc7e 100644 --- a/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs +++ b/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs @@ -5,13 +5,13 @@ namespace StardewModdingAPI.Framework.Networking /********* ** Accessors *********/ - /// <summary>The mod's display name.</summary> + /// <inheritdoc /> public string Name { get; } - /// <summary>The unique mod ID.</summary> + /// <inheritdoc /> public string ID { get; } - /// <summary>The mod version.</summary> + /// <inheritdoc /> public ISemanticVersion Version { get; } diff --git a/src/SMAPI/Framework/Reflection/ReflectedField.cs b/src/SMAPI/Framework/Reflection/ReflectedField.cs index d771422c..3c4da4fc 100644 --- a/src/SMAPI/Framework/Reflection/ReflectedField.cs +++ b/src/SMAPI/Framework/Reflection/ReflectedField.cs @@ -23,7 +23,7 @@ namespace StardewModdingAPI.Framework.Reflection /********* ** Accessors *********/ - /// <summary>The reflection metadata.</summary> + /// <inheritdoc /> public FieldInfo FieldInfo { get; } @@ -55,7 +55,7 @@ namespace StardewModdingAPI.Framework.Reflection this.FieldInfo = field; } - /// <summary>Get the field value.</summary> + /// <inheritdoc /> public TValue GetValue() { try @@ -72,8 +72,7 @@ namespace StardewModdingAPI.Framework.Reflection } } - /// <summary>Set the field value.</summary> - //// <param name="value">The value to set.</param> + /// <inheritdoc /> public void SetValue(TValue value) { try diff --git a/src/SMAPI/Framework/Reflection/ReflectedMethod.cs b/src/SMAPI/Framework/Reflection/ReflectedMethod.cs index 82737a7f..26112806 100644 --- a/src/SMAPI/Framework/Reflection/ReflectedMethod.cs +++ b/src/SMAPI/Framework/Reflection/ReflectedMethod.cs @@ -22,7 +22,7 @@ namespace StardewModdingAPI.Framework.Reflection /********* ** Accessors *********/ - /// <summary>The reflection metadata.</summary> + /// <inheritdoc /> public MethodInfo MethodInfo { get; } @@ -54,9 +54,7 @@ namespace StardewModdingAPI.Framework.Reflection this.MethodInfo = method; } - /// <summary>Invoke the method.</summary> - /// <typeparam name="TValue">The return type.</typeparam> - /// <param name="arguments">The method arguments to pass in.</param> + /// <inheritdoc /> public TValue Invoke<TValue>(params object[] arguments) { // invoke method @@ -85,8 +83,7 @@ namespace StardewModdingAPI.Framework.Reflection } } - /// <summary>Invoke the method.</summary> - /// <param name="arguments">The method arguments to pass in.</param> + /// <inheritdoc /> public void Invoke(params object[] arguments) { // invoke method diff --git a/src/SMAPI/Framework/Reflection/ReflectedProperty.cs b/src/SMAPI/Framework/Reflection/ReflectedProperty.cs index 8a10ff9a..42d7bb59 100644 --- a/src/SMAPI/Framework/Reflection/ReflectedProperty.cs +++ b/src/SMAPI/Framework/Reflection/ReflectedProperty.cs @@ -23,7 +23,7 @@ namespace StardewModdingAPI.Framework.Reflection /********* ** Accessors *********/ - /// <summary>The reflection metadata.</summary> + /// <inheritdoc /> public PropertyInfo PropertyInfo { get; } @@ -61,7 +61,7 @@ namespace StardewModdingAPI.Framework.Reflection this.SetMethod = (Action<TValue>)Delegate.CreateDelegate(typeof(Action<TValue>), obj, this.PropertyInfo.SetMethod); } - /// <summary>Get the property value.</summary> + /// <inheritdoc /> public TValue GetValue() { if (this.GetMethod == null) @@ -81,8 +81,7 @@ namespace StardewModdingAPI.Framework.Reflection } } - /// <summary>Set the property value.</summary> - //// <param name="value">The value to set.</param> + /// <inheritdoc /> public void SetValue(TValue value) { if (this.SetMethod == null) diff --git a/src/SMAPI/Framework/Reflection/Reflector.cs b/src/SMAPI/Framework/Reflection/Reflector.cs index d4904878..889c7ed6 100644 --- a/src/SMAPI/Framework/Reflection/Reflector.cs +++ b/src/SMAPI/Framework/Reflection/Reflector.cs @@ -263,7 +263,7 @@ namespace StardewModdingAPI.Framework.Reflection CacheEntry entry = (CacheEntry)this.Cache[key]; return entry.IsValid ? (TMemberInfo)entry.MemberInfo - : default(TMemberInfo); + : default; } // fetch & cache new value diff --git a/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs b/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs index 121e53bc..cb499c6b 100644 --- a/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs +++ b/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs @@ -93,17 +93,17 @@ namespace StardewModdingAPI.Framework.Rendering { if (tile == null) return; - xTile.Dimensions.Rectangle tileImageBounds = tile.TileSheet.GetTileImageBounds(tile.TileIndex); + Rectangle tileImageBounds = tile.TileSheet.GetTileImageBounds(tile.TileIndex); Texture2D tileSheetTexture = this.m_tileSheetTextures[tile.TileSheet]; if (tileSheetTexture.IsDisposed) return; - this.m_tilePosition.X = (float)location.X; - this.m_tilePosition.Y = (float)location.Y; + this.m_tilePosition.X = location.X; + this.m_tilePosition.Y = location.Y; this.m_sourceRectangle.X = tileImageBounds.X; this.m_sourceRectangle.Y = tileImageBounds.Y; this.m_sourceRectangle.Width = tileImageBounds.Width; this.m_sourceRectangle.Height = tileImageBounds.Height; - this.m_spriteBatchAlpha.Draw(tileSheetTexture, this.m_tilePosition, new Microsoft.Xna.Framework.Rectangle?(this.m_sourceRectangle), this.m_modulationColour, 0.0f, Vector2.Zero, (float)Layer.zoom, SpriteEffects.None, layerDepth); + this.m_spriteBatchAlpha.Draw(tileSheetTexture, this.m_tilePosition, this.m_sourceRectangle, this.m_modulationColour, 0.0f, Vector2.Zero, Layer.zoom, SpriteEffects.None, layerDepth); } /// <summary>Finish drawing to the screen.</summary> diff --git a/src/SMAPI/Framework/RequestExitDelegate.cs b/src/SMAPI/Framework/RequestExitDelegate.cs index 12d0ea0c..810c399b 100644 --- a/src/SMAPI/Framework/RequestExitDelegate.cs +++ b/src/SMAPI/Framework/RequestExitDelegate.cs @@ -4,4 +4,4 @@ namespace StardewModdingAPI.Framework /// <param name="module">The module which requested an immediate exit.</param> /// <param name="reason">The reason provided for the shutdown.</param> internal delegate void RequestExitDelegate(string module, string reason); -}
\ No newline at end of file +} diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index 72ef9095..52b4b9cf 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -1,7 +1,7 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Net; @@ -9,24 +9,32 @@ using System.Reflection; using System.Runtime.ExceptionServices; using System.Security; using System.Text; -using System.Text.RegularExpressions; using System.Threading; +using System.Threading.Tasks; +using Microsoft.Xna.Framework; #if SMAPI_FOR_WINDOWS using System.Windows.Forms; #endif using Newtonsoft.Json; +using StardewModdingAPI.Enums; using StardewModdingAPI.Events; -using StardewModdingAPI.Framework.Commands; +using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.Events; using StardewModdingAPI.Framework.Exceptions; +using StardewModdingAPI.Framework.Input; using StardewModdingAPI.Framework.Logging; using StardewModdingAPI.Framework.Models; using StardewModdingAPI.Framework.ModHelpers; using StardewModdingAPI.Framework.ModLoading; +using StardewModdingAPI.Framework.Networking; using StardewModdingAPI.Framework.Patching; using StardewModdingAPI.Framework.PerformanceMonitoring; using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Framework.Rendering; using StardewModdingAPI.Framework.Serialization; +using StardewModdingAPI.Framework.StateTracking.Comparers; +using StardewModdingAPI.Framework.StateTracking.Snapshots; +using StardewModdingAPI.Framework.Utilities; using StardewModdingAPI.Patches; using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; @@ -35,8 +43,7 @@ using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; using StardewModdingAPI.Utilities; using StardewValley; -using Object = StardewValley.Object; -using ThreadState = System.Threading.ThreadState; +using SObject = StardewValley.Object; namespace StardewModdingAPI.Framework { @@ -46,20 +53,17 @@ namespace StardewModdingAPI.Framework /********* ** Fields *********/ - /// <summary>The log file to which to write messages.</summary> - private readonly LogFileManager LogFile; + /**** + ** Low-level components + ****/ + /// <summary>Tracks whether the game should exit immediately and any pending initialization should be cancelled.</summary> + private readonly CancellationTokenSource CancellationToken = new CancellationTokenSource(); - /// <summary>Manages console output interception.</summary> - private readonly ConsoleInterceptionManager ConsoleManager = new ConsoleInterceptionManager(); + /// <summary>Manages the SMAPI console window and log file.</summary> + private readonly LogManager LogManager; /// <summary>The core logger and monitor for SMAPI.</summary> - private readonly Monitor Monitor; - - /// <summary>The core logger and monitor on behalf of the game.</summary> - private readonly Monitor MonitorForGame; - - /// <summary>Tracks whether the game should exit immediately and any pending initialization should be cancelled.</summary> - private readonly CancellationTokenSource CancellationToken = new CancellationTokenSource(); + private Monitor Monitor => this.LogManager.Monitor; /// <summary>Simplifies access to private game code.</summary> private readonly Reflector Reflection = new Reflector(); @@ -70,11 +74,26 @@ namespace StardewModdingAPI.Framework /// <summary>The SMAPI configuration settings.</summary> private readonly SConfig Settings; + /// <summary>The mod toolkit used for generic mod interactions.</summary> + private readonly ModToolkit Toolkit = new ModToolkit(); + + /**** + ** Higher-level components + ****/ + /// <summary>Manages console commands.</summary> + private readonly CommandManager CommandManager = new CommandManager(); + /// <summary>The underlying game instance.</summary> - private SGame GameInstance; + private SGame Game; + + /// <summary>Manages input visible to the game.</summary> + private SInputState Input => SGame.Input; + + /// <summary>The game's core multiplayer utility.</summary> + private SMultiplayer Multiplayer => SGame.Multiplayer; - /// <summary>The underlying content manager.</summary> - private ContentCoordinator ContentCore => this.GameInstance.ContentCore; + /// <summary>SMAPI's content manager.</summary> + private ContentCoordinator ContentCore; /// <summary>Tracks the installed mods.</summary> /// <remarks>This is initialized after the game starts.</remarks> @@ -83,50 +102,55 @@ namespace StardewModdingAPI.Framework /// <summary>Manages SMAPI events for mods.</summary> private readonly EventManager EventManager; + /// <summary>Monitors the entire game state for changes.</summary> + private WatcherCore Watchers; + + /// <summary>A snapshot of the current <see cref="Watchers"/> state.</summary> + private readonly WatcherSnapshot WatcherSnapshot = new WatcherSnapshot(); + + /**** + ** State + ****/ + /// <summary>The path to search for mods.</summary> + private string ModsPath => Constants.ModsPath; + /// <summary>Whether the game is currently running.</summary> private bool IsGameRunning; /// <summary>Whether the program has been disposed.</summary> private bool IsDisposed; - /// <summary>Regex patterns which match console non-error messages to suppress from the console and log.</summary> - private readonly Regex[] SuppressConsolePatterns = - { - new Regex(@"^TextBox\.Selected is now '(?:True|False)'\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^(?:FRUIT )?TREE: IsClient:(?:True|False) randomOutput: \d+$", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^loadPreferences\(\); begin", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^savePreferences\(\); async=", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^DebugOutput:\s+(?:added CLOUD|added cricket|dismount tile|Ping|playerPos)", RegexOptions.Compiled | RegexOptions.CultureInvariant) - }; - - /// <summary>Regex patterns which match console messages to show a more friendly error for.</summary> - private readonly ReplaceLogPattern[] ReplaceConsolePatterns = - { - // Steam not loaded - new ReplaceLogPattern( - search: new Regex(@"^System\.InvalidOperationException: Steamworks is not initialized\.[\s\S]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant), - replacement: -#if SMAPI_FOR_WINDOWS - "Oops! Steam achievements won't work because Steam isn't loaded. You can launch the game through Steam to fix that (see 'Part 2: Configure Steam' in the install guide for more info: https://smapi.io/install).", -#else - "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 - ), + /// <summary>Whether the next content manager requested by the game will be for <see cref="Game1.content"/>.</summary> + private bool NextContentManagerIsMain; - // save file not found error - new ReplaceLogPattern( - search: new Regex(@"^System\.IO\.FileNotFoundException: [^\n]+\n[^:]+: '[^\n]+[/\\]Saves[/\\]([^'\r\n]+)[/\\]([^'\r\n]+)'[\s\S]+LoadGameMenu\.FindSaveGames[\s\S]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant), - replacement: "The game can't find the '$2' file for your '$1' save. See https://stardewvalleywiki.com/Saves#Troubleshooting for help.", - logLevel: LogLevel.Error - ) - }; + /// <summary>Whether post-game-startup initialization has been performed.</summary> + private bool IsInitialized; - /// <summary>The mod toolkit used for generic mod interactions.</summary> - private readonly ModToolkit Toolkit = new ModToolkit(); + /// <summary>The maximum number of consecutive attempts SMAPI should make to recover from an update error.</summary> + private readonly Countdown UpdateCrashTimer = new Countdown(60); // 60 ticks = roughly one second - /// <summary>The path to search for mods.</summary> - private string ModsPath => Constants.ModsPath; + /// <summary>The number of ticks until SMAPI should notify mods that the game has loaded.</summary> + /// <remarks>Skipping a few frames ensures the game finishes initializing the world before mods try to change it.</remarks> + private readonly Countdown AfterLoadTimer = new Countdown(5); + + /// <summary>Whether custom content was removed from the save data to avoid a crash.</summary> + private bool IsSaveContentRemoved; + + /// <summary>Whether the game is saving and SMAPI has already raised <see cref="IGameLoopEvents.Saving"/>.</summary> + private bool IsBetweenSaveEvents; + + /// <summary>Whether the game is creating the save file and SMAPI has already raised <see cref="IGameLoopEvents.SaveCreating"/>.</summary> + private bool IsBetweenCreateEvents; + + /// <summary>Whether the player just returned to the title screen.</summary> + private bool JustReturnedToTitle; + + /// <summary>Asset interceptors added or removed since the last tick.</summary> + private readonly List<AssetInterceptorChange> ReloadAssetInterceptorsQueue = new List<AssetInterceptorChange>(); + + /// <summary>A list of queued commands to execute.</summary> + /// <remarks>This property must be thread-safe, since it's accessed from a separate console input thread.</remarks> + public ConcurrentQueue<string> CommandQueue { get; } = new ConcurrentQueue<string>(); /********* @@ -140,6 +164,9 @@ namespace StardewModdingAPI.Framework /// <remarks>This is initialized after the game starts. This is non-private for use by Console Commands.</remarks> internal static PerformanceMonitor PerformanceMonitor { get; private set; } + /// <summary>The number of update ticks which have already executed. This is similar to <see cref="Game1.ticks"/>, but incremented more consistently for every tick.</summary> + internal static uint TicksElapsed { get; private set; } + /********* ** Public methods @@ -163,14 +190,7 @@ namespace StardewModdingAPI.Framework 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) - { - WriteToConsole = writeToConsole, - ShowTraceInConsole = this.Settings.DeveloperMode, - ShowFullStampInConsole = this.Settings.DeveloperMode - }; - this.MonitorForGame = this.GetSecondaryMonitor("game"); + this.LogManager = new LogManager(logPath: logPath, colorConfig: this.Settings.ConsoleColors, writeToConsole: writeToConsole, isVerbose: this.Settings.VerboseLogging, isDeveloperMode: this.Settings.DeveloperMode); SCore.PerformanceMonitor = new PerformanceMonitor(this.Monitor); this.EventManager = new EventManager(this.ModRegistry, SCore.PerformanceMonitor); @@ -180,38 +200,21 @@ namespace StardewModdingAPI.Framework SDate.Translations = this.Translator; - // redirect direct console output - if (this.MonitorForGame.WriteToConsole) - this.ConsoleManager.OnMessageIntercepted += message => this.HandleConsoleMessage(this.MonitorForGame, message); - - // init logging - this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} on {EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform)}", LogLevel.Info); - this.Monitor.Log($"Mods go here: {modsPath}", LogLevel.Info); - if (modsPath != Constants.DefaultModsPath) - this.Monitor.Log("(Using custom --mods-path argument.)", LogLevel.Trace); - this.Monitor.Log($"Log started at {DateTime.UtcNow:s} UTC", LogLevel.Trace); - - // log custom settings - { - IDictionary<string, object> customSettings = this.Settings.GetCustomSettings(); - if (customSettings.Any()) - this.Monitor.Log($"Loaded with custom settings: {string.Join(", ", customSettings.OrderBy(p => p.Key).Select(p => $"{p.Key}: {p.Value}"))}", LogLevel.Trace); - } + // log SMAPI/OS info + this.LogManager.LogIntro(modsPath, this.Settings.GetCustomSettings()); // validate platform #if SMAPI_FOR_WINDOWS if (Constants.Platform != Platform.Windows) { this.Monitor.Log("Oops! You're running Windows, but this version of SMAPI is for Linux or Mac. Please reinstall SMAPI to fix this.", LogLevel.Error); - this.PressAnyKeyToExit(); - return; + this.LogManager.PressAnyKeyToExit(); } #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.PressAnyKeyToExit(); - return; + this.LogManager.PressAnyKeyToExit(); } #endif } @@ -246,33 +249,31 @@ namespace StardewModdingAPI.Framework LocalizedContentManager.OnLanguageChange += locale => this.OnLocaleChanged(); // override game - SGame.ConstructorHack = new SGameConstructorHack(this.Monitor, this.Reflection, this.Toolkit.JsonHelper, this.InitializeBeforeFirstAssetLoaded); - this.GameInstance = new SGame( + var multiplayer = new SMultiplayer(this.Monitor, this.EventManager, this.Toolkit.JsonHelper, this.ModRegistry, this.Reflection, this.OnModMessageReceived, this.Settings.LogNetworkTraffic); + var modHooks = new SModHooks(this.OnNewDayAfterFade); + SGame.CreateContentManagerImpl = this.CreateContentManager; // must be static since the game accesses it before the SGame constructor is called + this.Game = new SGame( monitor: this.Monitor, - monitorForGame: this.MonitorForGame, reflection: this.Reflection, - translator: this.Translator, eventManager: this.EventManager, - jsonHelper: this.Toolkit.JsonHelper, - modRegistry: this.ModRegistry, - deprecationManager: SCore.DeprecationManager, - performanceMonitor: SCore.PerformanceMonitor, - onGameInitialized: this.InitializeAfterGameStart, - onGameExiting: this.Dispose, - cancellationToken: this.CancellationToken, - logNetworkTraffic: this.Settings.LogNetworkTraffic + modHooks: modHooks, + multiplayer: multiplayer, + exitGameImmediately: this.ExitGameImmediately, + + onGameContentLoaded: this.OnGameContentLoaded, + onGameUpdating: this.OnGameUpdating, + onGameExiting: this.OnGameExiting ); - this.Translator.SetLocale(this.GameInstance.ContentCore.GetLocale(), this.GameInstance.ContentCore.Language); - StardewValley.Program.gamePtr = this.GameInstance; + StardewValley.Program.gamePtr = this.Game; // apply game patches new GamePatcher(this.Monitor).Apply( - new EventErrorPatch(this.MonitorForGame), - new DialogueErrorPatch(this.MonitorForGame, this.Reflection), + new EventErrorPatch(this.LogManager.MonitorForGame), + new DialogueErrorPatch(this.LogManager.MonitorForGame, this.Reflection), new ObjectErrorPatch(), - new LoadContextPatch(this.Reflection, this.GameInstance.OnLoadStageChanged), - new LoadErrorPatch(this.Monitor, this.GameInstance.OnSaveContentRemoved), - new ScheduleErrorPatch(this.MonitorForGame) + new LoadContextPatch(this.Reflection, this.OnLoadStageChanged), + new LoadErrorPatch(this.Monitor, this.OnSaveContentRemoved), + new ScheduleErrorPatch(this.LogManager.MonitorForGame) ); // add exit handler @@ -281,73 +282,33 @@ namespace StardewModdingAPI.Framework this.CancellationToken.Token.WaitHandle.WaitOne(); if (this.IsGameRunning) { - try - { - File.WriteAllText(Constants.FatalCrashMarker, string.Empty); - File.Copy(this.LogFile.Path, Constants.FatalCrashLog, overwrite: true); - } - catch (Exception ex) - { - this.Monitor.Log($"SMAPI failed trying to track the crash details: {ex.GetLogSummary()}", LogLevel.Error); - } - - this.GameInstance.Exit(); + this.LogManager.WriteCrashLog(); + this.Game.Exit(); } }).Start(); // set window titles - this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion}"; - Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion}"; + this.SetWindowTitles( + game: $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion}", + smapi: $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion}" + ); } catch (Exception ex) { this.Monitor.Log($"SMAPI failed to initialize: {ex.GetLogSummary()}", LogLevel.Error); - this.PressAnyKeyToExit(); + this.LogManager.PressAnyKeyToExit(); return; } - // check update marker - if (File.Exists(Constants.UpdateMarker)) - { - string rawUpdateFound = File.ReadAllText(Constants.UpdateMarker); - if (SemanticVersion.TryParse(rawUpdateFound, out ISemanticVersion updateFound)) - { - if (Constants.ApiVersion.IsPrerelease() && updateFound.IsNewerThan(Constants.ApiVersion)) - { - this.Monitor.Log("A new version of SMAPI was detected last time you played.", LogLevel.Error); - this.Monitor.Log($"You can update to {updateFound}: https://smapi.io.", LogLevel.Error); - this.Monitor.Log("Press any key to continue playing anyway. (This only appears when using a SMAPI beta.)", LogLevel.Info); - Console.ReadKey(); - } - } - File.Delete(Constants.UpdateMarker); - } - - // show details if game crashed during last session - if (File.Exists(Constants.FatalCrashMarker)) - { - 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(); - File.Delete(Constants.FatalCrashLog); - File.Delete(Constants.FatalCrashMarker); - } - - // add headers - if (this.Settings.DeveloperMode) - this.Monitor.Log($"You have SMAPI for developers, so the console will be much more verbose. You can disable developer mode by installing the non-developer version of SMAPI, or by editing {Constants.ApiConfigPath}.", LogLevel.Info); - if (this.Settings.RewriteInParallel) - this.Monitor.Log($"You enabled experimental parallel rewriting. This may result in faster startup times, but intermittent startup errors. You can disable it by reinstalling SMAPI or editing {Constants.ApiConfigPath}.", LogLevel.Info); - if (!this.Settings.CheckForUpdates) - this.Monitor.Log($"You configured SMAPI to not check for updates. Running an old version of SMAPI is not recommended. You can enable update checks by reinstalling SMAPI or editing {Constants.ApiConfigPath}.", LogLevel.Warn); - if (!this.Monitor.WriteToConsole) - this.Monitor.Log("Writing to the terminal is disabled because the --no-terminal argument was received. This usually means launching the terminal failed.", LogLevel.Warn); - this.Monitor.VerboseLog("Verbose logging enabled."); + // log basic info + this.LogManager.HandleMarkerFiles(); + this.LogManager.LogSettingsHeader(this.Settings.DeveloperMode, this.Settings.CheckForUpdates); - // update window titles - this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion}"; - Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion}"; + // set window titles + this.SetWindowTitles( + game: $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion}", + smapi: $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion}" + ); // start game this.Monitor.Log("Starting game...", LogLevel.Debug); @@ -355,24 +316,12 @@ namespace StardewModdingAPI.Framework { this.IsGameRunning = true; StardewValley.Program.releaseBuild = true; // game's debug logic interferes with SMAPI opening the game window - this.GameInstance.Run(); - } - catch (InvalidOperationException ex) when (ex.Source == "Microsoft.Xna.Framework.Xact" && ex.StackTrace.Contains("Microsoft.Xna.Framework.Audio.AudioEngine..ctor")) - { - this.Monitor.Log("The game couldn't load audio. Do you have speakers or headphones plugged in?", LogLevel.Error); - this.Monitor.Log($"Technical details: {ex.GetLogSummary()}", LogLevel.Trace); - this.PressAnyKeyToExit(); - } - catch (FileNotFoundException ex) when (ex.Message == "Could not find file 'C:\\Program Files (x86)\\Steam\\SteamApps\\common\\Stardew Valley\\Content\\XACT\\FarmerSounds.xgs'.") // path in error is hardcoded regardless of install path - { - this.Monitor.Log("The game can't find its Content\\XACT\\FarmerSounds.xgs file. You can usually fix this by resetting your content files (see https://smapi.io/troubleshoot#reset-content ), or by uninstalling and reinstalling the game.", LogLevel.Error); - this.Monitor.Log($"Technical details: {ex.GetLogSummary()}", LogLevel.Trace); - this.PressAnyKeyToExit(); + this.Game.Run(); } catch (Exception ex) { - this.MonitorForGame.Log($"The game failed to launch: {ex.GetLogSummary()}", LogLevel.Error); - this.PressAnyKeyToExit(); + this.LogManager.LogFatalLaunchError(ex); + this.LogManager.PressAnyKeyToExit(); } finally { @@ -387,7 +336,7 @@ namespace StardewModdingAPI.Framework if (this.IsDisposed) return; this.IsDisposed = true; - this.Monitor.Log("Disposing...", LogLevel.Trace); + this.Monitor.Log("Disposing..."); // dispose mod data foreach (IModMetadata mod in this.ModRegistry.GetAll()) @@ -404,11 +353,10 @@ namespace StardewModdingAPI.Framework // dispose core components this.IsGameRunning = false; - this.ConsoleManager?.Dispose(); this.ContentCore?.Dispose(); this.CancellationToken?.Dispose(); - this.GameInstance?.Dispose(); - this.LogFile?.Dispose(); + this.Game?.Dispose(); + this.LogManager?.Dispose(); // dispose last to allow for any last-second log messages // end game (moved from Game1.OnExiting to let us clean up first) Process.GetCurrentProcess().Kill(); @@ -428,15 +376,7 @@ namespace StardewModdingAPI.Framework } // 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); - } + xTile.Format.FormatManager.Instance.RegisterMapFormat(new TMXTile.TMXFormat(Game1.tileSize / Game1.pixelZoom, Game1.tileSize / Game1.pixelZoom, Game1.pixelZoom, Game1.pixelZoom)); // load mod data ModToolkit toolkit = new ModToolkit(); @@ -444,14 +384,14 @@ namespace StardewModdingAPI.Framework // load mods { - this.Monitor.Log("Loading mod metadata...", LogLevel.Trace); + this.Monitor.Log("Loading mod metadata..."); ModResolver resolver = new ModResolver(); // log loose files { string[] looseFiles = new DirectoryInfo(this.ModsPath).GetFiles().Select(p => p.Name).ToArray(); if (looseFiles.Any()) - this.Monitor.Log($" Ignored loose files: {string.Join(", ", looseFiles.OrderBy(p => p, StringComparer.OrdinalIgnoreCase))}", LogLevel.Trace); + this.Monitor.Log($" Ignored loose files: {string.Join(", ", looseFiles.OrderBy(p => p, StringComparer.OrdinalIgnoreCase))}"); } // load manifests @@ -459,7 +399,7 @@ namespace StardewModdingAPI.Framework // filter out ignored mods foreach (IModMetadata mod in mods.Where(p => p.IsIgnored)) - this.Monitor.Log($" Skipped {mod.GetRelativePathWithRoot()} (folder name starts with a dot).", LogLevel.Trace); + this.Monitor.Log($" Skipped {mod.GetRelativePathWithRoot()} (folder name starts with a dot)."); mods = mods.Where(p => !p.IsIgnored).ToArray(); // load mods @@ -473,19 +413,591 @@ namespace StardewModdingAPI.Framework // update window titles int modsLoaded = this.ModRegistry.GetAll().Count(); - this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion} with {modsLoaded} mods"; - Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion} with {modsLoaded} mods"; + this.SetWindowTitles( + game: $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion} with {modsLoaded} mods", + smapi: $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion} with {modsLoaded} mods" + ); } - /// <summary>Initialize SMAPI and mods after the game starts.</summary> - private void InitializeAfterGameStart() + /// <summary>Raised after the game finishes initializing.</summary> + private void OnGameInitialized() { + // set initial state + this.Input.Update(); + + // init watchers + this.Watchers = new WatcherCore(this.Input, this.Game.GetObservableLocations()); + // validate XNB integrity if (!this.ValidateContentIntegrity()) this.Monitor.Log("SMAPI found problems in your game's content files which are likely to cause errors or crashes. Consider uninstalling XNB mods or reinstalling the game.", LogLevel.Error); // start SMAPI console - new Thread(this.RunConsoleLoop).Start(); + new Thread( + () => this.LogManager.RunConsoleInputLoop( + commandManager: this.CommandManager, + reloadTranslations: this.ReloadTranslations, + handleInput: input => this.CommandQueue.Enqueue(input), + continueWhile: () => this.IsGameRunning && !this.CancellationToken.IsCancellationRequested + ) + ).Start(); + } + + /// <summary>Raised after the game finishes loading its initial content.</summary> + private void OnGameContentLoaded() + { + // override map display device + Game1.mapDisplayDevice = new SDisplayDevice(Game1.content, Game1.game1.GraphicsDevice); + + // log GPU info +#if SMAPI_FOR_WINDOWS + this.Monitor.Log($"Running on GPU: {Game1.game1.GraphicsDevice?.Adapter?.Description ?? "<unknown>"}"); +#endif + } + + /// <summary>Raised when the game is updating its state (roughly 60 times per second).</summary> + /// <param name="gameTime">A snapshot of the game timing state.</param> + /// <param name="runGameUpdate">Invoke the game's update logic.</param> + private void OnGameUpdating(GameTime gameTime, Action runGameUpdate) + { + var events = this.EventManager; + + try + { + /********* + ** Safe queued work + *********/ + // print warnings/alerts + SCore.DeprecationManager.PrintQueued(); + SCore.PerformanceMonitor.PrintQueuedAlerts(); + + // reapply overrides + if (this.JustReturnedToTitle && !(Game1.mapDisplayDevice is SDisplayDevice)) + Game1.mapDisplayDevice = new SDisplayDevice(Game1.content, Game1.game1.GraphicsDevice); + + /********* + ** First-tick initialization + *********/ + if (!this.IsInitialized) + { + this.IsInitialized = true; + this.OnGameInitialized(); + } + + /********* + ** Update input + *********/ + // This should *always* run, even when suppressing mod events, since the game uses + // this too. For example, doing this after mod event suppression would prevent the + // user from doing anything on the overnight shipping screen. + SInputState inputState = this.Input; + if (this.Game.IsActive) + inputState.Update(); + + /********* + ** Special cases + *********/ + // Abort if SMAPI is exiting. + if (this.CancellationToken.IsCancellationRequested) + { + this.Monitor.Log("SMAPI shutting down: aborting update."); + return; + } + + // Run async tasks synchronously to avoid issues due to mod events triggering + // concurrently with game code. + bool saveParsed = false; + if (Game1.currentLoader != null) + { + this.Monitor.Log("Game loader synchronizing..."); + while (Game1.currentLoader?.MoveNext() == true) + { + // raise load stage changed + switch (Game1.currentLoader.Current) + { + case 20 when (!saveParsed && SaveGame.loaded != null): + saveParsed = true; + this.OnLoadStageChanged(LoadStage.SaveParsed); + break; + + case 36: + this.OnLoadStageChanged(LoadStage.SaveLoadedBasicInfo); + break; + + case 50: + this.OnLoadStageChanged(LoadStage.SaveLoadedLocations); + break; + + default: + if (Game1.gameMode == Game1.playingGameMode) + this.OnLoadStageChanged(LoadStage.Preloaded); + break; + } + } + + Game1.currentLoader = null; + this.Monitor.Log("Game loader done."); + } + + if (SGame.NewDayTask?.Status == TaskStatus.Created) + { + this.Monitor.Log("New day task synchronizing..."); + SGame.NewDayTask.RunSynchronously(); + this.Monitor.Log("New day task done."); + } + + // While a background task is in progress, the game may make changes to the game + // 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 + // update tick are negligible and not worth the complications of bypassing Game1.Update. + if (SGame.NewDayTask != null || Game1.gameMode == Game1.loadingMode) + { + events.UnvalidatedUpdateTicking.RaiseEmpty(); + SCore.TicksElapsed++; + runGameUpdate(); + events.UnvalidatedUpdateTicked.RaiseEmpty(); + return; + } + + // Raise minimal events while saving. + // While the game is writing to the save file in the background, mods can unexpectedly + // fail since they don't have exclusive access to resources (e.g. collection changed + // during enumeration errors). To avoid problems, events are not invoked while a save + // is in progress. It's safe to raise SaveEvents.BeforeSave as soon as the menu is + // opened (since the save hasn't started yet), but all other events should be suppressed. + if (Context.IsSaving) + { + // raise before-create + if (!Context.IsWorldReady && !this.IsBetweenCreateEvents) + { + this.IsBetweenCreateEvents = true; + this.Monitor.Log("Context: before save creation."); + events.SaveCreating.RaiseEmpty(); + } + + // raise before-save + if (Context.IsWorldReady && !this.IsBetweenSaveEvents) + { + this.IsBetweenSaveEvents = true; + this.Monitor.Log("Context: before save."); + events.Saving.RaiseEmpty(); + } + + // suppress non-save events + events.UnvalidatedUpdateTicking.RaiseEmpty(); + SCore.TicksElapsed++; + runGameUpdate(); + events.UnvalidatedUpdateTicked.RaiseEmpty(); + return; + } + + /********* + ** Reload assets when interceptors are added/removed + *********/ + if (this.ReloadAssetInterceptorsQueue.Any()) + { + // get unique interceptors + AssetInterceptorChange[] interceptors = this.ReloadAssetInterceptorsQueue + .GroupBy(p => p.Instance, new ObjectReferenceComparer<object>()) + .Select(p => p.First()) + .ToArray(); + this.ReloadAssetInterceptorsQueue.Clear(); + + // log summary + this.Monitor.Log("Invalidating cached assets for new editors & loaders..."); + this.Monitor.Log( + " changed: " + + string.Join(", ", + interceptors + .GroupBy(p => p.Mod) + .OrderBy(p => p.Key.DisplayName) + .Select(modGroup => + $"{modGroup.Key.DisplayName} (" + + string.Join(", ", modGroup.GroupBy(p => p.WasAdded).ToDictionary(p => p.Key, p => p.Count()).Select(p => $"{(p.Key ? "added" : "removed")} {p.Value}")) + + ")" + ) + ) + ); + + // reload affected assets + this.ContentCore.InvalidateCache(asset => interceptors.Any(p => p.CanIntercept(asset))); + } + + /********* + ** Execute commands + *********/ + while (this.CommandQueue.TryDequeue(out string rawInput)) + { + // parse command + string name; + string[] args; + Command command; + try + { + if (!this.CommandManager.TryParse(rawInput, out name, out args, out command)) + { + this.Monitor.Log("Unknown command; type 'help' for a list of available commands.", LogLevel.Error); + continue; + } + } + catch (Exception ex) + { + this.Monitor.Log($"Failed parsing that command:\n{ex.GetLogSummary()}", LogLevel.Error); + continue; + } + + // execute command + try + { + command.Callback.Invoke(name, args); + } + catch (Exception ex) + { + if (command.Mod != null) + command.Mod.LogAsMod($"Mod failed handling that command:\n{ex.GetLogSummary()}", LogLevel.Error); + else + this.Monitor.Log($"Failed handling that command:\n{ex.GetLogSummary()}", LogLevel.Error); + } + } + + /********* + ** Update context + *********/ + bool wasWorldReady = Context.IsWorldReady; + if ((Context.IsWorldReady && !Context.IsSaveLoaded) || Game1.exitToTitle) + { + Context.IsWorldReady = false; + this.AfterLoadTimer.Reset(); + } + else if (Context.IsSaveLoaded && this.AfterLoadTimer.Current > 0 && Game1.currentLocation != null) + { + if (Game1.dayOfMonth != 0) // wait until new-game intro finishes (world not fully initialized yet) + this.AfterLoadTimer.Decrement(); + Context.IsWorldReady = this.AfterLoadTimer.Current == 0; + } + + /********* + ** Update watchers + ** (Watchers need to be updated, checked, and reset in one go so we can detect any changes mods make in event handlers.) + *********/ + this.Watchers.Update(); + this.WatcherSnapshot.Update(this.Watchers); + this.Watchers.Reset(); + WatcherSnapshot state = this.WatcherSnapshot; + + /********* + ** Display in-game warnings + *********/ + // save content removed + if (this.IsSaveContentRemoved && Context.IsWorldReady) + { + this.IsSaveContentRemoved = false; + Game1.addHUDMessage(new HUDMessage(this.Translator.Get("warn.invalid-content-removed"), HUDMessage.error_type)); + } + + /********* + ** Pre-update events + *********/ + { + /********* + ** Save created/loaded events + *********/ + if (this.IsBetweenCreateEvents) + { + // raise after-create + this.IsBetweenCreateEvents = false; + this.Monitor.Log($"Context: after save creation, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}."); + this.OnLoadStageChanged(LoadStage.CreatedSaveFile); + events.SaveCreated.RaiseEmpty(); + } + + if (this.IsBetweenSaveEvents) + { + // raise after-save + this.IsBetweenSaveEvents = false; + this.Monitor.Log($"Context: after save, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}."); + events.Saved.RaiseEmpty(); + events.DayStarted.RaiseEmpty(); + } + + /********* + ** Locale changed events + *********/ + if (state.Locale.IsChanged) + this.Monitor.Log($"Context: locale set to {state.Locale.New}."); + + /********* + ** Load / return-to-title events + *********/ + if (wasWorldReady && !Context.IsWorldReady) + this.OnLoadStageChanged(LoadStage.None); + else if (Context.IsWorldReady && Context.LoadStage != LoadStage.Ready) + { + // print context + string context = $"Context: loaded save '{Constants.SaveFolderName}', starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}, locale set to {this.ContentCore.Language}."; + if (Context.IsMultiplayer) + { + int onlineCount = Game1.getOnlineFarmers().Count(); + context += $" {(Context.IsMainPlayer ? "Main player" : "Farmhand")} with {onlineCount} {(onlineCount == 1 ? "player" : "players")} online."; + } + else + context += " Single-player."; + + this.Monitor.Log(context); + + // raise events + this.OnLoadStageChanged(LoadStage.Ready); + events.SaveLoaded.RaiseEmpty(); + events.DayStarted.RaiseEmpty(); + } + + /********* + ** Window events + *********/ + // Here we depend on the game's viewport instead of listening to the Window.Resize + // event because we need to notify mods after the game handles the resize, so the + // game's metadata (like Game1.viewport) are updated. That's a bit complicated + // since the game adds & removes its own handler on the fly. + if (state.WindowSize.IsChanged) + { + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Events: window size changed to {state.WindowSize.New}."); + + events.WindowResized.Raise(new WindowResizedEventArgs(state.WindowSize.Old, state.WindowSize.New)); + } + + /********* + ** Input events (if window has focus) + *********/ + if (this.Game.IsActive) + { + // raise events + bool isChatInput = Game1.IsChatting || (Context.IsMultiplayer && Context.IsWorldReady && Game1.activeClickableMenu == null && Game1.currentMinigame == null && inputState.IsAnyDown(Game1.options.chatButton)); + if (!isChatInput) + { + ICursorPosition cursor = this.Input.CursorPosition; + + // raise cursor moved event + if (state.Cursor.IsChanged) + events.CursorMoved.Raise(new CursorMovedEventArgs(state.Cursor.Old, state.Cursor.New)); + + // raise mouse wheel scrolled + if (state.MouseWheelScroll.IsChanged) + { + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Events: mouse wheel scrolled to {state.MouseWheelScroll.New}."); + events.MouseWheelScrolled.Raise(new MouseWheelScrolledEventArgs(cursor, state.MouseWheelScroll.Old, state.MouseWheelScroll.New)); + } + + // raise input button events + foreach (var pair in inputState.ButtonStates) + { + SButton button = pair.Key; + SButtonState status = pair.Value; + + if (status == SButtonState.Pressed) + { + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Events: button {button} pressed."); + + events.ButtonPressed.Raise(new ButtonPressedEventArgs(button, cursor, inputState)); + } + else if (status == SButtonState.Released) + { + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Events: button {button} released."); + + events.ButtonReleased.Raise(new ButtonReleasedEventArgs(button, cursor, inputState)); + } + } + } + } + + /********* + ** Menu events + *********/ + if (state.ActiveMenu.IsChanged) + { + var was = state.ActiveMenu.Old; + var now = state.ActiveMenu.New; + + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Context: menu changed from {was?.GetType().FullName ?? "none"} to {now?.GetType().FullName ?? "none"}."); + + // raise menu events + events.MenuChanged.Raise(new MenuChangedEventArgs(was, now)); + } + + /********* + ** World & player events + *********/ + if (Context.IsWorldReady) + { + bool raiseWorldEvents = !state.SaveID.IsChanged; // don't report changes from unloaded => loaded + + // location list changes + if (state.Locations.LocationList.IsChanged && (events.LocationListChanged.HasListeners() || this.Monitor.IsVerbose)) + { + var added = state.Locations.LocationList.Added.ToArray(); + var removed = state.Locations.LocationList.Removed.ToArray(); + + if (this.Monitor.IsVerbose) + { + string addedText = added.Any() ? string.Join(", ", added.Select(p => p.Name)) : "none"; + string removedText = removed.Any() ? string.Join(", ", removed.Select(p => p.Name)) : "none"; + this.Monitor.Log($"Context: location list changed (added {addedText}; removed {removedText})."); + } + + events.LocationListChanged.Raise(new LocationListChangedEventArgs(added, removed)); + } + + // raise location contents changed + if (raiseWorldEvents) + { + foreach (LocationSnapshot locState in state.Locations.Locations) + { + var location = locState.Location; + + // buildings changed + if (locState.Buildings.IsChanged) + events.BuildingListChanged.Raise(new BuildingListChangedEventArgs(location, locState.Buildings.Added, locState.Buildings.Removed)); + + // debris changed + if (locState.Debris.IsChanged) + events.DebrisListChanged.Raise(new DebrisListChangedEventArgs(location, locState.Debris.Added, locState.Debris.Removed)); + + // large terrain features changed + if (locState.LargeTerrainFeatures.IsChanged) + events.LargeTerrainFeatureListChanged.Raise(new LargeTerrainFeatureListChangedEventArgs(location, locState.LargeTerrainFeatures.Added, locState.LargeTerrainFeatures.Removed)); + + // NPCs changed + if (locState.Npcs.IsChanged) + events.NpcListChanged.Raise(new NpcListChangedEventArgs(location, locState.Npcs.Added, locState.Npcs.Removed)); + + // objects changed + if (locState.Objects.IsChanged) + events.ObjectListChanged.Raise(new ObjectListChangedEventArgs(location, locState.Objects.Added, locState.Objects.Removed)); + + // chest items changed + if (events.ChestInventoryChanged.HasListeners()) + { + foreach (var pair in locState.ChestItems) + { + SnapshotItemListDiff diff = pair.Value; + events.ChestInventoryChanged.Raise(new ChestInventoryChangedEventArgs(pair.Key, location, added: diff.Added, removed: diff.Removed, quantityChanged: diff.QuantityChanged)); + } + } + + // terrain features changed + if (locState.TerrainFeatures.IsChanged) + events.TerrainFeatureListChanged.Raise(new TerrainFeatureListChangedEventArgs(location, locState.TerrainFeatures.Added, locState.TerrainFeatures.Removed)); + } + } + + // raise time changed + if (raiseWorldEvents && state.Time.IsChanged) + events.TimeChanged.Raise(new TimeChangedEventArgs(state.Time.Old, state.Time.New)); + + // raise player events + if (raiseWorldEvents) + { + PlayerSnapshot playerState = state.CurrentPlayer; + Farmer player = playerState.Player; + + // raise current location changed + if (playerState.Location.IsChanged) + { + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Context: set location to {playerState.Location.New}."); + + events.Warped.Raise(new WarpedEventArgs(player, playerState.Location.Old, playerState.Location.New)); + } + + // raise player leveled up a skill + foreach (var pair in playerState.Skills) + { + if (!pair.Value.IsChanged) + continue; + + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Events: player skill '{pair.Key}' changed from {pair.Value.Old} to {pair.Value.New}."); + + events.LevelChanged.Raise(new LevelChangedEventArgs(player, pair.Key, pair.Value.Old, pair.Value.New)); + } + + // raise player inventory changed + if (playerState.Inventory.IsChanged) + { + var inventory = playerState.Inventory; + + if (this.Monitor.IsVerbose) + this.Monitor.Log("Events: player inventory changed."); + events.InventoryChanged.Raise(new InventoryChangedEventArgs(player, added: inventory.Added, removed: inventory.Removed, quantityChanged: inventory.QuantityChanged)); + } + } + } + + /********* + ** Game update + *********/ + // game launched + bool isFirstTick = SCore.TicksElapsed == 0; + if (isFirstTick) + { + Context.IsGameLaunched = true; + events.GameLaunched.Raise(new GameLaunchedEventArgs()); + } + + // preloaded + if (Context.IsSaveLoaded && Context.LoadStage != LoadStage.Loaded && Context.LoadStage != LoadStage.Ready && Game1.dayOfMonth != 0) + this.OnLoadStageChanged(LoadStage.Loaded); + } + + /********* + ** Game update tick + *********/ + { + bool isOneSecond = SCore.TicksElapsed % 60 == 0; + events.UnvalidatedUpdateTicking.RaiseEmpty(); + events.UpdateTicking.RaiseEmpty(); + if (isOneSecond) + events.OneSecondUpdateTicking.RaiseEmpty(); + try + { + this.Input.ApplyOverrides(); // if mods added any new overrides since the update, process them now + SCore.TicksElapsed++; + runGameUpdate(); + } + catch (Exception ex) + { + this.LogManager.MonitorForGame.Log($"An error occured in the base update loop: {ex.GetLogSummary()}", LogLevel.Error); + } + + events.UnvalidatedUpdateTicked.RaiseEmpty(); + events.UpdateTicked.RaiseEmpty(); + if (isOneSecond) + events.OneSecondUpdateTicked.RaiseEmpty(); + } + + /********* + ** Update events + *********/ + this.UpdateCrashTimer.Reset(); + } + catch (Exception ex) + { + // log error + this.Monitor.Log($"An error occured in the overridden update loop: {ex.GetLogSummary()}", LogLevel.Error); + + // exit if irrecoverable + if (!this.UpdateCrashTimer.Decrement()) + this.ExitGameImmediately("The game crashed when updating, and SMAPI was unable to recover the game."); + } } /// <summary>Handle the game changing locale.</summary> @@ -505,53 +1017,109 @@ namespace StardewModdingAPI.Framework mod.Translations.SetLocale(locale, languageCode); } - /// <summary>Run a loop handling console input.</summary> - [SuppressMessage("ReSharper", "FunctionNeverReturns", Justification = "The thread is aborted when the game exits.")] - private void RunConsoleLoop() + /// <summary>Raised when the low-level stage while loading a save changes.</summary> + /// <param name="newStage">The new load stage.</param> + internal void OnLoadStageChanged(LoadStage newStage) + { + // nothing to do + if (newStage == Context.LoadStage) + return; + + // update data + LoadStage oldStage = Context.LoadStage; + Context.LoadStage = newStage; + this.Monitor.VerboseLog($"Context: load stage changed to {newStage}"); + if (newStage == LoadStage.None) + { + this.Monitor.Log("Context: returned to title"); + this.OnReturnedToTitle(); + } + + // raise events + this.EventManager.LoadStageChanged.Raise(new LoadStageChangedEventArgs(oldStage, newStage)); + if (newStage == LoadStage.None) + this.EventManager.ReturnedToTitle.RaiseEmpty(); + } + + /// <summary>Raised after custom content is removed from the save data to avoid a crash.</summary> + internal void OnSaveContentRemoved() + { + this.IsSaveContentRemoved = true; + } + + /// <summary>A callback invoked before <see cref="Game1.newDayAfterFade"/> runs.</summary> + protected void OnNewDayAfterFade() + { + this.EventManager.DayEnding.RaiseEmpty(); + } + + /// <summary>Raised after the player returns to the title screen.</summary> + private void OnReturnedToTitle() + { + // perform cleanup + this.Multiplayer.CleanupOnMultiplayerExit(); + this.JustReturnedToTitle = true; + } + + /// <summary>Raised before the game exits.</summary> + private void OnGameExiting() + { + this.Multiplayer.Disconnect(StardewValley.Multiplayer.DisconnectType.ClosedGame); + this.Dispose(); + } + + /// <summary>Raised when a mod network message is received.</summary> + /// <param name="message">The message to deliver to applicable mods.</param> + private void OnModMessageReceived(ModMessageModel message) + { + // get mod IDs to notify + HashSet<string> modIDs = new HashSet<string>(message.ToModIDs ?? this.ModRegistry.GetAll().Select(p => p.Manifest.UniqueID), StringComparer.OrdinalIgnoreCase); + if (message.FromPlayerID == Game1.player?.UniqueMultiplayerID) + modIDs.Remove(message.FromModID); // don't send a broadcast back to the sender + + // raise events + this.EventManager.ModMessageReceived.Raise(new ModMessageReceivedEventArgs(message), mod => mod != null && modIDs.Contains(mod.Manifest.UniqueID)); + } + + /// <summary>Constructor a content manager to read game content files.</summary> + /// <param name="serviceProvider">The service provider to use to locate services.</param> + /// <param name="rootDirectory">The root directory to search for content.</param> + private LocalizedContentManager CreateContentManager(IServiceProvider serviceProvider, string rootDirectory) { - // prepare console - this.Monitor.Log("Type 'help' for help, or 'help <cmd>' for a command's usage", LogLevel.Info); - this.GameInstance.CommandManager - .Add(new HelpCommand(this.GameInstance.CommandManager), this.Monitor) - .Add(new HarmonySummaryCommand(), this.Monitor) - .Add(new ReloadI18nCommand(this.ReloadTranslations), this.Monitor); - - // start handling command line input - Thread inputThread = new Thread(() => + // Game1._temporaryContent initializing from SGame constructor + if (this.ContentCore == null) { - while (true) - { - // get input - string input = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(input)) - continue; + this.ContentCore = new ContentCoordinator(serviceProvider, rootDirectory, Thread.CurrentThread.CurrentUICulture, this.Monitor, this.Reflection, this.Toolkit.JsonHelper, this.InitializeBeforeFirstAssetLoaded); + if (this.ContentCore.Language != this.Translator.LocaleEnum) + this.Translator.SetLocale(this.ContentCore.GetLocale(), this.ContentCore.Language); - // handle command - this.Monitor.LogUserInput(input); - this.GameInstance.CommandQueue.Enqueue(input); - } - }); - inputThread.Start(); - - // keep console thread alive while the game is running - while (this.IsGameRunning && !this.CancellationToken.IsCancellationRequested) - Thread.Sleep(1000 / 10); - if (inputThread.ThreadState == ThreadState.Running) - inputThread.Abort(); + this.NextContentManagerIsMain = true; + return this.ContentCore.CreateGameContentManager("Game1._temporaryContent"); + } + + // Game1.content initializing from LoadContent + if (this.NextContentManagerIsMain) + { + this.NextContentManagerIsMain = false; + return this.ContentCore.MainContentManager; + } + + // any other content manager + return this.ContentCore.CreateGameContentManager("(generated)"); } /// <summary>Look for common issues with the game's XNB content, and log warnings if anything looks broken or outdated.</summary> /// <returns>Returns whether all integrity checks passed.</returns> private bool ValidateContentIntegrity() { - this.Monitor.Log("Detecting common issues...", LogLevel.Trace); + this.Monitor.Log("Detecting common issues..."); bool issuesFound = false; // object format (commonly broken by outdated files) { // detect issues bool hasObjectIssues = false; - void LogIssue(int id, string issue) => this.Monitor.Log($@"Detected issue: item #{id} in Content\Data\ObjectInformation.xnb is invalid ({issue}).", LogLevel.Trace); + void LogIssue(int id, string issue) => this.Monitor.Log($@"Detected issue: item #{id} in Content\Data\ObjectInformation.xnb is invalid ({issue})."); foreach (KeyValuePair<int, string> entry in Game1.objectInformation) { // must not be empty @@ -564,7 +1132,7 @@ namespace StardewModdingAPI.Framework // require core fields string[] fields = entry.Value.Split('/'); - if (fields.Length < Object.objectInfoDescriptionIndex + 1) + if (fields.Length < SObject.objectInfoDescriptionIndex + 1) { LogIssue(entry.Key, "too few fields for an object"); hasObjectIssues = true; @@ -572,10 +1140,10 @@ namespace StardewModdingAPI.Framework } // check min length for specific types - switch (fields[Object.objectInfoTypeIndex].Split(new[] { ' ' }, 2)[0]) + switch (fields[SObject.objectInfoTypeIndex].Split(new[] { ' ' }, 2)[0]) { case "Cooking": - if (fields.Length < Object.objectInfoBuffDurationIndex + 1) + if (fields.Length < SObject.objectInfoBuffDurationIndex + 1) { LogIssue(entry.Key, "too few fields for a cooking item"); hasObjectIssues = true; @@ -595,6 +1163,15 @@ namespace StardewModdingAPI.Framework return !issuesFound; } + /// <summary>Set the window titles for the game and console windows.</summary> + /// <param name="game">The game window text.</param> + /// <param name="smapi">The SMAPI window text.</param> + private void SetWindowTitles(string game, string smapi) + { + this.Game.Window.Title = game; + this.LogManager.SetConsoleTitle(smapi); + } + /// <summary>Asynchronously check for a new version of SMAPI and any installed mods, and print alerts to the console if an update is available.</summary> /// <param name="mods">The mods to include in the update check (if eligible).</param> private void CheckForUpdatesAsync(IModMetadata[] mods) @@ -610,7 +1187,7 @@ namespace StardewModdingAPI.Framework url = url.Replace("https://", "http://"); // workaround for OpenSSL issues with the game's bundled Mono on Linux/Mac #endif WebApiClient client = new WebApiClient(url, Constants.ApiVersion); - this.Monitor.Log("Checking for updates...", LogLevel.Trace); + this.Monitor.Log("Checking for updates..."); // check SMAPI version ISemanticVersion updateFound = null; @@ -621,7 +1198,7 @@ namespace StardewModdingAPI.Framework if (response.SuggestedUpdate != null) this.Monitor.Log($"You can update SMAPI to {response.SuggestedUpdate.Version}: {Constants.HomePageUrl}", LogLevel.Alert); else - this.Monitor.Log(" SMAPI okay.", LogLevel.Trace); + this.Monitor.Log(" SMAPI okay."); updateFound = response.SuggestedUpdate?.Version; @@ -629,7 +1206,7 @@ namespace StardewModdingAPI.Framework if (response.Errors.Any()) { this.Monitor.Log("Couldn't check for a new version of SMAPI. This won't affect your game, but you may not be notified of new versions if this keeps happening.", LogLevel.Warn); - this.Monitor.Log($"Error: {string.Join("\n", response.Errors)}", LogLevel.Trace); + this.Monitor.Log($"Error: {string.Join("\n", response.Errors)}"); } } catch (Exception ex) @@ -637,13 +1214,13 @@ namespace StardewModdingAPI.Framework this.Monitor.Log("Couldn't check for a new version of SMAPI. This won't affect your game, but you won't be notified of new versions if this keeps happening.", LogLevel.Warn); this.Monitor.Log(ex is WebException && ex.InnerException == null ? $"Error: {ex.Message}" - : $"Error: {ex.GetLogSummary()}", LogLevel.Trace + : $"Error: {ex.GetLogSummary()}" ); } // show update message on next launch if (updateFound != null) - File.WriteAllText(Constants.UpdateMarker, updateFound.ToString()); + this.LogManager.WriteUpdateMarker(updateFound.ToString()); // check mod versions if (mods.Any()) @@ -667,7 +1244,7 @@ namespace StardewModdingAPI.Framework } // fetch results - this.Monitor.Log($" Checking for updates to {searchMods.Count} mods...", LogLevel.Trace); + this.Monitor.Log($" Checking for updates to {searchMods.Count} mods..."); IDictionary<string, ModEntryModel> results = client.GetModInfo(searchMods.ToArray(), apiVersion: Constants.ApiVersion, gameVersion: Constants.GameVersion, platform: Constants.Platform); // extract update alerts & errors @@ -696,7 +1273,7 @@ namespace StardewModdingAPI.Framework // show update errors if (errors.Length != 0) - this.Monitor.Log("Got update-check errors for some mods:\n" + errors.ToString().TrimEnd(), LogLevel.Trace); + this.Monitor.Log("Got update-check errors for some mods:\n" + errors.ToString().TrimEnd()); // show update alerts if (updates.Any()) @@ -712,14 +1289,14 @@ namespace StardewModdingAPI.Framework } } else - this.Monitor.Log(" All mods up to date.", LogLevel.Trace); + this.Monitor.Log(" All mods up to date."); } catch (Exception ex) { this.Monitor.Log("Couldn't check for new mod versions. This won't affect your game, but you won't be notified of mod updates if this keeps happening.", LogLevel.Warn); this.Monitor.Log(ex is WebException && ex.InnerException == null ? ex.Message - : ex.ToString(), LogLevel.Trace + : ex.ToString() ); } } @@ -749,29 +1326,28 @@ namespace StardewModdingAPI.Framework /// <param name="modDatabase">Handles access to SMAPI's internal mod metadata list.</param> private void LoadMods(IModMetadata[] mods, JsonHelper jsonHelper, ContentCoordinator contentCore, ModDatabase modDatabase) { - this.Monitor.Log("Loading mods...", LogLevel.Trace); + this.Monitor.Log("Loading mods..."); // load mods - IDictionary<IModMetadata, Tuple<string, string>> skippedMods = new Dictionary<IModMetadata, Tuple<string, string>>(); + IList<IModMetadata> skippedMods = new List<IModMetadata>(); using (AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.Platform, this.Monitor, this.Settings.ParanoidWarnings)) { // init HashSet<string> suppressUpdateChecks = new HashSet<string>(this.Settings.SuppressUpdateChecks, StringComparer.OrdinalIgnoreCase); InterfaceProxyFactory proxyFactory = new InterfaceProxyFactory(); - void LogSkip(IModMetadata mod, string errorPhrase, string errorDetails) - { - skippedMods[mod] = Tuple.Create(errorPhrase, errorDetails); - if (mod.Status != ModMetadataStatus.Failed) - mod.SetStatus(ModMetadataStatus.Failed, errorPhrase); - } // load mods - foreach (IModMetadata contentPack in mods) + foreach (IModMetadata mod in mods) { - if (!this.TryLoadMod(contentPack, mods, modAssemblyLoader, proxyFactory, jsonHelper, contentCore, modDatabase, suppressUpdateChecks, out string errorPhrase, out string errorDetails)) - LogSkip(contentPack, errorPhrase, errorDetails); + if (!this.TryLoadMod(mod, mods, modAssemblyLoader, proxyFactory, jsonHelper, contentCore, modDatabase, suppressUpdateChecks, out ModFailReason? failReason, out string errorPhrase, out string errorDetails)) + { + failReason ??= ModFailReason.LoadFailed; + mod.SetStatus(ModMetadataStatus.Failed, failReason.Value, errorPhrase, errorDetails); + skippedMods.Add(mod); + } } } + IModMetadata[] loaded = this.ModRegistry.GetAll().ToArray(); IModMetadata[] loadedContentPacks = loaded.Where(p => p.IsContentPack).ToArray(); IModMetadata[] loadedMods = loaded.Where(p => !p.IsContentPack).ToArray(); @@ -779,42 +1355,8 @@ namespace StardewModdingAPI.Framework // unlock content packs this.ModRegistry.AreAllModsLoaded = true; - // log loaded mods - this.Monitor.Log($"Loaded {loadedMods.Length} mods" + (loadedMods.Length > 0 ? ":" : "."), LogLevel.Info); - foreach (IModMetadata metadata in loadedMods.OrderBy(p => p.DisplayName)) - { - IManifest manifest = metadata.Manifest; - this.Monitor.Log( - $" {metadata.DisplayName} {manifest.Version}" - + (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "") - + (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""), - LogLevel.Info - ); - } - this.Monitor.Newline(); - - // log loaded content packs - if (loadedContentPacks.Any()) - { - string GetModDisplayName(string id) => loadedMods.FirstOrDefault(p => p.HasID(id))?.DisplayName; - - this.Monitor.Log($"Loaded {loadedContentPacks.Length} content packs:", LogLevel.Info); - foreach (IModMetadata metadata in loadedContentPacks.OrderBy(p => p.DisplayName)) - { - IManifest manifest = metadata.Manifest; - this.Monitor.Log( - $" {metadata.DisplayName} {manifest.Version}" - + (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "") - + $" | for {GetModDisplayName(metadata.Manifest.ContentPackFor.UniqueID)}" - + (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""), - LogLevel.Info - ); - } - this.Monitor.Newline(); - } - - // log mod warnings - this.LogModWarnings(loaded, skippedMods); + // log mod info + this.LogManager.LogModInfo(loaded, loadedContentPacks, loadedMods, skippedMods.ToArray(), this.Settings.ParanoidWarnings); // initialize translations this.ReloadTranslations(loaded); @@ -832,8 +1374,8 @@ namespace StardewModdingAPI.Framework this.ContentCore.Loaders.Add(new ModLinked<IAssetLoader>(metadata, loader)); // ReSharper restore SuspiciousTypeConversion.Global - 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); + helper.ObservableAssetEditors.CollectionChanged += (sender, e) => this.OnAssetInterceptorsChanged(metadata, e.NewItems?.Cast<IAssetEditor>(), e.OldItems?.Cast<IAssetEditor>(), this.ContentCore.Editors); + helper.ObservableAssetLoaders.CollectionChanged += (sender, e) => this.OnAssetInterceptorsChanged(metadata, e.NewItems?.Cast<IAssetLoader>(), e.OldItems?.Cast<IAssetLoader>(), this.ContentCore.Loaders); } // call entry method @@ -858,7 +1400,7 @@ namespace StardewModdingAPI.Framework } if (api != null) - this.Monitor.Log($" Found mod-provided API ({api.GetType().FullName}).", LogLevel.Trace); + this.Monitor.Log($" Found mod-provided API ({api.GetType().FullName})."); metadata.SetApi(api); } catch (Exception ex) @@ -867,34 +1409,27 @@ namespace StardewModdingAPI.Framework } } - // invalidate cache entries when needed - // (These listeners are registered after Entry to avoid repeatedly reloading assets as mods initialize.) - foreach (IModMetadata metadata in loadedMods) - { - if (metadata.Mod.Helper.Content is ContentHelper helper) - { - helper.ObservableAssetEditors.CollectionChanged += (sender, e) => this.GameInstance.OnAssetInterceptorsChanged(metadata, e.NewItems, e.OldItems); - helper.ObservableAssetLoaders.CollectionChanged += (sender, e) => this.GameInstance.OnAssetInterceptorsChanged(metadata, e.NewItems, e.OldItems); - } - } - // unlock mod integrations this.ModRegistry.AreAllModsInitialized = true; } - /// <summary>Handle a mod adding or removing asset interceptors.</summary> + /// <summary>Raised after a mod adds or removes 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) + /// <param name="list">A list of interceptors to update for the change.</param> + private void OnAssetInterceptorsChanged<T>(IModMetadata mod, IEnumerable<T> added, IEnumerable<T> removed, IList<ModLinked<T>> list) { foreach (T interceptor in added ?? new T[0]) + { + this.ReloadAssetInterceptorsQueue.Add(new AssetInterceptorChange(mod, interceptor, wasAdded: true)); list.Add(new ModLinked<T>(mod, interceptor)); + } foreach (T interceptor in removed ?? new T[0]) { + this.ReloadAssetInterceptorsQueue.Add(new AssetInterceptorChange(mod, interceptor, wasAdded: false)); foreach (ModLinked<T> entry in list.Where(p => p.Mod == mod && object.ReferenceEquals(p.Data, interceptor)).ToArray()) list.Remove(entry); } @@ -903,16 +1438,17 @@ namespace StardewModdingAPI.Framework /// <summary>Load a given mod.</summary> /// <param name="mod">The mod to load.</param> /// <param name="mods">The mods being loaded.</param> - /// <param name="assemblyLoader">Preprocesses and loads mod assemblies</param> + /// <param name="assemblyLoader">Preprocesses and loads mod assemblies.</param> /// <param name="proxyFactory">Generates proxy classes to access mod APIs through an arbitrary interface.</param> /// <param name="jsonHelper">The JSON helper with which to read mods' JSON files.</param> /// <param name="contentCore">The content manager to use for mod content.</param> /// <param name="modDatabase">Handles access to SMAPI's internal mod metadata list.</param> /// <param name="suppressUpdateChecks">The mod IDs to ignore when validating update keys.</param> + /// <param name="failReason">The reason the mod couldn't be loaded, if applicable.</param> /// <param name="errorReasonPhrase">The user-facing reason phrase explaining why the mod couldn't be loaded (if applicable).</param> /// <param name="errorDetails">More detailed details about the error intended for developers (if any).</param> /// <returns>Returns whether the mod was successfully loaded.</returns> - private bool TryLoadMod(IModMetadata mod, IModMetadata[] mods, AssemblyLoader assemblyLoader, InterfaceProxyFactory proxyFactory, JsonHelper jsonHelper, ContentCoordinator contentCore, ModDatabase modDatabase, HashSet<string> suppressUpdateChecks, out string errorReasonPhrase, out string errorDetails) + private bool TryLoadMod(IModMetadata mod, IModMetadata[] mods, AssemblyLoader assemblyLoader, InterfaceProxyFactory proxyFactory, JsonHelper jsonHelper, ContentCoordinator contentCore, ModDatabase modDatabase, HashSet<string> suppressUpdateChecks, out ModFailReason? failReason, out string errorReasonPhrase, out string errorDetails) { errorDetails = null; @@ -920,11 +1456,11 @@ namespace StardewModdingAPI.Framework { string relativePath = mod.GetRelativePathWithRoot(); if (mod.IsContentPack) - this.Monitor.Log($" {mod.DisplayName} (from {relativePath}) [content pack]...", LogLevel.Trace); + this.Monitor.Log($" {mod.DisplayName} (from {relativePath}) [content pack]..."); else if (mod.Manifest?.EntryDll != null) - this.Monitor.Log($" {mod.DisplayName} (from {relativePath}{Path.DirectorySeparatorChar}{mod.Manifest.EntryDll})...", LogLevel.Trace); // don't use Path.Combine here, since EntryDLL might not be valid + this.Monitor.Log($" {mod.DisplayName} (from {relativePath}{Path.DirectorySeparatorChar}{mod.Manifest.EntryDll})..."); // don't use Path.Combine here, since EntryDLL might not be valid else - this.Monitor.Log($" {mod.DisplayName} (from {relativePath})...", LogLevel.Trace); + this.Monitor.Log($" {mod.DisplayName} (from {relativePath})..."); } // add warning for missing update key @@ -934,7 +1470,8 @@ namespace StardewModdingAPI.Framework // validate status if (mod.Status == ModMetadataStatus.Failed) { - this.Monitor.Log($" Failed: {mod.Error}", LogLevel.Trace); + this.Monitor.Log($" Failed: {mod.Error}"); + failReason = mod.FailReason; errorReasonPhrase = mod.Error; return false; } @@ -951,6 +1488,7 @@ namespace StardewModdingAPI.Framework .FirstOrDefault(otherMod => otherMod.HasID(dependency.UniqueID)) ?.DisplayName ?? dependency.UniqueID; errorReasonPhrase = $"it needs the '{dependencyName}' mod, which couldn't be loaded."; + failReason = ModFailReason.MissingDependencies; return false; } } @@ -960,7 +1498,7 @@ namespace StardewModdingAPI.Framework if (mod.IsContentPack) { IManifest manifest = mod.Manifest; - IMonitor monitor = this.GetSecondaryMonitor(mod.DisplayName); + IMonitor monitor = this.LogManager.GetMonitor(mod.DisplayName); IContentHelper contentHelper = new ContentHelper(this.ContentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, monitor); TranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, contentCore.GetLocale(), contentCore.Language); IContentPack contentPack = new ContentPack(mod.DirectoryPath, manifest, contentHelper, translationHelper, jsonHelper); @@ -968,6 +1506,7 @@ namespace StardewModdingAPI.Framework this.ModRegistry.Add(mod); errorReasonPhrase = null; + failReason = null; return true; } @@ -983,24 +1522,27 @@ namespace StardewModdingAPI.Framework Assembly modAssembly; try { - modAssembly = assemblyLoader.Load(mod, assemblyPath, assumeCompatible: mod.DataRecord?.Status == ModStatus.AssumeCompatible, rewriteInParallel: this.Settings.RewriteInParallel); + modAssembly = assemblyLoader.Load(mod, assemblyPath, assumeCompatible: mod.DataRecord?.Status == ModStatus.AssumeCompatible); this.ModRegistry.TrackAssemblies(mod, modAssembly); } catch (IncompatibleInstructionException) // details already in trace logs { string[] updateUrls = new[] { modDatabase.GetModPageUrlFor(manifest.UniqueID), "https://smapi.io/mods" }.Where(p => p != null).ToArray(); errorReasonPhrase = $"it's no longer compatible. Please check for a new version at {string.Join(" or ", updateUrls)}"; + failReason = ModFailReason.Incompatible; return false; } catch (SAssemblyLoadFailedException ex) { errorReasonPhrase = $"its DLL couldn't be loaded: {ex.Message}"; + failReason = ModFailReason.LoadFailed; return false; } catch (Exception ex) { errorReasonPhrase = "its DLL couldn't be loaded."; errorDetails = $"Error: {ex.GetLogSummary()}"; + failReason = ModFailReason.LoadFailed; return false; } @@ -1009,7 +1551,10 @@ namespace StardewModdingAPI.Framework { // get mod instance if (!this.TryLoadModEntry(modAssembly, out Mod modEntry, out errorReasonPhrase)) + { + failReason = ModFailReason.LoadFailed; return false; + } // get content packs IContentPack[] GetContentPacks() @@ -1025,28 +1570,28 @@ namespace StardewModdingAPI.Framework } // init mod helpers - IMonitor monitor = this.GetSecondaryMonitor(mod.DisplayName); + IMonitor monitor = this.LogManager.GetMonitor(mod.DisplayName); TranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, contentCore.GetLocale(), contentCore.Language); IModHelper modHelper; { IContentPack CreateFakeContentPack(string packDirPath, IManifest packManifest) { - IMonitor packMonitor = this.GetSecondaryMonitor(packManifest.Name); + IMonitor packMonitor = this.LogManager.GetMonitor(packManifest.Name); IContentHelper packContentHelper = new ContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor); ITranslationHelper packTranslationHelper = new TranslationHelper(packManifest.UniqueID, contentCore.GetLocale(), contentCore.Language); return new ContentPack(packDirPath, packManifest, packContentHelper, packTranslationHelper, this.Toolkit.JsonHelper); } IModEvents events = new ModEvents(mod, this.EventManager); - ICommandHelper commandHelper = new CommandHelper(mod, this.GameInstance.CommandManager); + ICommandHelper commandHelper = new CommandHelper(mod, this.CommandManager); IContentHelper contentHelper = new ContentHelper(contentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, monitor); IContentPackHelper contentPackHelper = new ContentPackHelper(manifest.UniqueID, new Lazy<IContentPack[]>(GetContentPacks), CreateFakeContentPack); IDataHelper dataHelper = new DataHelper(manifest.UniqueID, mod.DirectoryPath, jsonHelper); IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, mod.DisplayName, this.Reflection); IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry, proxyFactory, monitor); - IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(manifest.UniqueID, this.GameInstance.Multiplayer); + IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(manifest.UniqueID, this.Multiplayer); - modHelper = new ModHelper(manifest.UniqueID, mod.DirectoryPath, this.GameInstance.Input, events, contentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper); + modHelper = new ModHelper(manifest.UniqueID, mod.DirectoryPath, this.Input, events, contentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper); } // init mod @@ -1057,192 +1602,18 @@ namespace StardewModdingAPI.Framework // track mod mod.SetMod(modEntry, translationHelper); this.ModRegistry.Add(mod); + failReason = null; return true; } catch (Exception ex) { errorReasonPhrase = $"initialization failed:\n{ex.GetLogSummary()}"; + failReason = ModFailReason.LoadFailed; return false; } } } - /// <summary>Write a summary of mod warnings to the console and log.</summary> - /// <param name="mods">The loaded mods.</param> - /// <param name="skippedMods">The mods which were skipped, along with the friendly and developer reasons.</param> - private void LogModWarnings(IEnumerable<IModMetadata> mods, IDictionary<IModMetadata, Tuple<string, string>> skippedMods) - { - // get mods with warnings - IModMetadata[] modsWithWarnings = mods.Where(p => p.Warnings != ModWarning.None).ToArray(); - if (!modsWithWarnings.Any() && !skippedMods.Any()) - return; - - // log intro - { - int count = modsWithWarnings.Union(skippedMods.Keys).Count(); - this.Monitor.Log($"Found {count} mod{(count == 1 ? "" : "s")} with warnings:", LogLevel.Info); - } - - // log skipped mods - if (skippedMods.Any()) - { - // get logging logic - HashSet<string> logged = new HashSet<string>(); - void LogSkippedMod(IModMetadata mod, string errorReason, string errorDetails) - { - string message = $" - {mod.DisplayName}{(mod.Manifest?.Version != null ? " " + mod.Manifest.Version.ToString() : "")} because {errorReason}"; - - if (logged.Add($"{message}|{errorDetails}")) - { - this.Monitor.Log(message, LogLevel.Error); - if (errorDetails != null) - this.Monitor.Log($" ({errorDetails})", LogLevel.Trace); - } - } - - // find skipped dependencies - KeyValuePair<IModMetadata, Tuple<string, string>>[] skippedDependencies; - { - HashSet<string> skippedDependencyIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); - HashSet<string> skippedModIds = new HashSet<string>(from mod in skippedMods where mod.Key.HasID() select mod.Key.Manifest.UniqueID, StringComparer.OrdinalIgnoreCase); - foreach (IModMetadata mod in skippedMods.Keys) - { - foreach (string requiredId in skippedModIds.Intersect(mod.GetRequiredModIds())) - skippedDependencyIds.Add(requiredId); - } - skippedDependencies = skippedMods.Where(p => p.Key.HasID() && skippedDependencyIds.Contains(p.Key.Manifest.UniqueID)).ToArray(); - } - - // log skipped mods - this.Monitor.Log(" Skipped mods", LogLevel.Error); - this.Monitor.Log(" " + "".PadRight(50, '-'), LogLevel.Error); - this.Monitor.Log(" These mods could not be added to your game.", LogLevel.Error); - this.Monitor.Newline(); - - if (skippedDependencies.Any()) - { - foreach (var pair in skippedDependencies.OrderBy(p => p.Key.DisplayName)) - LogSkippedMod(pair.Key, pair.Value.Item1, pair.Value.Item2); - this.Monitor.Newline(); - } - - foreach (var pair in skippedMods.OrderBy(p => p.Key.DisplayName)) - LogSkippedMod(pair.Key, pair.Value.Item1, pair.Value.Item2); - this.Monitor.Newline(); - } - - // log warnings - if (modsWithWarnings.Any()) - { - // broken code - this.LogModWarningGroup(modsWithWarnings, ModWarning.BrokenCodeLoaded, LogLevel.Error, "Broken mods", - "These mods have broken code, but you configured SMAPI to load them anyway. This may cause bugs,", - "errors, or crashes in-game." - ); - - // changes serializer - this.LogModWarningGroup(modsWithWarnings, ModWarning.ChangesSaveSerializer, LogLevel.Warn, "Changed save serializer", - "These mods change the save serializer. They may corrupt your save files, or make them unusable if", - "you uninstall these mods." - ); - - // patched game code - this.LogModWarningGroup(modsWithWarnings, ModWarning.PatchesGame, LogLevel.Info, "Patched game code", - "These mods directly change the game code. They're more likely to cause errors or bugs in-game; if", - "your game has issues, try removing these first. Otherwise you can ignore this warning." - ); - - // unvalidated update tick - this.LogModWarningGroup(modsWithWarnings, ModWarning.UsesUnvalidatedUpdateTick, LogLevel.Info, "Bypassed safety checks", - "These mods bypass SMAPI's normal safety checks, so they're more likely to cause errors or save", - "corruption. If your game has issues, try removing these first." - ); - - // paranoid warnings - if (this.Settings.ParanoidWarnings) - { - this.LogModWarningGroup( - modsWithWarnings, - match: mod => mod.HasUnsuppressedWarnings(ModWarning.AccessesConsole, ModWarning.AccessesFilesystem, ModWarning.AccessesShell), - level: LogLevel.Debug, - heading: "Direct system access", - blurb: new[] - { - "You enabled paranoid warnings and these mods directly access the filesystem, shells/processes, or", - "SMAPI console. (This is usually legitimate and innocent usage; this warning is only useful for", - "further investigation.)" - }, - modLabel: mod => - { - List<string> labels = new List<string>(); - if (mod.HasUnsuppressedWarnings(ModWarning.AccessesConsole)) - labels.Add("console"); - if (mod.HasUnsuppressedWarnings(ModWarning.AccessesFilesystem)) - labels.Add("files"); - if (mod.HasUnsuppressedWarnings(ModWarning.AccessesShell)) - labels.Add("shells/processes"); - - return $"{mod.DisplayName} ({string.Join(", ", labels)})"; - } - ); - } - - // no update keys - this.LogModWarningGroup(modsWithWarnings, ModWarning.NoUpdateKeys, LogLevel.Debug, "No update keys", - "These mods have no update keys in their manifest. SMAPI may not notify you about updates for these", - "mods. Consider notifying the mod authors about this problem." - ); - - // not crossplatform - this.LogModWarningGroup(modsWithWarnings, ModWarning.UsesDynamic, LogLevel.Debug, "Not crossplatform", - "These mods use the 'dynamic' keyword, and won't work on Linux/Mac." - ); - } - } - - /// <summary>Write a mod warning group to the console and log.</summary> - /// <param name="mods">The mods to search.</param> - /// <param name="match">Matches mods to include in the warning group.</param> - /// <param name="level">The log level for the logged messages.</param> - /// <param name="heading">A brief heading label for the group.</param> - /// <param name="blurb">A detailed explanation of the warning, split into lines.</param> - /// <param name="modLabel">Formats the mod label, or <c>null</c> to use the <see cref="IModMetadata.DisplayName"/>.</param> - private void LogModWarningGroup(IModMetadata[] mods, Func<IModMetadata, bool> match, LogLevel level, string heading, string[] blurb, Func<IModMetadata, string> modLabel = null) - { - // get matching mods - string[] modLabels = mods - .Where(match) - .Select(mod => modLabel?.Invoke(mod) ?? mod.DisplayName) - .OrderBy(p => p) - .ToArray(); - if (!modLabels.Any()) - return; - - // log header/blurb - this.Monitor.Log(" " + heading, level); - this.Monitor.Log(" " + "".PadRight(50, '-'), level); - foreach (string line in blurb) - this.Monitor.Log(" " + line, level); - this.Monitor.Newline(); - - // log mod list - foreach (string label in modLabels) - this.Monitor.Log($" - {label}", level); - - this.Monitor.Newline(); - } - - /// <summary>Write a mod warning group to the console and log.</summary> - /// <param name="mods">The mods to search.</param> - /// <param name="warning">The mod warning to match.</param> - /// <param name="level">The log level for the logged messages.</param> - /// <param name="heading">A brief heading label for the group.</param> - /// <param name="blurb">A detailed explanation of the warning, split into lines.</param> - void LogModWarningGroup(IModMetadata[] mods, ModWarning warning, LogLevel level, string heading, params string[] blurb) - { - this.LogModWarningGroup(mods, mod => mod.HasUnsuppressedWarnings(warning), level, heading, blurb); - } - /// <summary>Load a mod's entry class.</summary> /// <param name="modAssembly">The mod assembly.</param> /// <param name="mod">The loaded instance.</param> @@ -1342,7 +1713,6 @@ namespace StardewModdingAPI.Framework catch (Exception ex) { errors.Add($"{file.Name} file couldn't be parsed: {ex.GetLogSummary()}"); - continue; } } } @@ -1368,64 +1738,6 @@ namespace StardewModdingAPI.Framework return translations; } - /// <summary>Redirect messages logged directly to the console to the given monitor.</summary> - /// <param name="gameMonitor">The monitor with which to log messages as the game.</param> - /// <param name="message">The message to log.</param> - private void HandleConsoleMessage(IMonitor gameMonitor, string message) - { - // detect exception - LogLevel level = message.Contains("Exception") ? LogLevel.Error : LogLevel.Trace; - - // ignore suppressed message - if (level != LogLevel.Error && this.SuppressConsolePatterns.Any(p => p.IsMatch(message))) - return; - - // show friendly error if applicable - foreach (ReplaceLogPattern entry in this.ReplaceConsolePatterns) - { - string newMessage = entry.Search.Replace(message, entry.Replacement); - if (message != newMessage) - { - gameMonitor.Log(newMessage, entry.LogLevel); - gameMonitor.Log(message, LogLevel.Trace); - return; - } - } - - // forward to monitor - gameMonitor.Log(message, level); - } - - /// <summary>Show a 'press any key to exit' message, and exit when they press a key.</summary> - private void PressAnyKeyToExit() - { - this.Monitor.Log("Game has ended. Press any key to exit.", LogLevel.Info); - this.PressAnyKeyToExit(showMessage: false); - } - - /// <summary>Show a 'press any key to exit' message, and exit when they press a key.</summary> - /// <param name="showMessage">Whether to print a 'press any key to exit' message to the console.</param> - private void PressAnyKeyToExit(bool showMessage) - { - if (showMessage) - Console.WriteLine("Game has ended. Press any key to exit."); - Thread.Sleep(100); - Console.ReadKey(); - Environment.Exit(0); - } - - /// <summary>Get a monitor instance derived from SMAPI's current settings.</summary> - /// <param name="name">The name of the module which will log messages with this instance.</param> - private Monitor GetSecondaryMonitor(string name) - { - return new Monitor(name, this.ConsoleManager, this.LogFile, this.Settings.ConsoleColors, this.Settings.VerboseLogging) - { - WriteToConsole = this.Monitor.WriteToConsole, - ShowTraceInConsole = this.Settings.DeveloperMode, - ShowFullStampInConsole = this.Settings.DeveloperMode - }; - } - /// <summary>Get the absolute path to the next available log file.</summary> private string GetLogPath() { @@ -1477,35 +1789,12 @@ namespace StardewModdingAPI.Framework } } - /// <summary>A console log pattern to replace with a different message.</summary> - private class ReplaceLogPattern + /// <summary>Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</summary> + /// <param name="message">The fatal log message.</param> + private void ExitGameImmediately(string message) { - /********* - ** Accessors - *********/ - /// <summary>The regex pattern matching the portion of the message to replace.</summary> - public Regex Search { get; } - - /// <summary>The replacement string.</summary> - public string Replacement { get; } - - /// <summary>The log level for the new message.</summary> - public LogLevel LogLevel { get; } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="search">The regex pattern matching the portion of the message to replace.</param> - /// <param name="replacement">The replacement string.</param> - /// <param name="logLevel">The log level for the new message.</param> - public ReplaceLogPattern(Regex search, string replacement, LogLevel logLevel) - { - this.Search = search; - this.Replacement = replacement; - this.LogLevel = logLevel; - } + this.Monitor.LogFatal(message); + this.CancellationToken.Cancel(); } } } diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index abb766f2..6680a6c9 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -1,32 +1,16 @@ using System; -using System.Collections; -using System.Collections.Concurrent; -using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Text; -using System.Threading; using System.Threading.Tasks; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; -using Netcode; -using StardewModdingAPI.Enums; -using StardewModdingAPI.Events; -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.Rendering; -using StardewModdingAPI.Framework.StateTracking.Comparers; -using StardewModdingAPI.Framework.StateTracking.Snapshots; using StardewModdingAPI.Framework.Utilities; -using StardewModdingAPI.Toolkit.Serialization; using StardewValley; using StardewValley.BellsAndWhistles; -using StardewValley.Events; using StardewValley.Locations; using StardewValley.Menus; using StardewValley.Tools; @@ -42,182 +26,98 @@ namespace StardewModdingAPI.Framework /********* ** Fields *********/ - /**** - ** SMAPI state - ****/ /// <summary>Encapsulates monitoring and logging for SMAPI.</summary> private readonly Monitor Monitor; - /// <summary>Encapsulates monitoring and logging on the game's behalf.</summary> - private readonly IMonitor MonitorForGame; - /// <summary>Manages SMAPI events for mods.</summary> private readonly EventManager Events; - /// <summary>Tracks the installed mods.</summary> - private readonly ModRegistry ModRegistry; - - /// <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 - /// <summary>The maximum number of consecutive attempts SMAPI should make to recover from an update error.</summary> - private readonly Countdown UpdateCrashTimer = new Countdown(60); // 60 ticks = roughly one second - - /// <summary>The number of ticks until SMAPI should notify mods that the game has loaded.</summary> - /// <remarks>Skipping a few frames ensures the game finishes initializing the world before mods try to change it.</remarks> - private readonly Countdown AfterLoadTimer = new Countdown(5); - - /// <summary>Whether custom content was removed from the save data to avoid a crash.</summary> - private bool IsSaveContentRemoved; - - /// <summary>Whether the game is saving and SMAPI has already raised <see cref="IGameLoopEvents.Saving"/>.</summary> - private bool IsBetweenSaveEvents; - - /// <summary>Whether the game is creating the save file and SMAPI has already raised <see cref="IGameLoopEvents.SaveCreating"/>.</summary> - private bool IsBetweenCreateEvents; - - /// <summary>A callback to invoke the first time *any* game content manager loads an asset.</summary> - private readonly Action OnLoadingFirstAsset; - - /// <summary>A callback to invoke after the game finishes initializing.</summary> - private readonly Action OnGameInitialized; - - /// <summary>A callback to invoke when the game exits.</summary> - private readonly Action OnGameExiting; - /// <summary>Simplifies access to private game code.</summary> private readonly Reflector Reflection; - /// <summary>Encapsulates access to SMAPI core translations.</summary> - private readonly Translator Translator; - - /// <summary>Propagates notification that SMAPI should exit.</summary> - private readonly CancellationTokenSource CancellationToken; - - /**** - ** Game state - ****/ - /// <summary>Monitors the entire game state for changes.</summary> - private WatcherCore Watchers; + /// <summary>Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</summary> + private readonly Action<string> ExitGameImmediately; - /// <summary>A snapshot of the current <see cref="Watchers"/> state.</summary> - private readonly WatcherSnapshot WatcherSnapshot = new WatcherSnapshot(); + /// <summary>Raised after the game finishes loading its initial content.</summary> + private readonly Action OnGameContentLoaded; - /// <summary>Whether post-game-startup initialization has been performed.</summary> - private bool IsInitialized; + /// <summary>Raised when the game is updating its state (roughly 60 times per second).</summary> + private readonly Action<GameTime, Action> OnGameUpdating; - /// <summary>Whether the next content manager requested by the game will be for <see cref="Game1.content"/>.</summary> - private bool NextContentManagerIsMain; + /// <summary>Raised before the game exits.</summary> + private readonly Action OnGameExiting; /********* ** Accessors *********/ - /// <summary>Static state to use while <see cref="Game1"/> is initializing, which happens before the <see cref="SGame"/> constructor runs.</summary> - internal static SGameConstructorHack ConstructorHack { get; set; } - - /// <summary>The number of update ticks which have already executed. This is similar to <see cref="Game1.ticks"/>, but incremented more consistently for every tick.</summary> - internal static uint TicksElapsed { get; private set; } - - /// <summary>SMAPI's content manager.</summary> - public ContentCoordinator ContentCore { get; private set; } - - /// <summary>Manages console commands.</summary> - public CommandManager CommandManager { get; } = new CommandManager(); - /// <summary>Manages input visible to the game.</summary> - public SInputState Input => (SInputState)Game1.input; + public static SInputState Input => (SInputState)Game1.input; /// <summary>The game's core multiplayer utility.</summary> - public SMultiplayer Multiplayer => (SMultiplayer)Game1.multiplayer; + public static SMultiplayer Multiplayer => (SMultiplayer)Game1.multiplayer; - /// <summary>A list of queued commands to execute.</summary> - /// <remarks>This property must be threadsafe, since it's accessed from a separate console input thread.</remarks> - public ConcurrentQueue<string> CommandQueue { get; } = new ConcurrentQueue<string>(); + /// <summary>The game background task which initializes a new day.</summary> + public static Task NewDayTask => Game1._newDayTask; - /// <summary>Asset interceptors added or removed since the last tick.</summary> - private readonly List<AssetInterceptorChange> ReloadAssetInterceptorsQueue = new List<AssetInterceptorChange>(); + /// <summary>Construct a content manager to read game content files.</summary> + /// <remarks>This must be static because the game accesses it before the <see cref="SGame"/> constructor is called.</remarks> + public static Func<IServiceProvider, string, LocalizedContentManager> CreateContentManagerImpl; /********* - ** Protected methods + ** Public methods *********/ /// <summary>Construct an instance.</summary> /// <param name="monitor">Encapsulates monitoring and logging for SMAPI.</param> - /// <param name="monitorForGame">Encapsulates monitoring and logging on the game's behalf.</param> /// <param name="reflection">Simplifies access to private game code.</param> - /// <param name="translator">Encapsulates access to arbitrary translations.</param> /// <param name="eventManager">Manages SMAPI events for mods.</param> - /// <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, PerformanceMonitor performanceMonitor, Action onGameInitialized, Action onGameExiting, CancellationTokenSource cancellationToken, bool logNetworkTraffic) + /// <param name="modHooks">Handles mod hooks provided by the game.</param> + /// <param name="multiplayer">The core multiplayer logic.</param> + /// <param name="exitGameImmediately">Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</param> + /// <param name="onGameContentLoaded">Raised after the game finishes loading its initial content.</param> + /// <param name="onGameUpdating">Raised when the game is updating its state (roughly 60 times per second).</param> + /// <param name="onGameExiting">Raised before the game exits.</param> + public SGame(Monitor monitor, Reflector reflection, EventManager eventManager, SModHooks modHooks, SMultiplayer multiplayer, Action<string> exitGameImmediately, Action onGameContentLoaded, Action<GameTime, Action> onGameUpdating, Action onGameExiting) { - this.OnLoadingFirstAsset = SGame.ConstructorHack.OnLoadingFirstAsset; - SGame.ConstructorHack = null; - - // check expectations - if (this.ContentCore == null) - throw new InvalidOperationException($"The game didn't initialize its first content manager before SMAPI's {nameof(SGame)} constructor. This indicates an incompatible lifecycle change."); - // init XNA Game1.graphics.GraphicsProfile = GraphicsProfile.HiDef; + // hook into game + Game1.input = new SInputState(); + Game1.multiplayer = multiplayer; + Game1.hooks = modHooks; + Game1.locations = new ObservableCollection<GameLocation>(); + // init SMAPI this.Monitor = monitor; - this.MonitorForGame = monitorForGame; this.Events = eventManager; - this.ModRegistry = modRegistry; this.Reflection = reflection; - this.Translator = translator; - this.DeprecationManager = deprecationManager; - this.PerformanceMonitor = performanceMonitor; - this.OnGameInitialized = onGameInitialized; + this.ExitGameImmediately = exitGameImmediately; + this.OnGameContentLoaded = onGameContentLoaded; + this.OnGameUpdating = onGameUpdating; this.OnGameExiting = onGameExiting; - Game1.input = new SInputState(); - Game1.multiplayer = new SMultiplayer(monitor, eventManager, jsonHelper, modRegistry, reflection, this.OnModMessageReceived, logNetworkTraffic); - Game1.hooks = new SModHooks(this.OnNewDayAfterFade); - this.CancellationToken = cancellationToken; + } - // init observables - Game1.locations = new ObservableCollection<GameLocation>(); + /// <summary>Get the observable location list.</summary> + public ObservableCollection<GameLocation> GetObservableLocations() + { + return (ObservableCollection<GameLocation>)Game1.locations; } + + /********* + ** Protected methods + *********/ /// <summary>Load content when the game is launched.</summary> protected override void LoadContent() { - // load content base.LoadContent(); - Game1.mapDisplayDevice = new SDisplayDevice(Game1.content, this.GraphicsDevice); - // log GPU info -#if SMAPI_FOR_WINDOWS - this.Monitor.Log($"Running on GPU: {this.GraphicsDevice?.Adapter?.Description ?? "<unknown>"}"); -#endif - } - - /// <summary>Initialize just before the game's first update tick.</summary> - private void InitializeAfterGameStarted() - { - // set initial state - this.Input.TrueUpdate(); - - // init watchers - this.Watchers = new WatcherCore(this.Input); - - // raise callback - this.OnGameInitialized(); + this.OnGameContentLoaded(); } /// <summary>Perform cleanup logic when the game exits.</summary> @@ -226,639 +126,25 @@ namespace StardewModdingAPI.Framework /// <remarks>This overrides the logic in <see cref="Game1.exitEvent"/> to let SMAPI clean up before exit.</remarks> protected override void OnExiting(object sender, EventArgs args) { - Game1.multiplayer.Disconnect(StardewValley.Multiplayer.DisconnectType.ClosedGame); - this.OnGameExiting?.Invoke(); - } - - /// <summary>A callback invoked before <see cref="Game1.newDayAfterFade"/> runs.</summary> - protected void OnNewDayAfterFade() - { - this.Events.DayEnding.RaiseEmpty(); - } - - /// <summary>A callback invoked when a mod message is received.</summary> - /// <param name="message">The message to deliver to applicable mods.</param> - private void OnModMessageReceived(ModMessageModel message) - { - // get mod IDs to notify - HashSet<string> modIDs = new HashSet<string>(message.ToModIDs ?? this.ModRegistry.GetAll().Select(p => p.Manifest.UniqueID), StringComparer.OrdinalIgnoreCase); - if (message.FromPlayerID == Game1.player?.UniqueMultiplayerID) - modIDs.Remove(message.FromModID); // don't send a broadcast back to the sender - - // raise events - this.Events.ModMessageReceived.Raise(new ModMessageReceivedEventArgs(message), mod => mod != null && modIDs.Contains(mod.Manifest.UniqueID)); - } - - /// <summary>A callback invoked when custom content is removed from the save data to avoid a crash.</summary> - internal void OnSaveContentRemoved() - { - this.IsSaveContentRemoved = true; - } - - /// <summary>A callback invoked when the game's low-level load stage changes.</summary> - /// <param name="newStage">The new load stage.</param> - internal void OnLoadStageChanged(LoadStage newStage) - { - // nothing to do - if (newStage == Context.LoadStage) - return; - - // update data - LoadStage oldStage = Context.LoadStage; - Context.LoadStage = newStage; - this.Monitor.VerboseLog($"Context: load stage changed to {newStage}"); - if (newStage == LoadStage.None) - { - this.Monitor.Log("Context: returned to title", LogLevel.Trace); - this.OnReturnedToTitle(); - } - - // raise events - this.Events.LoadStageChanged.Raise(new LoadStageChangedEventArgs(oldStage, newStage)); - if (newStage == LoadStage.None) - this.Events.ReturnedToTitle.RaiseEmpty(); + this.OnGameExiting(); } - /// <summary>A callback invoked when a mod adds or removes an asset interceptor.</summary> - /// <param name="mod">The mod which added or removed interceptors.</param> - /// <param name="added">The added interceptors.</param> - /// <param name="removed">The removed interceptors.</param> - internal void OnAssetInterceptorsChanged(IModMetadata mod, IEnumerable added, IEnumerable removed) - { - if (added != null) - { - foreach (object instance in added) - this.ReloadAssetInterceptorsQueue.Add(new AssetInterceptorChange(mod, instance, wasAdded: true)); - } - if (removed != null) - { - foreach (object instance in removed) - this.ReloadAssetInterceptorsQueue.Add(new AssetInterceptorChange(mod, instance, wasAdded: false)); - } - } - - /// <summary>Perform cleanup when the game returns to the title screen.</summary> - private void OnReturnedToTitle() - { - this.Multiplayer.CleanupOnMultiplayerExit(); - - if (!(Game1.mapDisplayDevice is SDisplayDevice)) - Game1.mapDisplayDevice = new SDisplayDevice(Game1.content, this.GraphicsDevice); - } - - /// <summary>Constructor a content manager to read XNB files.</summary> + /// <summary>Construct a content manager to read game content files.</summary> /// <param name="serviceProvider">The service provider to use to locate services.</param> /// <param name="rootDirectory">The root directory to search for content.</param> protected override LocalizedContentManager CreateContentManager(IServiceProvider serviceProvider, string rootDirectory) { - // Game1._temporaryContent initializing from SGame constructor - // NOTE: this method is called before the SGame constructor runs. Don't depend on anything being initialized at this point. - if (this.ContentCore == null) - { - this.ContentCore = new ContentCoordinator(serviceProvider, rootDirectory, Thread.CurrentThread.CurrentUICulture, SGame.ConstructorHack.Monitor, SGame.ConstructorHack.Reflection, SGame.ConstructorHack.JsonHelper, this.OnLoadingFirstAsset ?? SGame.ConstructorHack?.OnLoadingFirstAsset); - this.NextContentManagerIsMain = true; - return this.ContentCore.CreateGameContentManager("Game1._temporaryContent"); - } + if (SGame.CreateContentManagerImpl == null) + throw new InvalidOperationException($"The {nameof(SGame)}.{nameof(SGame.CreateContentManagerImpl)} must be set."); - // Game1.content initializing from LoadContent - if (this.NextContentManagerIsMain) - { - this.NextContentManagerIsMain = false; - return this.ContentCore.MainContentManager; - } - - // any other content manager - return this.ContentCore.CreateGameContentManager("(generated)"); + return SGame.CreateContentManagerImpl(serviceProvider, rootDirectory); } - /// <summary>The method called when the game is updating its state. This happens roughly 60 times per second.</summary> + /// <summary>The method called when the game is updating its state (roughly 60 times per second).</summary> /// <param name="gameTime">A snapshot of the game timing state.</param> protected override void Update(GameTime gameTime) { - var events = this.Events; - - try - { - this.DeprecationManager.PrintQueued(); - this.PerformanceMonitor.PrintQueuedAlerts(); - - /********* - ** First-tick initialization - *********/ - if (!this.IsInitialized) - { - this.IsInitialized = true; - this.InitializeAfterGameStarted(); - } - - /********* - ** Update input - *********/ - // This should *always* run, even when suppressing mod events, since the game uses - // this too. For example, doing this after mod event suppression would prevent the - // user from doing anything on the overnight shipping screen. - SInputState inputState = this.Input; - if (this.IsActive) - inputState.TrueUpdate(); - - /********* - ** Special cases - *********/ - // Abort if SMAPI is exiting. - if (this.CancellationToken.IsCancellationRequested) - { - this.Monitor.Log("SMAPI shutting down: aborting update.", LogLevel.Trace); - return; - } - - // Run async tasks synchronously to avoid issues due to mod events triggering - // concurrently with game code. - bool saveParsed = false; - if (Game1.currentLoader != null) - { - this.Monitor.Log("Game loader synchronizing...", LogLevel.Trace); - while (Game1.currentLoader?.MoveNext() == true) - { - // raise load stage changed - switch (Game1.currentLoader.Current) - { - case 20 when (!saveParsed && SaveGame.loaded != null): - saveParsed = true; - this.OnLoadStageChanged(LoadStage.SaveParsed); - break; - - case 36: - this.OnLoadStageChanged(LoadStage.SaveLoadedBasicInfo); - break; - - case 50: - this.OnLoadStageChanged(LoadStage.SaveLoadedLocations); - break; - - default: - if (Game1.gameMode == Game1.playingGameMode) - this.OnLoadStageChanged(LoadStage.Preloaded); - break; - } - } - - Game1.currentLoader = null; - this.Monitor.Log("Game loader done.", LogLevel.Trace); - } - if (Game1._newDayTask?.Status == TaskStatus.Created) - { - this.Monitor.Log("New day task synchronizing...", LogLevel.Trace); - Game1._newDayTask.RunSynchronously(); - this.Monitor.Log("New day task done.", LogLevel.Trace); - } - - // While a background task is in progress, the game may make changes to the game - // 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 - // update tick are negligible and not worth the complications of bypassing Game1.Update. - if (Game1._newDayTask != null || Game1.gameMode == Game1.loadingMode) - { - events.UnvalidatedUpdateTicking.RaiseEmpty(); - SGame.TicksElapsed++; - base.Update(gameTime); - events.UnvalidatedUpdateTicked.RaiseEmpty(); - return; - } - - // Raise minimal events while saving. - // While the game is writing to the save file in the background, mods can unexpectedly - // fail since they don't have exclusive access to resources (e.g. collection changed - // during enumeration errors). To avoid problems, events are not invoked while a save - // is in progress. It's safe to raise SaveEvents.BeforeSave as soon as the menu is - // opened (since the save hasn't started yet), but all other events should be suppressed. - if (Context.IsSaving) - { - // raise before-create - if (!Context.IsWorldReady && !this.IsBetweenCreateEvents) - { - this.IsBetweenCreateEvents = true; - this.Monitor.Log("Context: before save creation.", LogLevel.Trace); - events.SaveCreating.RaiseEmpty(); - } - - // raise before-save - if (Context.IsWorldReady && !this.IsBetweenSaveEvents) - { - this.IsBetweenSaveEvents = true; - this.Monitor.Log("Context: before save.", LogLevel.Trace); - events.Saving.RaiseEmpty(); - } - - // suppress non-save events - events.UnvalidatedUpdateTicking.RaiseEmpty(); - SGame.TicksElapsed++; - base.Update(gameTime); - events.UnvalidatedUpdateTicked.RaiseEmpty(); - return; - } - - /********* - ** Reload assets when interceptors are added/removed - *********/ - if (this.ReloadAssetInterceptorsQueue.Any()) - { - // get unique interceptors - AssetInterceptorChange[] interceptors = this.ReloadAssetInterceptorsQueue - .GroupBy(p => p.Instance, new ObjectReferenceComparer<object>()) - .Select(p => p.First()) - .ToArray(); - this.ReloadAssetInterceptorsQueue.Clear(); - - // log summary - this.Monitor.Log("Invalidating cached assets for new editors & loaders..."); - this.Monitor.Log( - " changed: " - + string.Join(", ", - interceptors - .GroupBy(p => p.Mod) - .OrderBy(p => p.Key.DisplayName) - .Select(modGroup => - $"{modGroup.Key.DisplayName} (" - + string.Join(", ", modGroup.GroupBy(p => p.WasAdded).ToDictionary(p => p.Key, p => p.Count()).Select(p => $"{(p.Key ? "added" : "removed")} {p.Value}")) - + ")" - ) - ) - ); - - // reload affected assets - this.ContentCore.InvalidateCache(asset => interceptors.Any(p => p.CanIntercept(asset))); - } - - /********* - ** Execute commands - *********/ - while (this.CommandQueue.TryDequeue(out string rawInput)) - { - // parse command - string name; - string[] args; - Command command; - try - { - if (!this.CommandManager.TryParse(rawInput, out name, out args, out command)) - { - this.Monitor.Log("Unknown command; type 'help' for a list of available commands.", LogLevel.Error); - continue; - } - } - catch (Exception ex) - { - this.Monitor.Log($"Failed parsing that command:\n{ex.GetLogSummary()}", LogLevel.Error); - continue; - } - - // execute command - try - { - command.Callback.Invoke(name, args); - } - catch (Exception ex) - { - if (command.Mod != null) - command.Mod.LogAsMod($"Mod failed handling that command:\n{ex.GetLogSummary()}", LogLevel.Error); - else - this.Monitor.Log($"Failed handling that command:\n{ex.GetLogSummary()}", LogLevel.Error); - } - } - - /********* - ** Update context - *********/ - bool wasWorldReady = Context.IsWorldReady; - if ((Context.IsWorldReady && !Context.IsSaveLoaded) || Game1.exitToTitle) - { - Context.IsWorldReady = false; - this.AfterLoadTimer.Reset(); - } - else if (Context.IsSaveLoaded && this.AfterLoadTimer.Current > 0 && Game1.currentLocation != null) - { - if (Game1.dayOfMonth != 0) // wait until new-game intro finishes (world not fully initialized yet) - this.AfterLoadTimer.Decrement(); - Context.IsWorldReady = this.AfterLoadTimer.Current == 0; - } - - /********* - ** Update watchers - ** (Watchers need to be updated, checked, and reset in one go so we can detect any changes mods make in event handlers.) - *********/ - this.Watchers.Update(); - this.WatcherSnapshot.Update(this.Watchers); - this.Watchers.Reset(); - WatcherSnapshot state = this.WatcherSnapshot; - - /********* - ** Display in-game warnings - *********/ - // save content removed - if (this.IsSaveContentRemoved && Context.IsWorldReady) - { - this.IsSaveContentRemoved = false; - Game1.addHUDMessage(new HUDMessage(this.Translator.Get("warn.invalid-content-removed"), HUDMessage.error_type)); - } - - /********* - ** Pre-update events - *********/ - { - /********* - ** Save created/loaded events - *********/ - if (this.IsBetweenCreateEvents) - { - // raise after-create - this.IsBetweenCreateEvents = false; - this.Monitor.Log($"Context: after save creation, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.", LogLevel.Trace); - this.OnLoadStageChanged(LoadStage.CreatedSaveFile); - events.SaveCreated.RaiseEmpty(); - } - if (this.IsBetweenSaveEvents) - { - // raise after-save - this.IsBetweenSaveEvents = false; - this.Monitor.Log($"Context: after save, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.", LogLevel.Trace); - events.Saved.RaiseEmpty(); - events.DayStarted.RaiseEmpty(); - } - - /********* - ** Locale changed events - *********/ - if (state.Locale.IsChanged) - this.Monitor.Log($"Context: locale set to {state.Locale.New}.", LogLevel.Trace); - - /********* - ** Load / return-to-title events - *********/ - if (wasWorldReady && !Context.IsWorldReady) - this.OnLoadStageChanged(LoadStage.None); - else if (Context.IsWorldReady && Context.LoadStage != LoadStage.Ready) - { - // print context - string context = $"Context: loaded save '{Constants.SaveFolderName}', starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}, locale set to {this.ContentCore.Language}."; - if (Context.IsMultiplayer) - { - int onlineCount = Game1.getOnlineFarmers().Count(); - context += $" {(Context.IsMainPlayer ? "Main player" : "Farmhand")} with {onlineCount} {(onlineCount == 1 ? "player" : "players")} online."; - } - else - context += " Single-player."; - this.Monitor.Log(context, LogLevel.Trace); - - // raise events - this.OnLoadStageChanged(LoadStage.Ready); - events.SaveLoaded.RaiseEmpty(); - events.DayStarted.RaiseEmpty(); - } - - /********* - ** Window events - *********/ - // Here we depend on the game's viewport instead of listening to the Window.Resize - // event because we need to notify mods after the game handles the resize, so the - // game's metadata (like Game1.viewport) are updated. That's a bit complicated - // since the game adds & removes its own handler on the fly. - if (state.WindowSize.IsChanged) - { - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Events: window size changed to {state.WindowSize.New}.", LogLevel.Trace); - - events.WindowResized.Raise(new WindowResizedEventArgs(state.WindowSize.Old, state.WindowSize.New)); - } - - /********* - ** Input events (if window has focus) - *********/ - if (this.IsActive) - { - // raise events - bool isChatInput = Game1.IsChatting || (Context.IsMultiplayer && Context.IsWorldReady && Game1.activeClickableMenu == null && Game1.currentMinigame == null && inputState.IsAnyDown(Game1.options.chatButton)); - if (!isChatInput) - { - ICursorPosition cursor = this.Input.CursorPosition; - - // raise cursor moved event - if (state.Cursor.IsChanged) - events.CursorMoved.Raise(new CursorMovedEventArgs(state.Cursor.Old, state.Cursor.New)); - - // raise mouse wheel scrolled - if (state.MouseWheelScroll.IsChanged) - { - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Events: mouse wheel scrolled to {state.MouseWheelScroll.New}.", LogLevel.Trace); - events.MouseWheelScrolled.Raise(new MouseWheelScrolledEventArgs(cursor, state.MouseWheelScroll.Old, state.MouseWheelScroll.New)); - } - - // raise input button events - foreach (var pair in inputState.LastButtonStates) - { - SButton button = pair.Key; - SButtonState status = pair.Value; - - if (status == SButtonState.Pressed) - { - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Events: button {button} pressed.", LogLevel.Trace); - - events.ButtonPressed.Raise(new ButtonPressedEventArgs(button, cursor, inputState)); - } - else if (status == SButtonState.Released) - { - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Events: button {button} released.", LogLevel.Trace); - - events.ButtonReleased.Raise(new ButtonReleasedEventArgs(button, cursor, inputState)); - } - } - } - } - - /********* - ** Menu events - *********/ - if (state.ActiveMenu.IsChanged) - { - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Context: menu changed from {state.ActiveMenu.Old?.GetType().FullName ?? "none"} to {state.ActiveMenu.New?.GetType().FullName ?? "none"}.", LogLevel.Trace); - - // raise menu events - events.MenuChanged.Raise(new MenuChangedEventArgs(state.ActiveMenu.Old, state.ActiveMenu.New)); - } - - /********* - ** World & player events - *********/ - if (Context.IsWorldReady) - { - bool raiseWorldEvents = !state.SaveID.IsChanged; // don't report changes from unloaded => loaded - - // location list changes - if (state.Locations.LocationList.IsChanged && (events.LocationListChanged.HasListeners() || this.Monitor.IsVerbose)) - { - var added = state.Locations.LocationList.Added.ToArray(); - var removed = state.Locations.LocationList.Removed.ToArray(); - - if (this.Monitor.IsVerbose) - { - string addedText = added.Any() ? string.Join(", ", added.Select(p => p.Name)) : "none"; - string removedText = removed.Any() ? string.Join(", ", removed.Select(p => p.Name)) : "none"; - this.Monitor.Log($"Context: location list changed (added {addedText}; removed {removedText}).", LogLevel.Trace); - } - - events.LocationListChanged.Raise(new LocationListChangedEventArgs(added, removed)); - } - - // raise location contents changed - if (raiseWorldEvents) - { - foreach (LocationSnapshot locState in state.Locations.Locations) - { - var location = locState.Location; - - // buildings changed - if (locState.Buildings.IsChanged) - events.BuildingListChanged.Raise(new BuildingListChangedEventArgs(location, locState.Buildings.Added, locState.Buildings.Removed)); - - // debris changed - if (locState.Debris.IsChanged) - events.DebrisListChanged.Raise(new DebrisListChangedEventArgs(location, locState.Debris.Added, locState.Debris.Removed)); - - // large terrain features changed - if (locState.LargeTerrainFeatures.IsChanged) - events.LargeTerrainFeatureListChanged.Raise(new LargeTerrainFeatureListChangedEventArgs(location, locState.LargeTerrainFeatures.Added, locState.LargeTerrainFeatures.Removed)); - - // NPCs changed - if (locState.Npcs.IsChanged) - events.NpcListChanged.Raise(new NpcListChangedEventArgs(location, locState.Npcs.Added, locState.Npcs.Removed)); - - // objects changed - if (locState.Objects.IsChanged) - events.ObjectListChanged.Raise(new ObjectListChangedEventArgs(location, locState.Objects.Added, locState.Objects.Removed)); - - // chest items changed - if (events.ChestInventoryChanged.HasListeners()) - { - foreach (var pair in locState.ChestItems) - { - SnapshotItemListDiff diff = pair.Value; - events.ChestInventoryChanged.Raise(new ChestInventoryChangedEventArgs(pair.Key, location, added: diff.Added, removed: diff.Removed, quantityChanged: diff.QuantityChanged)); - } - } - - // terrain features changed - if (locState.TerrainFeatures.IsChanged) - events.TerrainFeatureListChanged.Raise(new TerrainFeatureListChangedEventArgs(location, locState.TerrainFeatures.Added, locState.TerrainFeatures.Removed)); - } - } - - // raise time changed - if (raiseWorldEvents && state.Time.IsChanged) - events.TimeChanged.Raise(new TimeChangedEventArgs(state.Time.Old, state.Time.New)); - - // raise player events - if (raiseWorldEvents) - { - PlayerSnapshot playerState = state.CurrentPlayer; - Farmer player = playerState.Player; - - // raise current location changed - if (playerState.Location.IsChanged) - { - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Context: set location to {playerState.Location.New}.", LogLevel.Trace); - - events.Warped.Raise(new WarpedEventArgs(player, playerState.Location.Old, playerState.Location.New)); - } - - // raise player leveled up a skill - foreach (var pair in playerState.Skills) - { - if (!pair.Value.IsChanged) - continue; - - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Events: player skill '{pair.Key}' changed from {pair.Value.Old} to {pair.Value.New}.", LogLevel.Trace); - - events.LevelChanged.Raise(new LevelChangedEventArgs(player, pair.Key, pair.Value.Old, pair.Value.New)); - } - - // raise player inventory changed - if (playerState.Inventory.IsChanged) - { - var inventory = playerState.Inventory; - - if (this.Monitor.IsVerbose) - this.Monitor.Log("Events: player inventory changed.", LogLevel.Trace); - events.InventoryChanged.Raise(new InventoryChangedEventArgs(player, added: inventory.Added, removed: inventory.Removed, quantityChanged: inventory.QuantityChanged)); - } - } - } - - /********* - ** Game update - *********/ - // game launched - bool isFirstTick = SGame.TicksElapsed == 0; - if (isFirstTick) - { - Context.IsGameLaunched = true; - events.GameLaunched.Raise(new GameLaunchedEventArgs()); - } - - // preloaded - if (Context.IsSaveLoaded && Context.LoadStage != LoadStage.Loaded && Context.LoadStage != LoadStage.Ready && Game1.dayOfMonth != 0) - this.OnLoadStageChanged(LoadStage.Loaded); - } - - /********* - ** Game update tick - *********/ - { - bool isOneSecond = SGame.TicksElapsed % 60 == 0; - events.UnvalidatedUpdateTicking.RaiseEmpty(); - events.UpdateTicking.RaiseEmpty(); - if (isOneSecond) - events.OneSecondUpdateTicking.RaiseEmpty(); - try - { - this.Input.ApplyOverrides(); // if mods added any new overrides since the update, process them now - SGame.TicksElapsed++; - base.Update(gameTime); - } - catch (Exception ex) - { - this.MonitorForGame.Log($"An error occured in the base update loop: {ex.GetLogSummary()}", LogLevel.Error); - } - - events.UnvalidatedUpdateTicked.RaiseEmpty(); - events.UpdateTicked.RaiseEmpty(); - if (isOneSecond) - events.OneSecondUpdateTicked.RaiseEmpty(); - } - - /********* - ** Update events - *********/ - this.UpdateCrashTimer.Reset(); - } - catch (Exception ex) - { - // log error - this.Monitor.Log($"An error occured in the overridden update loop: {ex.GetLogSummary()}", LogLevel.Error); - - // exit if irrecoverable - if (!this.UpdateCrashTimer.Decrement()) - this.ExitGameImmediately("The game crashed when updating, and SMAPI was unable to recover the game."); - } + this.OnGameUpdating(gameTime, () => base.Update(gameTime)); } /// <summary>The method called to draw everything to the screen.</summary> @@ -890,7 +176,7 @@ namespace StardewModdingAPI.Framework { if (Game1.spriteBatch.IsOpen(this.Reflection)) { - this.Monitor.Log("Recovering sprite batch from error...", LogLevel.Trace); + this.Monitor.Log("Recovering sprite batch from error..."); Game1.spriteBatch.End(); } } @@ -923,694 +209,632 @@ namespace StardewModdingAPI.Framework Game1.showingHealthBar = false; if (Game1._newDayTask != null) { - this.GraphicsDevice.Clear(Game1.bgColor); + base.GraphicsDevice.Clear(Game1.bgColor); + return; } - else + if (target_screen != null) + { + base.GraphicsDevice.SetRenderTarget(target_screen); + } + if (this.IsSaving) + { + base.GraphicsDevice.Clear(Game1.bgColor); + IClickableMenu menu = Game1.activeClickableMenu; + if (menu != null) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + events.Rendering.RaiseEmpty(); + try + { + events.RenderingActiveMenu.RaiseEmpty(); + menu.draw(Game1.spriteBatch); + events.RenderedActiveMenu.RaiseEmpty(); + } + catch (Exception ex) + { + this.Monitor.Log($"The {activeClickableMenu.GetType().FullName} menu crashed while drawing itself during save. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); + activeClickableMenu.exitThisMenu(); + } + events.Rendered.RaiseEmpty(); + Game1.spriteBatch.End(); + } + if (Game1.overlayMenu != null) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + Game1.overlayMenu.draw(Game1.spriteBatch); + Game1.spriteBatch.End(); + } + this.renderScreenBuffer(target_screen); + return; + } + base.GraphicsDevice.Clear(Game1.bgColor); + if (Game1.activeClickableMenu != null && Game1.options.showMenuBackground && Game1.activeClickableMenu.showWithoutTransparencyIfOptionIsSet() && !this.takingMapScreenshot) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + + events.Rendering.RaiseEmpty(); + try + { + Game1.activeClickableMenu.drawBackground(Game1.spriteBatch); + events.RenderingActiveMenu.RaiseEmpty(); + Game1.activeClickableMenu.draw(Game1.spriteBatch); + events.RenderedActiveMenu.RaiseEmpty(); + } + catch (Exception ex) + { + this.Monitor.Log($"The {Game1.activeClickableMenu.GetType().FullName} menu crashed while drawing itself. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); + Game1.activeClickableMenu.exitThisMenu(); + } + events.Rendered.RaiseEmpty(); + Game1.spriteBatch.End(); + this.drawOverlays(Game1.spriteBatch); + if (target_screen != null) + { + base.GraphicsDevice.SetRenderTarget(null); + base.GraphicsDevice.Clear(Game1.bgColor); + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); + Game1.spriteBatch.Draw(target_screen, Vector2.Zero, target_screen.Bounds, Color.White, 0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.End(); + } + if (Game1.overlayMenu != null) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + Game1.overlayMenu.draw(Game1.spriteBatch); + Game1.spriteBatch.End(); + } + return; + } + if (Game1.gameMode == 11) { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + events.Rendering.RaiseEmpty(); + Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3685"), new Vector2(16f, 16f), Color.HotPink); + Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3686"), new Vector2(16f, 32f), new Color(0, 255, 0)); + Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.parseText(Game1.errorMessage, Game1.dialogueFont, Game1.graphics.GraphicsDevice.Viewport.Width), new Vector2(16f, 48f), Color.White); + events.Rendered.RaiseEmpty(); + Game1.spriteBatch.End(); + return; + } + if (Game1.currentMinigame != null) + { + if (events.Rendering.HasListeners()) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + events.Rendering.RaiseEmpty(); + Game1.spriteBatch.End(); + } + + Game1.currentMinigame.draw(Game1.spriteBatch); + if (Game1.globalFade && !Game1.menuUp && (!Game1.nameSelectUp || Game1.messagePause)) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * ((Game1.gameMode == 0) ? (1f - Game1.fadeToBlackAlpha) : Game1.fadeToBlackAlpha)); + Game1.spriteBatch.End(); + } + this.drawOverlays(Game1.spriteBatch); if (target_screen != null) - this.GraphicsDevice.SetRenderTarget(target_screen); - if (this.IsSaving) { - this.GraphicsDevice.Clear(Game1.bgColor); - IClickableMenu activeClickableMenu = Game1.activeClickableMenu; - if (activeClickableMenu != null) + base.GraphicsDevice.SetRenderTarget(null); + base.GraphicsDevice.Clear(Game1.bgColor); + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); + Game1.spriteBatch.Draw(target_screen, Vector2.Zero, target_screen.Bounds, Color.White, 0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + events.Rendered.RaiseEmpty(); + Game1.spriteBatch.End(); + } + else + { + if (events.Rendered.HasListeners()) { - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - events.Rendering.RaiseEmpty(); - try - { - events.RenderingActiveMenu.RaiseEmpty(); - activeClickableMenu.draw(Game1.spriteBatch); - events.RenderedActiveMenu.RaiseEmpty(); - } - catch (Exception ex) - { - this.Monitor.Log($"The {activeClickableMenu.GetType().FullName} menu crashed while drawing itself during save. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); - activeClickableMenu.exitThisMenu(); - } + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); events.Rendered.RaiseEmpty(); Game1.spriteBatch.End(); } - if (Game1.overlayMenu != null) + } + return; + } + if (Game1.showingEndOfNightStuff) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + events.Rendering.RaiseEmpty(); + if (Game1.activeClickableMenu != null) + { + try { - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - Game1.overlayMenu.draw(Game1.spriteBatch); - Game1.spriteBatch.End(); + events.RenderingActiveMenu.RaiseEmpty(); + Game1.activeClickableMenu.draw(Game1.spriteBatch); + events.RenderedActiveMenu.RaiseEmpty(); + } + catch (Exception ex) + { + this.Monitor.Log($"The {Game1.activeClickableMenu.GetType().FullName} menu crashed while drawing itself during end-of-night-stuff. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); + Game1.activeClickableMenu.exitThisMenu(); } - this.renderScreenBuffer(target_screen); } - else + events.Rendered.RaiseEmpty(); + Game1.spriteBatch.End(); + this.drawOverlays(Game1.spriteBatch); + if (target_screen != null) { - this.GraphicsDevice.Clear(Game1.bgColor); - if (Game1.activeClickableMenu != null && Game1.options.showMenuBackground && (Game1.activeClickableMenu.showWithoutTransparencyIfOptionIsSet() && !this.takingMapScreenshot)) - { - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - + base.GraphicsDevice.SetRenderTarget(null); + base.GraphicsDevice.Clear(Game1.bgColor); + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); + Game1.spriteBatch.Draw(target_screen, Vector2.Zero, target_screen.Bounds, Color.White, 0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.End(); + } + return; + } + if (Game1.gameMode == 6 || (Game1.gameMode == 3 && Game1.currentLocation == null)) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + events.Rendering.RaiseEmpty(); + string addOn = ""; + for (int i = 0; (double)i < gameTime.TotalGameTime.TotalMilliseconds % 999.0 / 333.0; i++) + { + addOn += "."; + } + string str = Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3688"); + string msg = str + addOn; + string largestMessage = str + "... "; + int msgw = SpriteText.getWidthOfString(largestMessage); + int msgh = 64; + int msgx = 64; + int msgy = Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Bottom - msgh; + SpriteText.drawString(Game1.spriteBatch, msg, msgx, msgy, 999999, msgw, msgh, 1f, 0.88f, junimoText: false, 0, largestMessage); + events.Rendered.RaiseEmpty(); + Game1.spriteBatch.End(); + this.drawOverlays(Game1.spriteBatch); + if (target_screen != null) + { + base.GraphicsDevice.SetRenderTarget(null); + base.GraphicsDevice.Clear(Game1.bgColor); + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); + Game1.spriteBatch.Draw(target_screen, Vector2.Zero, target_screen.Bounds, Color.White, 0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.End(); + } + if (Game1.overlayMenu != null) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + Game1.overlayMenu.draw(Game1.spriteBatch); + Game1.spriteBatch.End(); + } + //base.Draw(gameTime); + return; + } + byte batchOpens = 0; // used for rendering event + if (Game1.gameMode == 0) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + if (++batchOpens == 1) + events.Rendering.RaiseEmpty(); + } + else + { + if (Game1.drawLighting) + { + base.GraphicsDevice.SetRenderTarget(Game1.lightmap); + base.GraphicsDevice.Clear(Color.White * 0f); + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, null, null); + if (++batchOpens == 1) events.Rendering.RaiseEmpty(); - try + Color lighting = (Game1.currentLocation.Name.StartsWith("UndergroundMine") && Game1.currentLocation is MineShaft) ? (Game1.currentLocation as MineShaft).getLightingColor(gameTime) : ((Game1.ambientLight.Equals(Color.White) || (Game1.isRaining && (bool)Game1.currentLocation.isOutdoors)) ? Game1.outdoorLight : Game1.ambientLight); + Game1.spriteBatch.Draw(Game1.staminaRect, Game1.lightmap.Bounds, lighting); + foreach (LightSource lightSource in Game1.currentLightSources) + { + if ((Game1.isRaining || Game1.isDarkOut()) && lightSource.lightContext.Value == LightSource.LightContext.WindowLight) { - Game1.activeClickableMenu.drawBackground(Game1.spriteBatch); - events.RenderingActiveMenu.RaiseEmpty(); - Game1.activeClickableMenu.draw(Game1.spriteBatch); - events.RenderedActiveMenu.RaiseEmpty(); + continue; } - catch (Exception ex) + if (lightSource.PlayerID != 0L && lightSource.PlayerID != Game1.player.UniqueMultiplayerID) { - this.Monitor.Log($"The {Game1.activeClickableMenu.GetType().FullName} menu crashed while drawing itself. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); - Game1.activeClickableMenu.exitThisMenu(); + Farmer farmer = Game1.getFarmerMaybeOffline(lightSource.PlayerID); + if (farmer == null || (farmer.currentLocation != null && farmer.currentLocation.Name != Game1.currentLocation.Name) || (bool)farmer.hidden) + { + continue; + } } - events.Rendered.RaiseEmpty(); - Game1.spriteBatch.End(); - this.drawOverlays(Game1.spriteBatch); - if (target_screen != null) + if (Utility.isOnScreen(lightSource.position, (int)((float)lightSource.radius * 64f * 4f))) { - this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); - this.GraphicsDevice.Clear(Game1.bgColor); - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); - Game1.spriteBatch.Draw((Texture2D)target_screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(target_screen.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); - Game1.spriteBatch.End(); + Game1.spriteBatch.Draw(lightSource.lightTexture, Game1.GlobalToLocal(Game1.viewport, lightSource.position) / (Game1.options.lightingQuality / 2), lightSource.lightTexture.Bounds, lightSource.color, 0f, new Vector2(lightSource.lightTexture.Bounds.Center.X, lightSource.lightTexture.Bounds.Center.Y), (float)lightSource.radius / (float)(Game1.options.lightingQuality / 2), SpriteEffects.None, 0.9f); } - if (Game1.overlayMenu == null) - return; - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - Game1.overlayMenu.draw(Game1.spriteBatch); - Game1.spriteBatch.End(); } - else if (Game1.gameMode == (byte)11) - { - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - events.Rendering.RaiseEmpty(); - Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3685"), new Vector2(16f, 16f), Microsoft.Xna.Framework.Color.HotPink); - Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3686"), new Vector2(16f, 32f), new Microsoft.Xna.Framework.Color(0, (int)byte.MaxValue, 0)); - Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.parseText(Game1.errorMessage, Game1.dialogueFont, Game1.graphics.GraphicsDevice.Viewport.Width), new Vector2(16f, 48f), Microsoft.Xna.Framework.Color.White); - events.Rendered.RaiseEmpty(); - Game1.spriteBatch.End(); - } - else if (Game1.currentMinigame != null) + Game1.spriteBatch.End(); + base.GraphicsDevice.SetRenderTarget(target_screen); + } + if (Game1.bloomDay && Game1.bloom != null) + { + Game1.bloom.BeginDraw(); + } + base.GraphicsDevice.Clear(Game1.bgColor); + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + if (++batchOpens == 1) + events.Rendering.RaiseEmpty(); + events.RenderingWorld.RaiseEmpty(); + if (Game1.background != null) + { + Game1.background.draw(Game1.spriteBatch); + } + Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch); + Game1.currentLocation.Map.GetLayer("Back").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, wrapAround: false, 4); + Game1.currentLocation.drawWater(Game1.spriteBatch); + this._farmerShadows.Clear(); + if (Game1.currentLocation.currentEvent != null && !Game1.currentLocation.currentEvent.isFestival && Game1.currentLocation.currentEvent.farmerActors.Count > 0) + { + foreach (Farmer f in Game1.currentLocation.currentEvent.farmerActors) { - int batchEnds = 0; - - if (events.Rendering.HasListeners()) + if ((f.IsLocalPlayer && Game1.displayFarmer) || !f.hidden) { - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); - events.Rendering.RaiseEmpty(); - Game1.spriteBatch.End(); + this._farmerShadows.Add(f); } - Game1.currentMinigame.draw(Game1.spriteBatch); - if (Game1.globalFade && !Game1.menuUp && (!Game1.nameSelectUp || Game1.messagePause)) + } + } + else + { + foreach (Farmer f2 in Game1.currentLocation.farmers) + { + if ((f2.IsLocalPlayer && Game1.displayFarmer) || !f2.hidden) { - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Microsoft.Xna.Framework.Color.Black * (Game1.gameMode == (byte)0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha)); - Game1.spriteBatch.End(); + this._farmerShadows.Add(f2); } - this.drawOverlays(Game1.spriteBatch); - if (target_screen == null) + } + } + if (!Game1.currentLocation.shouldHideCharacters()) + { + if (Game1.CurrentEvent == null) + { + foreach (NPC k in Game1.currentLocation.characters) { - if (++batchEnds == 1 && events.Rendered.HasListeners()) + if (!k.swimming && !k.HideShadow && !k.IsInvisible && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(k.getTileLocation())) { - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); - events.Rendered.RaiseEmpty(); - Game1.spriteBatch.End(); + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, k.Position + new Vector2((float)(k.Sprite.SpriteWidth * 4) / 2f, k.GetBoundingBox().Height + ((!k.IsMonster) ? 12 : 0))), Game1.shadowTexture.Bounds, Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), (4f + (float)k.yJumpOffset / 40f) * (float)k.scale, SpriteEffects.None, Math.Max(0f, (float)k.getStandingY() / 10000f) - 1E-06f); } - return; } - this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); - this.GraphicsDevice.Clear(Game1.bgColor); - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); - Game1.spriteBatch.Draw((Texture2D)target_screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(target_screen.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); - if (++batchEnds == 1) - events.Rendered.RaiseEmpty(); - Game1.spriteBatch.End(); } - else if (Game1.showingEndOfNightStuff) + else { - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - events.Rendering.RaiseEmpty(); - if (Game1.activeClickableMenu != null) + foreach (NPC l in Game1.CurrentEvent.actors) { - try - { - events.RenderingActiveMenu.RaiseEmpty(); - Game1.activeClickableMenu.draw(Game1.spriteBatch); - events.RenderedActiveMenu.RaiseEmpty(); - } - catch (Exception ex) + if (!l.swimming && !l.HideShadow && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(l.getTileLocation())) { - this.Monitor.Log($"The {Game1.activeClickableMenu.GetType().FullName} menu crashed while drawing itself during end-of-night-stuff. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); - Game1.activeClickableMenu.exitThisMenu(); + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, l.Position + new Vector2((float)(l.Sprite.SpriteWidth * 4) / 2f, l.GetBoundingBox().Height + ((!l.IsMonster) ? ((l.Sprite.SpriteHeight <= 16) ? (-4) : 12) : 0))), Game1.shadowTexture.Bounds, Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), (4f + (float)l.yJumpOffset / 40f) * (float)l.scale, SpriteEffects.None, Math.Max(0f, (float)l.getStandingY() / 10000f) - 1E-06f); } } - events.Rendered.RaiseEmpty(); - Game1.spriteBatch.End(); - this.drawOverlays(Game1.spriteBatch); - if (target_screen == null) - return; - this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); - this.GraphicsDevice.Clear(Game1.bgColor); - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); - Game1.spriteBatch.Draw((Texture2D)target_screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(target_screen.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); - Game1.spriteBatch.End(); } - else if (Game1.gameMode == (byte)6 || Game1.gameMode == (byte)3 && Game1.currentLocation == null) + foreach (Farmer f3 in this._farmerShadows) { - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - events.Rendering.RaiseEmpty(); - string str1 = ""; - for (int index = 0; (double)index < gameTime.TotalGameTime.TotalMilliseconds % 999.0 / 333.0; ++index) - str1 += "."; - string str2 = Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3688"); - string s = str2 + str1; - string str3 = str2 + "... "; - int widthOfString = SpriteText.getWidthOfString(str3, 999999); - int height = 64; - int x = 64; - int y = Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Bottom - height; - SpriteText.drawString(Game1.spriteBatch, s, x, y, 999999, widthOfString, height, 1f, 0.88f, false, 0, str3, -1, SpriteText.ScrollTextAlignment.Left); - events.Rendered.RaiseEmpty(); - Game1.spriteBatch.End(); - this.drawOverlays(Game1.spriteBatch); - if (target_screen != null) - { - this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); - this.GraphicsDevice.Clear(Game1.bgColor); - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); - Game1.spriteBatch.Draw((Texture2D)target_screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(target_screen.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); - Game1.spriteBatch.End(); - } - if (Game1.overlayMenu != null) + if (!Game1.multiplayer.isDisconnecting(f3.UniqueMultiplayerID) && !f3.swimming && !f3.isRidingHorse() && (Game1.currentLocation == null || !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(f3.getTileLocation()))) { - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - Game1.overlayMenu.draw(Game1.spriteBatch); - Game1.spriteBatch.End(); + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(f3.Position + new Vector2(32f, 24f)), Game1.shadowTexture.Bounds, Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), 4f - (((f3.running || f3.UsingTool) && f3.FarmerSprite.currentAnimationIndex > 1) ? ((float)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[f3.FarmerSprite.CurrentFrame]) * 0.5f) : 0f), SpriteEffects.None, 0f); } - //base.Draw(gameTime); } - else + } + Layer building_layer = Game1.currentLocation.Map.GetLayer("Buildings"); + building_layer.Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, wrapAround: false, 4); + Game1.mapDisplayDevice.EndScene(); + Game1.spriteBatch.End(); + Game1.spriteBatch.Begin(SpriteSortMode.FrontToBack, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + if (!Game1.currentLocation.shouldHideCharacters()) + { + if (Game1.CurrentEvent == null) { - byte batchOpens = 0; // used for rendering event - - Microsoft.Xna.Framework.Rectangle rectangle; - Viewport viewport; - if (Game1.gameMode == (byte)0) + foreach (NPC n in Game1.currentLocation.characters) { - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - if (++batchOpens == 1) - events.Rendering.RaiseEmpty(); - } - else - { - if (Game1.drawLighting) - { - this.GraphicsDevice.SetRenderTarget(Game1.lightmap); - this.GraphicsDevice.Clear(Microsoft.Xna.Framework.Color.White * 0.0f); - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - if (++batchOpens == 1) - events.Rendering.RaiseEmpty(); - Microsoft.Xna.Framework.Color color = !Game1.currentLocation.Name.StartsWith("UndergroundMine") || !(Game1.currentLocation is MineShaft) ? (Game1.ambientLight.Equals(Microsoft.Xna.Framework.Color.White) || Game1.isRaining && (bool)(NetFieldBase<bool, NetBool>)Game1.currentLocation.isOutdoors ? Game1.outdoorLight : Game1.ambientLight) : (Game1.currentLocation as MineShaft).getLightingColor(gameTime); - Game1.spriteBatch.Draw(Game1.staminaRect, Game1.lightmap.Bounds, color); - foreach (LightSource currentLightSource in Game1.currentLightSources) - { - if (!Game1.isRaining && !Game1.isDarkOut() || currentLightSource.lightContext.Value != LightSource.LightContext.WindowLight) - { - if (currentLightSource.PlayerID != 0L && currentLightSource.PlayerID != Game1.player.UniqueMultiplayerID) - { - Farmer farmerMaybeOffline = Game1.getFarmerMaybeOffline(currentLightSource.PlayerID); - if (farmerMaybeOffline == null || farmerMaybeOffline.currentLocation != null && farmerMaybeOffline.currentLocation.Name != Game1.currentLocation.Name || (bool)(NetFieldBase<bool, NetBool>)farmerMaybeOffline.hidden) - continue; - } - if (Utility.isOnScreen((Vector2)(NetFieldBase<Vector2, NetVector2>)currentLightSource.position, (int)((double)(float)(NetFieldBase<float, NetFloat>)currentLightSource.radius * 64.0 * 4.0))) - Game1.spriteBatch.Draw(currentLightSource.lightTexture, Game1.GlobalToLocal(Game1.viewport, (Vector2)(NetFieldBase<Vector2, NetVector2>)currentLightSource.position) / (float)(Game1.options.lightingQuality / 2), new Microsoft.Xna.Framework.Rectangle?(currentLightSource.lightTexture.Bounds), (Microsoft.Xna.Framework.Color)(NetFieldBase<Microsoft.Xna.Framework.Color, NetColor>)currentLightSource.color, 0.0f, new Vector2((float)currentLightSource.lightTexture.Bounds.Center.X, (float)currentLightSource.lightTexture.Bounds.Center.Y), (float)(NetFieldBase<float, NetFloat>)currentLightSource.radius / (float)(Game1.options.lightingQuality / 2), SpriteEffects.None, 0.9f); - } - } - Game1.spriteBatch.End(); - this.GraphicsDevice.SetRenderTarget(target_screen); - } - if (Game1.bloomDay && Game1.bloom != null) - Game1.bloom.BeginDraw(); - this.GraphicsDevice.Clear(Game1.bgColor); - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - if (++batchOpens == 1) - events.Rendering.RaiseEmpty(); - events.RenderingWorld.RaiseEmpty(); - if (Game1.background != null) - Game1.background.draw(Game1.spriteBatch); - Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch); - Game1.currentLocation.Map.GetLayer("Back").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, 4); - Game1.currentLocation.drawWater(Game1.spriteBatch); - this._farmerShadows.Clear(); - if (Game1.currentLocation.currentEvent != null && !Game1.currentLocation.currentEvent.isFestival && Game1.currentLocation.currentEvent.farmerActors.Count > 0) + if (!n.swimming && !n.HideShadow && !n.isInvisible && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(n.getTileLocation())) { - foreach (Farmer farmerActor in Game1.currentLocation.currentEvent.farmerActors) - { - if (farmerActor.IsLocalPlayer && Game1.displayFarmer || !(bool)(NetFieldBase<bool, NetBool>)farmerActor.hidden) - this._farmerShadows.Add(farmerActor); - } + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, n.Position + new Vector2((float)(n.Sprite.SpriteWidth * 4) / 2f, n.GetBoundingBox().Height + ((!n.IsMonster) ? 12 : 0))), Game1.shadowTexture.Bounds, Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), (4f + (float)n.yJumpOffset / 40f) * (float)n.scale, SpriteEffects.None, Math.Max(0f, (float)n.getStandingY() / 10000f) - 1E-06f); } - else - { - foreach (Farmer farmer in Game1.currentLocation.farmers) - { - if (farmer.IsLocalPlayer && Game1.displayFarmer || !(bool)(NetFieldBase<bool, NetBool>)farmer.hidden) - this._farmerShadows.Add(farmer); - } - } - if (!Game1.currentLocation.shouldHideCharacters()) - { - if (Game1.CurrentEvent == null) - { - foreach (NPC character in Game1.currentLocation.characters) - { - if (!(bool)(NetFieldBase<bool, NetBool>)character.swimming && !character.HideShadow && (!character.IsInvisible && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(character.getTileLocation()))) - Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, character.Position + new Vector2((float)(character.Sprite.SpriteWidth * 4) / 2f, (float)(character.GetBoundingBox().Height + (character.IsMonster ? 0 : 12)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)character.yJumpOffset / 40.0) * (float)(NetFieldBase<float, NetFloat>)character.scale, SpriteEffects.None, Math.Max(0.0f, (float)character.getStandingY() / 10000f) - 1E-06f); - } - } - else - { - foreach (NPC actor in Game1.CurrentEvent.actors) - { - if (!(bool)(NetFieldBase<bool, NetBool>)actor.swimming && !actor.HideShadow && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(actor.getTileLocation())) - Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, actor.Position + new Vector2((float)(actor.Sprite.SpriteWidth * 4) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : (actor.Sprite.SpriteHeight <= 16 ? -4 : 12))))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)actor.yJumpOffset / 40.0) * (float)(NetFieldBase<float, NetFloat>)actor.scale, SpriteEffects.None, Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 1E-06f); - } - } - foreach (Farmer farmerShadow in this._farmerShadows) - { - if (!Game1.multiplayer.isDisconnecting(farmerShadow.UniqueMultiplayerID) && !(bool)(NetFieldBase<bool, NetBool>)farmerShadow.swimming && !farmerShadow.isRidingHorse() && (Game1.currentLocation == null || !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(farmerShadow.getTileLocation()))) - { - SpriteBatch spriteBatch = Game1.spriteBatch; - Texture2D shadowTexture = Game1.shadowTexture; - Vector2 local = Game1.GlobalToLocal(farmerShadow.Position + new Vector2(32f, 24f)); - Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds); - Microsoft.Xna.Framework.Color white = Microsoft.Xna.Framework.Color.White; - Microsoft.Xna.Framework.Rectangle bounds = Game1.shadowTexture.Bounds; - double x = (double)bounds.Center.X; - bounds = Game1.shadowTexture.Bounds; - double y = (double)bounds.Center.Y; - Vector2 origin = new Vector2((float)x, (float)y); - double num = 4.0 - (!farmerShadow.running && !farmerShadow.UsingTool || farmerShadow.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmerShadow.FarmerSprite.CurrentFrame]) * 0.5); - spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, 0.0f, origin, (float)num, SpriteEffects.None, 0.0f); - } - } - } - Layer layer1 = Game1.currentLocation.Map.GetLayer("Buildings"); - layer1.Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, 4); - Game1.mapDisplayDevice.EndScene(); - Game1.spriteBatch.End(); - Game1.spriteBatch.Begin(SpriteSortMode.FrontToBack, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - if (!Game1.currentLocation.shouldHideCharacters()) - { - if (Game1.CurrentEvent == null) - { - foreach (NPC character in Game1.currentLocation.characters) - { - if (!(bool)(NetFieldBase<bool, NetBool>)character.swimming && !character.HideShadow && (!(bool)(NetFieldBase<bool, NetBool>)character.isInvisible && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(character.getTileLocation()))) - Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, character.Position + new Vector2((float)(character.Sprite.SpriteWidth * 4) / 2f, (float)(character.GetBoundingBox().Height + (character.IsMonster ? 0 : 12)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)character.yJumpOffset / 40.0) * (float)(NetFieldBase<float, NetFloat>)character.scale, SpriteEffects.None, Math.Max(0.0f, (float)character.getStandingY() / 10000f) - 1E-06f); - } - } - else - { - foreach (NPC actor in Game1.CurrentEvent.actors) - { - if (!(bool)(NetFieldBase<bool, NetBool>)actor.swimming && !actor.HideShadow && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(actor.getTileLocation())) - Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, actor.Position + new Vector2((float)(actor.Sprite.SpriteWidth * 4) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : 12)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)actor.yJumpOffset / 40.0) * (float)(NetFieldBase<float, NetFloat>)actor.scale, SpriteEffects.None, Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 1E-06f); - } - } - foreach (Farmer farmerShadow in this._farmerShadows) - { - float num1 = Math.Max(0.0001f, farmerShadow.getDrawLayer() + 0.00011f) - 0.0001f; - if (!(bool)(NetFieldBase<bool, NetBool>)farmerShadow.swimming && !farmerShadow.isRidingHorse() && (Game1.currentLocation != null && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(farmerShadow.getTileLocation()))) - { - SpriteBatch spriteBatch = Game1.spriteBatch; - Texture2D shadowTexture = Game1.shadowTexture; - Vector2 local = Game1.GlobalToLocal(farmerShadow.Position + new Vector2(32f, 24f)); - Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds); - Microsoft.Xna.Framework.Color white = Microsoft.Xna.Framework.Color.White; - Microsoft.Xna.Framework.Rectangle bounds = Game1.shadowTexture.Bounds; - double x = (double)bounds.Center.X; - bounds = Game1.shadowTexture.Bounds; - double y = (double)bounds.Center.Y; - Vector2 origin = new Vector2((float)x, (float)y); - double num2 = 4.0 - (!farmerShadow.running && !farmerShadow.UsingTool || farmerShadow.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmerShadow.FarmerSprite.CurrentFrame]) * 0.5); - double num3 = (double)num1; - spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, 0.0f, origin, (float)num2, SpriteEffects.None, (float)num3); - } - } - } - if ((Game1.eventUp || Game1.killScreen) && (!Game1.killScreen && Game1.currentLocation.currentEvent != null)) - Game1.currentLocation.currentEvent.draw(Game1.spriteBatch); - if (Game1.player.currentUpgrade != null && Game1.player.currentUpgrade.daysLeftTillUpgradeDone <= 3 && Game1.currentLocation.Name.Equals("Farm")) - Game1.spriteBatch.Draw(Game1.player.currentUpgrade.workerTexture, Game1.GlobalToLocal(Game1.viewport, Game1.player.currentUpgrade.positionOfCarpenter), new Microsoft.Xna.Framework.Rectangle?(Game1.player.currentUpgrade.getSourceRectangle()), Microsoft.Xna.Framework.Color.White, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, (float)(((double)Game1.player.currentUpgrade.positionOfCarpenter.Y + 48.0) / 10000.0)); - Game1.currentLocation.draw(Game1.spriteBatch); - foreach (Vector2 key in Game1.crabPotOverlayTiles.Keys) - { - Tile tile = layer1.Tiles[(int)key.X, (int)key.Y]; - if (tile != null) - { - Vector2 local = Game1.GlobalToLocal(Game1.viewport, key * 64f); - Location location = new Location((int)local.X, (int)local.Y); - Game1.mapDisplayDevice.DrawTile(tile, location, (float)(((double)key.Y * 64.0 - 1.0) / 10000.0)); - } - } - if (Game1.eventUp && Game1.currentLocation.currentEvent != null) - { - string messageToScreen = Game1.currentLocation.currentEvent.messageToScreen; - } - if (Game1.player.ActiveObject == null && (Game1.player.UsingTool || Game1.pickingTool) && (Game1.player.CurrentTool != null && (!Game1.player.CurrentTool.Name.Equals("Seeds") || Game1.pickingTool))) - Game1.drawTool(Game1.player); - if (Game1.currentLocation.Name.Equals("Farm")) - this.drawFarmBuildings(); - if (Game1.tvStation >= 0) - Game1.spriteBatch.Draw(Game1.tvStationTexture, Game1.GlobalToLocal(Game1.viewport, new Vector2(400f, 160f)), new Microsoft.Xna.Framework.Rectangle?(new Microsoft.Xna.Framework.Rectangle(Game1.tvStation * 24, 0, 24, 15)), Microsoft.Xna.Framework.Color.White, 0.0f, Vector2.Zero, 4f, SpriteEffects.None, 1E-08f); - if (Game1.panMode) - { - Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle((int)Math.Floor((double)(Game1.getOldMouseX() + Game1.viewport.X) / 64.0) * 64 - Game1.viewport.X, (int)Math.Floor((double)(Game1.getOldMouseY() + Game1.viewport.Y) / 64.0) * 64 - Game1.viewport.Y, 64, 64), Microsoft.Xna.Framework.Color.Lime * 0.75f); - foreach (Warp warp in (NetList<Warp, NetRef<Warp>>)Game1.currentLocation.warps) - Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle(warp.X * 64 - Game1.viewport.X, warp.Y * 64 - Game1.viewport.Y, 64, 64), Microsoft.Xna.Framework.Color.Red * 0.75f); - } - Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch); - Game1.currentLocation.Map.GetLayer("Front").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, 4); - Game1.mapDisplayDevice.EndScene(); - Game1.currentLocation.drawAboveFrontLayer(Game1.spriteBatch); - Game1.spriteBatch.End(); - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - if (Game1.displayFarmer && Game1.player.ActiveObject != null && ((bool)(NetFieldBase<bool, NetBool>)Game1.player.ActiveObject.bigCraftable && this.checkBigCraftableBoundariesForFrontLayer()) && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), Game1.player.getStandingY()), Game1.viewport.Size) == null) - Game1.drawPlayerHeldObject(Game1.player); - else if (Game1.displayFarmer && Game1.player.ActiveObject != null) - { - if (Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location((int)Game1.player.Position.X, (int)Game1.player.Position.Y - 38), Game1.viewport.Size) == null || Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location((int)Game1.player.Position.X, (int)Game1.player.Position.Y - 38), Game1.viewport.Size).TileIndexProperties.ContainsKey("FrontAlways")) - { - Layer layer2 = Game1.currentLocation.Map.GetLayer("Front"); - rectangle = Game1.player.GetBoundingBox(); - Location mapDisplayLocation1 = new Location(rectangle.Right, (int)Game1.player.Position.Y - 38); - xTile.Dimensions.Size size1 = Game1.viewport.Size; - if (layer2.PickTile(mapDisplayLocation1, size1) != null) - { - Layer layer3 = Game1.currentLocation.Map.GetLayer("Front"); - rectangle = Game1.player.GetBoundingBox(); - Location mapDisplayLocation2 = new Location(rectangle.Right, (int)Game1.player.Position.Y - 38); - xTile.Dimensions.Size size2 = Game1.viewport.Size; - if (layer3.PickTile(mapDisplayLocation2, size2).TileIndexProperties.ContainsKey("FrontAlways")) - goto label_139; - } - else - goto label_139; - } - Game1.drawPlayerHeldObject(Game1.player); - } - label_139: - if ((Game1.player.UsingTool || Game1.pickingTool) && Game1.player.CurrentTool != null && ((!Game1.player.CurrentTool.Name.Equals("Seeds") || Game1.pickingTool) && (Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), (int)Game1.player.Position.Y - 38), Game1.viewport.Size) != null && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), Game1.player.getStandingY()), Game1.viewport.Size) == null))) - Game1.drawTool(Game1.player); - if (Game1.currentLocation.Map.GetLayer("AlwaysFront") != null) - { - Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch); - Game1.currentLocation.Map.GetLayer("AlwaysFront").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, 4); - Game1.mapDisplayDevice.EndScene(); - } - if ((double)Game1.toolHold > 400.0 && Game1.player.CurrentTool.UpgradeLevel >= 1 && Game1.player.canReleaseTool) - { - Microsoft.Xna.Framework.Color color = Microsoft.Xna.Framework.Color.White; - switch ((int)((double)Game1.toolHold / 600.0) + 2) - { - case 1: - color = Tool.copperColor; - break; - case 2: - color = Tool.steelColor; - break; - case 3: - color = Tool.goldColor; - break; - case 4: - color = Tool.iridiumColor; - break; - } - Game1.spriteBatch.Draw(Game1.littleEffect, new Microsoft.Xna.Framework.Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X - 2, (int)Game1.player.getLocalPosition(Game1.viewport).Y - (Game1.player.CurrentTool.Name.Equals("Watering Can") ? 0 : 64) - 2, (int)((double)Game1.toolHold % 600.0 * 0.0799999982118607) + 4, 12), Microsoft.Xna.Framework.Color.Black); - Game1.spriteBatch.Draw(Game1.littleEffect, new Microsoft.Xna.Framework.Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X, (int)Game1.player.getLocalPosition(Game1.viewport).Y - (Game1.player.CurrentTool.Name.Equals("Watering Can") ? 0 : 64), (int)((double)Game1.toolHold % 600.0 * 0.0799999982118607), 8), color); - } - this.drawWeather(gameTime, target_screen); - if (Game1.farmEvent != null) - Game1.farmEvent.draw(Game1.spriteBatch); - if ((double)Game1.currentLocation.LightLevel > 0.0 && Game1.timeOfDay < 2000) - Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Microsoft.Xna.Framework.Color.Black * Game1.currentLocation.LightLevel); - if (Game1.screenGlow) - Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Game1.screenGlowColor * Game1.screenGlowAlpha); - Game1.currentLocation.drawAboveAlwaysFrontLayer(Game1.spriteBatch); - if (Game1.player.CurrentTool != null && Game1.player.CurrentTool is FishingRod && ((Game1.player.CurrentTool as FishingRod).isTimingCast || (double)(Game1.player.CurrentTool as FishingRod).castingChosenCountdown > 0.0 || ((Game1.player.CurrentTool as FishingRod).fishCaught || (Game1.player.CurrentTool as FishingRod).showingTreasure))) - Game1.player.CurrentTool.draw(Game1.spriteBatch); - Game1.spriteBatch.End(); - Game1.spriteBatch.Begin(SpriteSortMode.FrontToBack, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - if (Game1.eventUp && Game1.currentLocation.currentEvent != null) - { - foreach (NPC actor in Game1.currentLocation.currentEvent.actors) - { - if (actor.isEmoting) - { - Vector2 localPosition = actor.getLocalPosition(Game1.viewport); - localPosition.Y -= 140f; - if (actor.Age == 2) - localPosition.Y += 32f; - else if (actor.Gender == 1) - localPosition.Y += 10f; - Game1.spriteBatch.Draw(Game1.emoteSpriteSheet, localPosition, new Microsoft.Xna.Framework.Rectangle?(new Microsoft.Xna.Framework.Rectangle(actor.CurrentEmoteIndex * 16 % Game1.emoteSpriteSheet.Width, actor.CurrentEmoteIndex * 16 / Game1.emoteSpriteSheet.Width * 16, 16, 16)), Microsoft.Xna.Framework.Color.White, 0.0f, Vector2.Zero, 4f, SpriteEffects.None, (float)actor.getStandingY() / 10000f); - } - } - } - Game1.spriteBatch.End(); - if (Game1.drawLighting) - { - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, this.lightingBlend, SamplerState.LinearClamp, (DepthStencilState)null, (RasterizerState)null); - Game1.spriteBatch.Draw((Texture2D)Game1.lightmap, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(Game1.lightmap.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, Vector2.Zero, (float)(Game1.options.lightingQuality / 2), SpriteEffects.None, 1f); - if (Game1.isRaining && (bool)(NetFieldBase<bool, NetBool>)Game1.currentLocation.isOutdoors && !(Game1.currentLocation is Desert)) - Game1.spriteBatch.Draw(Game1.staminaRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Microsoft.Xna.Framework.Color.OrangeRed * 0.45f); - Game1.spriteBatch.End(); - } - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - events.RenderedWorld.RaiseEmpty(); - if (Game1.drawGrid) - { - int num1 = -Game1.viewport.X % 64; - float num2 = (float)(-Game1.viewport.Y % 64); - int num3 = num1; - while (true) - { - int num4 = num3; - viewport = Game1.graphics.GraphicsDevice.Viewport; - int width = viewport.Width; - if (num4 < width) - { - SpriteBatch spriteBatch = Game1.spriteBatch; - Texture2D staminaRect = Game1.staminaRect; - int x = num3; - int y = (int)num2; - viewport = Game1.graphics.GraphicsDevice.Viewport; - int height = viewport.Height; - Microsoft.Xna.Framework.Rectangle destinationRectangle = new Microsoft.Xna.Framework.Rectangle(x, y, 1, height); - Microsoft.Xna.Framework.Color color = Microsoft.Xna.Framework.Color.Red * 0.5f; - spriteBatch.Draw(staminaRect, destinationRectangle, color); - num3 += 64; - } - else - break; - } - float num5 = num2; - while (true) - { - double num4 = (double)num5; - viewport = Game1.graphics.GraphicsDevice.Viewport; - double height = (double)viewport.Height; - if (num4 < height) - { - SpriteBatch spriteBatch = Game1.spriteBatch; - Texture2D staminaRect = Game1.staminaRect; - int x = num1; - int y = (int)num5; - viewport = Game1.graphics.GraphicsDevice.Viewport; - int width = viewport.Width; - Microsoft.Xna.Framework.Rectangle destinationRectangle = new Microsoft.Xna.Framework.Rectangle(x, y, width, 1); - Microsoft.Xna.Framework.Color color = Microsoft.Xna.Framework.Color.Red * 0.5f; - spriteBatch.Draw(staminaRect, destinationRectangle, color); - num5 += 64f; - } - else - break; - } - } - if (Game1.currentBillboard != 0 && !this.takingMapScreenshot) - this.drawBillboard(); - if (!Game1.eventUp && Game1.farmEvent == null && (Game1.currentBillboard == 0 && Game1.gameMode == (byte)3) && (!this.takingMapScreenshot && Game1.isOutdoorMapSmallerThanViewport())) - { - SpriteBatch spriteBatch1 = Game1.spriteBatch; - Texture2D fadeToBlackRect1 = Game1.fadeToBlackRect; - int width1 = -Math.Min(Game1.viewport.X, 4096); - viewport = Game1.graphics.GraphicsDevice.Viewport; - int height1 = viewport.Height; - Microsoft.Xna.Framework.Rectangle destinationRectangle1 = new Microsoft.Xna.Framework.Rectangle(0, 0, width1, height1); - Microsoft.Xna.Framework.Color black1 = Microsoft.Xna.Framework.Color.Black; - spriteBatch1.Draw(fadeToBlackRect1, destinationRectangle1, black1); - SpriteBatch spriteBatch2 = Game1.spriteBatch; - Texture2D fadeToBlackRect2 = Game1.fadeToBlackRect; - int x = -Game1.viewport.X + Game1.currentLocation.map.Layers[0].LayerWidth * 64; - viewport = Game1.graphics.GraphicsDevice.Viewport; - int width2 = Math.Min(4096, viewport.Width - (-Game1.viewport.X + Game1.currentLocation.map.Layers[0].LayerWidth * 64)); - viewport = Game1.graphics.GraphicsDevice.Viewport; - int height2 = viewport.Height; - Microsoft.Xna.Framework.Rectangle destinationRectangle2 = new Microsoft.Xna.Framework.Rectangle(x, 0, width2, height2); - Microsoft.Xna.Framework.Color black2 = Microsoft.Xna.Framework.Color.Black; - spriteBatch2.Draw(fadeToBlackRect2, destinationRectangle2, black2); - } - if ((Game1.displayHUD || Game1.eventUp) && (Game1.currentBillboard == 0 && Game1.gameMode == (byte)3) && (!Game1.freezeControls && !Game1.panMode && (!Game1.HostPaused && !this.takingMapScreenshot))) - { - events.RenderingHud.RaiseEmpty(); - this.drawHUD(); - events.RenderedHud.RaiseEmpty(); - } - else if (Game1.activeClickableMenu == null) - { - FarmEvent farmEvent = Game1.farmEvent; - } - if (Game1.hudMessages.Count > 0 && !this.takingMapScreenshot) - { - for (int i = Game1.hudMessages.Count - 1; i >= 0; --i) - Game1.hudMessages[i].draw(Game1.spriteBatch, i); - } - } - if (Game1.farmEvent != null) - Game1.farmEvent.draw(Game1.spriteBatch); - if (Game1.dialogueUp && !Game1.nameSelectUp && !Game1.messagePause && ((Game1.activeClickableMenu == null || !(Game1.activeClickableMenu is DialogueBox)) && !this.takingMapScreenshot)) - this.drawDialogueBox(); - if (Game1.progressBar && !this.takingMapScreenshot) - { - SpriteBatch spriteBatch1 = Game1.spriteBatch; - Texture2D fadeToBlackRect = Game1.fadeToBlackRect; - int x1 = (Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Width - Game1.dialogueWidth) / 2; - rectangle = Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea(); - int y1 = rectangle.Bottom - 128; - int dialogueWidth = Game1.dialogueWidth; - Microsoft.Xna.Framework.Rectangle destinationRectangle1 = new Microsoft.Xna.Framework.Rectangle(x1, y1, dialogueWidth, 32); - Microsoft.Xna.Framework.Color lightGray = Microsoft.Xna.Framework.Color.LightGray; - spriteBatch1.Draw(fadeToBlackRect, destinationRectangle1, lightGray); - SpriteBatch spriteBatch2 = Game1.spriteBatch; - Texture2D staminaRect = Game1.staminaRect; - int x2 = (Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Width - Game1.dialogueWidth) / 2; - rectangle = Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea(); - int y2 = rectangle.Bottom - 128; - int width = (int)((double)Game1.pauseAccumulator / (double)Game1.pauseTime * (double)Game1.dialogueWidth); - Microsoft.Xna.Framework.Rectangle destinationRectangle2 = new Microsoft.Xna.Framework.Rectangle(x2, y2, width, 32); - Microsoft.Xna.Framework.Color dimGray = Microsoft.Xna.Framework.Color.DimGray; - spriteBatch2.Draw(staminaRect, destinationRectangle2, dimGray); - } - if (Game1.eventUp && Game1.currentLocation != null && Game1.currentLocation.currentEvent != null) - Game1.currentLocation.currentEvent.drawAfterMap(Game1.spriteBatch); - if (Game1.isRaining && Game1.currentLocation != null && ((bool)(NetFieldBase<bool, NetBool>)Game1.currentLocation.isOutdoors && !(Game1.currentLocation is Desert))) - { - SpriteBatch spriteBatch = Game1.spriteBatch; - Texture2D staminaRect = Game1.staminaRect; - viewport = Game1.graphics.GraphicsDevice.Viewport; - Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; - Microsoft.Xna.Framework.Color color = Microsoft.Xna.Framework.Color.Blue * 0.2f; - spriteBatch.Draw(staminaRect, bounds, color); - } - if ((Game1.fadeToBlack || Game1.globalFade) && !Game1.menuUp && ((!Game1.nameSelectUp || Game1.messagePause) && !this.takingMapScreenshot)) - { - SpriteBatch spriteBatch = Game1.spriteBatch; - Texture2D fadeToBlackRect = Game1.fadeToBlackRect; - viewport = Game1.graphics.GraphicsDevice.Viewport; - Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; - Microsoft.Xna.Framework.Color color = Microsoft.Xna.Framework.Color.Black * (Game1.gameMode == (byte)0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha); - spriteBatch.Draw(fadeToBlackRect, bounds, color); } - else if ((double)Game1.flashAlpha > 0.0 && !this.takingMapScreenshot) + } + else + { + foreach (NPC n2 in Game1.CurrentEvent.actors) { - if (Game1.options.screenFlash) + if (!n2.swimming && !n2.HideShadow && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(n2.getTileLocation())) { - SpriteBatch spriteBatch = Game1.spriteBatch; - Texture2D fadeToBlackRect = Game1.fadeToBlackRect; - viewport = Game1.graphics.GraphicsDevice.Viewport; - Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; - Microsoft.Xna.Framework.Color color = Microsoft.Xna.Framework.Color.White * Math.Min(1f, Game1.flashAlpha); - spriteBatch.Draw(fadeToBlackRect, bounds, color); + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, n2.Position + new Vector2((float)(n2.Sprite.SpriteWidth * 4) / 2f, n2.GetBoundingBox().Height + ((!n2.IsMonster) ? 12 : 0))), Game1.shadowTexture.Bounds, Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), (4f + (float)n2.yJumpOffset / 40f) * (float)n2.scale, SpriteEffects.None, Math.Max(0f, (float)n2.getStandingY() / 10000f) - 1E-06f); } - Game1.flashAlpha -= 0.1f; } - if ((Game1.messagePause || Game1.globalFade) && (Game1.dialogueUp && !this.takingMapScreenshot)) - this.drawDialogueBox(); - if (!this.takingMapScreenshot) - { - foreach (TemporaryAnimatedSprite overlayTempSprite in Game1.screenOverlayTempSprites) - overlayTempSprite.draw(Game1.spriteBatch, true, 0, 0, 1f); - } - if (Game1.debugMode) + } + foreach (Farmer f4 in this._farmerShadows) + { + float draw_layer = Math.Max(0.0001f, f4.getDrawLayer() + 0.00011f) - 0.0001f; + if (!f4.swimming && !f4.isRidingHorse() && Game1.currentLocation != null && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(f4.getTileLocation())) { - StringBuilder debugStringBuilder = Game1._debugStringBuilder; - debugStringBuilder.Clear(); - if (Game1.panMode) - { - debugStringBuilder.Append((Game1.getOldMouseX() + Game1.viewport.X) / 64); - debugStringBuilder.Append(","); - debugStringBuilder.Append((Game1.getOldMouseY() + Game1.viewport.Y) / 64); - } - else - { - debugStringBuilder.Append("player: "); - debugStringBuilder.Append(Game1.player.getStandingX() / 64); - debugStringBuilder.Append(", "); - debugStringBuilder.Append(Game1.player.getStandingY() / 64); - } - debugStringBuilder.Append(" mouseTransparency: "); - debugStringBuilder.Append(Game1.mouseCursorTransparency); - debugStringBuilder.Append(" mousePosition: "); - debugStringBuilder.Append(Game1.getMouseX()); - debugStringBuilder.Append(","); - debugStringBuilder.Append(Game1.getMouseY()); - debugStringBuilder.Append(Environment.NewLine); - debugStringBuilder.Append(" mouseWorldPosition: "); - debugStringBuilder.Append(Game1.getMouseX() + Game1.viewport.X); - debugStringBuilder.Append(","); - debugStringBuilder.Append(Game1.getMouseY() + Game1.viewport.Y); - debugStringBuilder.Append(" debugOutput: "); - debugStringBuilder.Append(Game1.debugOutput); - Game1.spriteBatch.DrawString(Game1.smallFont, debugStringBuilder, new Vector2((float)this.GraphicsDevice.Viewport.GetTitleSafeArea().X, (float)(this.GraphicsDevice.Viewport.GetTitleSafeArea().Y + Game1.smallFont.LineSpacing * 8)), Microsoft.Xna.Framework.Color.Red, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f); + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(f4.Position + new Vector2(32f, 24f)), Game1.shadowTexture.Bounds, Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), 4f - (((f4.running || f4.UsingTool) && f4.FarmerSprite.currentAnimationIndex > 1) ? ((float)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[f4.FarmerSprite.CurrentFrame]) * 0.5f) : 0f), SpriteEffects.None, draw_layer); } - if (Game1.showKeyHelp && !this.takingMapScreenshot) - Game1.spriteBatch.DrawString(Game1.smallFont, Game1.keyHelpString, new Vector2(64f, (float)(Game1.viewport.Height - 64 - (Game1.dialogueUp ? 192 + (Game1.isQuestion ? Game1.questionChoices.Count * 64 : 0) : 0)) - Game1.smallFont.MeasureString(Game1.keyHelpString).Y), Microsoft.Xna.Framework.Color.LightGray, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f); - if (Game1.activeClickableMenu != null && !this.takingMapScreenshot) + } + } + if ((Game1.eventUp || Game1.killScreen) && !Game1.killScreen && Game1.currentLocation.currentEvent != null) + { + Game1.currentLocation.currentEvent.draw(Game1.spriteBatch); + } + if (Game1.player.currentUpgrade != null && Game1.player.currentUpgrade.daysLeftTillUpgradeDone <= 3 && Game1.currentLocation.Name.Equals("Farm")) + { + Game1.spriteBatch.Draw(Game1.player.currentUpgrade.workerTexture, Game1.GlobalToLocal(Game1.viewport, Game1.player.currentUpgrade.positionOfCarpenter), Game1.player.currentUpgrade.getSourceRectangle(), Color.White, 0f, Vector2.Zero, 1f, SpriteEffects.None, (Game1.player.currentUpgrade.positionOfCarpenter.Y + 48f) / 10000f); + } + Game1.currentLocation.draw(Game1.spriteBatch); + foreach (Vector2 tile_position in Game1.crabPotOverlayTiles.Keys) + { + Tile tile = building_layer.Tiles[(int)tile_position.X, (int)tile_position.Y]; + if (tile != null) + { + Vector2 vector_draw_position = Game1.GlobalToLocal(Game1.viewport, tile_position * 64f); + Location draw_location = new Location((int)vector_draw_position.X, (int)vector_draw_position.Y); + Game1.mapDisplayDevice.DrawTile(tile, draw_location, (tile_position.Y * 64f - 1f) / 10000f); + } + } + if (Game1.eventUp && Game1.currentLocation.currentEvent != null) + { + _ = Game1.currentLocation.currentEvent.messageToScreen; + } + if (Game1.player.ActiveObject == null && (Game1.player.UsingTool || Game1.pickingTool) && Game1.player.CurrentTool != null && (!Game1.player.CurrentTool.Name.Equals("Seeds") || Game1.pickingTool)) + { + Game1.drawTool(Game1.player); + } + if (Game1.currentLocation.Name.Equals("Farm")) + { + this.drawFarmBuildings(); + } + if (Game1.tvStation >= 0) + { + Game1.spriteBatch.Draw(Game1.tvStationTexture, Game1.GlobalToLocal(Game1.viewport, new Vector2(400f, 160f)), new Microsoft.Xna.Framework.Rectangle(Game1.tvStation * 24, 0, 24, 15), Color.White, 0f, Vector2.Zero, 4f, SpriteEffects.None, 1E-08f); + } + if (Game1.panMode) + { + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle((int)Math.Floor((double)(Game1.getOldMouseX() + Game1.viewport.X) / 64.0) * 64 - Game1.viewport.X, (int)Math.Floor((double)(Game1.getOldMouseY() + Game1.viewport.Y) / 64.0) * 64 - Game1.viewport.Y, 64, 64), Color.Lime * 0.75f); + foreach (Warp w in Game1.currentLocation.warps) + { + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle(w.X * 64 - Game1.viewport.X, w.Y * 64 - Game1.viewport.Y, 64, 64), Color.Red * 0.75f); + } + } + Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch); + Game1.currentLocation.Map.GetLayer("Front").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, wrapAround: false, 4); + Game1.mapDisplayDevice.EndScene(); + Game1.currentLocation.drawAboveFrontLayer(Game1.spriteBatch); + Game1.spriteBatch.End(); + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + if (Game1.displayFarmer && Game1.player.ActiveObject != null && (bool)Game1.player.ActiveObject.bigCraftable && this.checkBigCraftableBoundariesForFrontLayer() && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), Game1.player.getStandingY()), Game1.viewport.Size) == null) + { + Game1.drawPlayerHeldObject(Game1.player); + } + else if (Game1.displayFarmer && Game1.player.ActiveObject != null && ((Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location((int)Game1.player.Position.X, (int)Game1.player.Position.Y - 38), Game1.viewport.Size) != null && !Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location((int)Game1.player.Position.X, (int)Game1.player.Position.Y - 38), Game1.viewport.Size).TileIndexProperties.ContainsKey("FrontAlways")) || (Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.GetBoundingBox().Right, (int)Game1.player.Position.Y - 38), Game1.viewport.Size) != null && !Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.GetBoundingBox().Right, (int)Game1.player.Position.Y - 38), Game1.viewport.Size).TileIndexProperties.ContainsKey("FrontAlways")))) + { + Game1.drawPlayerHeldObject(Game1.player); + } + if ((Game1.player.UsingTool || Game1.pickingTool) && Game1.player.CurrentTool != null && (!Game1.player.CurrentTool.Name.Equals("Seeds") || Game1.pickingTool) && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), (int)Game1.player.Position.Y - 38), Game1.viewport.Size) != null && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), Game1.player.getStandingY()), Game1.viewport.Size) == null) + { + Game1.drawTool(Game1.player); + } + if (Game1.currentLocation.Map.GetLayer("AlwaysFront") != null) + { + Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch); + Game1.currentLocation.Map.GetLayer("AlwaysFront").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, wrapAround: false, 4); + Game1.mapDisplayDevice.EndScene(); + } + if (Game1.toolHold > 400f && Game1.player.CurrentTool.UpgradeLevel >= 1 && Game1.player.canReleaseTool) + { + Color barColor = Color.White; + switch ((int)(Game1.toolHold / 600f) + 2) + { + case 1: + barColor = Tool.copperColor; + break; + case 2: + barColor = Tool.steelColor; + break; + case 3: + barColor = Tool.goldColor; + break; + case 4: + barColor = Tool.iridiumColor; + break; + } + Game1.spriteBatch.Draw(Game1.littleEffect, new Microsoft.Xna.Framework.Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X - 2, (int)Game1.player.getLocalPosition(Game1.viewport).Y - ((!Game1.player.CurrentTool.Name.Equals("Watering Can")) ? 64 : 0) - 2, (int)(Game1.toolHold % 600f * 0.08f) + 4, 12), Color.Black); + Game1.spriteBatch.Draw(Game1.littleEffect, new Microsoft.Xna.Framework.Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X, (int)Game1.player.getLocalPosition(Game1.viewport).Y - ((!Game1.player.CurrentTool.Name.Equals("Watering Can")) ? 64 : 0), (int)(Game1.toolHold % 600f * 0.08f), 8), barColor); + } + this.drawWeather(gameTime, target_screen); + if (Game1.farmEvent != null) + { + Game1.farmEvent.draw(Game1.spriteBatch); + } + if (Game1.currentLocation.LightLevel > 0f && Game1.timeOfDay < 2000) + { + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * Game1.currentLocation.LightLevel); + } + if (Game1.screenGlow) + { + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Game1.screenGlowColor * Game1.screenGlowAlpha); + } + Game1.currentLocation.drawAboveAlwaysFrontLayer(Game1.spriteBatch); + if (Game1.player.CurrentTool != null && Game1.player.CurrentTool is FishingRod && ((Game1.player.CurrentTool as FishingRod).isTimingCast || (Game1.player.CurrentTool as FishingRod).castingChosenCountdown > 0f || (Game1.player.CurrentTool as FishingRod).fishCaught || (Game1.player.CurrentTool as FishingRod).showingTreasure)) + { + Game1.player.CurrentTool.draw(Game1.spriteBatch); + } + Game1.spriteBatch.End(); + Game1.spriteBatch.Begin(SpriteSortMode.FrontToBack, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + if (Game1.eventUp && Game1.currentLocation.currentEvent != null) + { + foreach (NPC m in Game1.currentLocation.currentEvent.actors) + { + if (m.isEmoting) { - try + Vector2 emotePosition = m.getLocalPosition(Game1.viewport); + emotePosition.Y -= 140f; + if (m.Age == 2) { - events.RenderingActiveMenu.RaiseEmpty(); - Game1.activeClickableMenu.draw(Game1.spriteBatch); - events.RenderedActiveMenu.RaiseEmpty(); + emotePosition.Y += 32f; } - catch (Exception ex) + else if (m.Gender == 1) { - this.Monitor.Log($"The {Game1.activeClickableMenu.GetType().FullName} menu crashed while drawing itself. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); - Game1.activeClickableMenu.exitThisMenu(); + emotePosition.Y += 10f; } + Game1.spriteBatch.Draw(Game1.emoteSpriteSheet, emotePosition, new Microsoft.Xna.Framework.Rectangle(m.CurrentEmoteIndex * 16 % Game1.emoteSpriteSheet.Width, m.CurrentEmoteIndex * 16 / Game1.emoteSpriteSheet.Width * 16, 16, 16), Color.White, 0f, Vector2.Zero, 4f, SpriteEffects.None, (float)m.getStandingY() / 10000f); } - else if (Game1.farmEvent != null) - Game1.farmEvent.drawAboveEverything(Game1.spriteBatch); - if (Game1.emoteMenu != null && !this.takingMapScreenshot) - Game1.emoteMenu.draw(Game1.spriteBatch); - if (Game1.HostPaused && !this.takingMapScreenshot) - { - string s = Game1.content.LoadString("Strings\\StringsFromCSFiles:DayTimeMoneyBox.cs.10378"); - SpriteText.drawStringWithScrollBackground(Game1.spriteBatch, s, 96, 32, "", 1f, -1, SpriteText.ScrollTextAlignment.Left); - } - - events.Rendered.RaiseEmpty(); - Game1.spriteBatch.End(); - this.drawOverlays(Game1.spriteBatch); - this.renderScreenBuffer(target_screen); + } + } + Game1.spriteBatch.End(); + if (Game1.drawLighting) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, this.lightingBlend, SamplerState.LinearClamp, null, null); + Game1.spriteBatch.Draw(Game1.lightmap, Vector2.Zero, Game1.lightmap.Bounds, Color.White, 0f, Vector2.Zero, Game1.options.lightingQuality / 2, SpriteEffects.None, 1f); + if (Game1.isRaining && (bool)Game1.currentLocation.isOutdoors && !(Game1.currentLocation is Desert)) + { + Game1.spriteBatch.Draw(Game1.staminaRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.OrangeRed * 0.45f); + } + Game1.spriteBatch.End(); + } + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + events.RenderedWorld.RaiseEmpty(); + if (Game1.drawGrid) + { + int startingX = -Game1.viewport.X % 64; + float startingY = -Game1.viewport.Y % 64; + for (int x = startingX; x < Game1.graphics.GraphicsDevice.Viewport.Width; x += 64) + { + Game1.spriteBatch.Draw(Game1.staminaRect, new Microsoft.Xna.Framework.Rectangle(x, (int)startingY, 1, Game1.graphics.GraphicsDevice.Viewport.Height), Color.Red * 0.5f); + } + for (float y = startingY; y < (float)Game1.graphics.GraphicsDevice.Viewport.Height; y += 64f) + { + Game1.spriteBatch.Draw(Game1.staminaRect, new Microsoft.Xna.Framework.Rectangle(startingX, (int)y, Game1.graphics.GraphicsDevice.Viewport.Width, 1), Color.Red * 0.5f); + } + } + if (Game1.currentBillboard != 0 && !this.takingMapScreenshot) + { + this.drawBillboard(); + } + if (!Game1.eventUp && Game1.farmEvent == null && Game1.currentBillboard == 0 && Game1.gameMode == 3 && !this.takingMapScreenshot && Game1.isOutdoorMapSmallerThanViewport()) + { + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle(0, 0, -Math.Min(Game1.viewport.X, 4096), Game1.graphics.GraphicsDevice.Viewport.Height), Color.Black); + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle(-Game1.viewport.X + Game1.currentLocation.map.Layers[0].LayerWidth * 64, 0, Math.Min(4096, Game1.graphics.GraphicsDevice.Viewport.Width - (-Game1.viewport.X + Game1.currentLocation.map.Layers[0].LayerWidth * 64)), Game1.graphics.GraphicsDevice.Viewport.Height), Color.Black); + } + if ((Game1.displayHUD || Game1.eventUp) && Game1.currentBillboard == 0 && Game1.gameMode == 3 && !Game1.freezeControls && !Game1.panMode && !Game1.HostPaused && !this.takingMapScreenshot) + { + events.RenderingHud.RaiseEmpty(); + this.drawHUD(); + events.RenderedHud.RaiseEmpty(); + } + else if (Game1.activeClickableMenu == null) + { + _ = Game1.farmEvent; + } + if (Game1.hudMessages.Count > 0 && !this.takingMapScreenshot) + { + for (int j = Game1.hudMessages.Count - 1; j >= 0; j--) + { + Game1.hudMessages[j].draw(Game1.spriteBatch, j); } } } - } - - /// <summary>Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</summary> - /// <param name="message">The fatal log message.</param> - private void ExitGameImmediately(string message) - { - this.Monitor.LogFatal(message); - this.CancellationToken.Cancel(); + if (Game1.farmEvent != null) + { + Game1.farmEvent.draw(Game1.spriteBatch); + } + if (Game1.dialogueUp && !Game1.nameSelectUp && !Game1.messagePause && (Game1.activeClickableMenu == null || !(Game1.activeClickableMenu is DialogueBox)) && !this.takingMapScreenshot) + { + this.drawDialogueBox(); + } + if (Game1.progressBar && !this.takingMapScreenshot) + { + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle((Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Width - Game1.dialogueWidth) / 2, Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Bottom - 128, Game1.dialogueWidth, 32), Color.LightGray); + Game1.spriteBatch.Draw(Game1.staminaRect, new Microsoft.Xna.Framework.Rectangle((Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Width - Game1.dialogueWidth) / 2, Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Bottom - 128, (int)(Game1.pauseAccumulator / Game1.pauseTime * (float)Game1.dialogueWidth), 32), Color.DimGray); + } + if (Game1.eventUp && Game1.currentLocation != null && Game1.currentLocation.currentEvent != null) + { + Game1.currentLocation.currentEvent.drawAfterMap(Game1.spriteBatch); + } + if (Game1.isRaining && Game1.currentLocation != null && (bool)Game1.currentLocation.isOutdoors && !(Game1.currentLocation is Desert)) + { + Game1.spriteBatch.Draw(Game1.staminaRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Blue * 0.2f); + } + if ((Game1.fadeToBlack || Game1.globalFade) && !Game1.menuUp && (!Game1.nameSelectUp || Game1.messagePause) && !this.takingMapScreenshot) + { + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * ((Game1.gameMode == 0) ? (1f - Game1.fadeToBlackAlpha) : Game1.fadeToBlackAlpha)); + } + else if (Game1.flashAlpha > 0f && !this.takingMapScreenshot) + { + if (Game1.options.screenFlash) + { + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.White * Math.Min(1f, Game1.flashAlpha)); + } + Game1.flashAlpha -= 0.1f; + } + if ((Game1.messagePause || Game1.globalFade) && Game1.dialogueUp && !this.takingMapScreenshot) + { + this.drawDialogueBox(); + } + if (!this.takingMapScreenshot) + { + foreach (TemporaryAnimatedSprite screenOverlayTempSprite in Game1.screenOverlayTempSprites) + { + screenOverlayTempSprite.draw(Game1.spriteBatch, localPosition: true); + } + } + if (Game1.debugMode) + { + StringBuilder sb = Game1._debugStringBuilder; + sb.Clear(); + if (Game1.panMode) + { + sb.Append((Game1.getOldMouseX() + Game1.viewport.X) / 64); + sb.Append(","); + sb.Append((Game1.getOldMouseY() + Game1.viewport.Y) / 64); + } + else + { + sb.Append("player: "); + sb.Append(Game1.player.getStandingX() / 64); + sb.Append(", "); + sb.Append(Game1.player.getStandingY() / 64); + } + sb.Append(" mouseTransparency: "); + sb.Append(Game1.mouseCursorTransparency); + sb.Append(" mousePosition: "); + sb.Append(Game1.getMouseX()); + sb.Append(","); + sb.Append(Game1.getMouseY()); + sb.Append(Environment.NewLine); + sb.Append(" mouseWorldPosition: "); + sb.Append(Game1.getMouseX() + Game1.viewport.X); + sb.Append(","); + sb.Append(Game1.getMouseY() + Game1.viewport.Y); + sb.Append(" debugOutput: "); + sb.Append(Game1.debugOutput); + Game1.spriteBatch.DrawString(Game1.smallFont, sb, new Vector2(base.GraphicsDevice.Viewport.GetTitleSafeArea().X, base.GraphicsDevice.Viewport.GetTitleSafeArea().Y + Game1.smallFont.LineSpacing * 8), Color.Red, 0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f); + } + if (Game1.showKeyHelp && !this.takingMapScreenshot) + { + Game1.spriteBatch.DrawString(Game1.smallFont, Game1.keyHelpString, new Vector2(64f, (float)(Game1.viewport.Height - 64 - (Game1.dialogueUp ? (192 + (Game1.isQuestion ? (Game1.questionChoices.Count * 64) : 0)) : 0)) - Game1.smallFont.MeasureString(Game1.keyHelpString).Y), Color.LightGray, 0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f); + } + if (Game1.activeClickableMenu != null && !this.takingMapScreenshot) + { + try + { + events.RenderingActiveMenu.RaiseEmpty(); + Game1.activeClickableMenu.draw(Game1.spriteBatch); + events.RenderedActiveMenu.RaiseEmpty(); + } + catch (Exception ex) + { + this.Monitor.Log($"The {Game1.activeClickableMenu.GetType().FullName} menu crashed while drawing itself. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); + Game1.activeClickableMenu.exitThisMenu(); + } + } + else if (Game1.farmEvent != null) + { + Game1.farmEvent.drawAboveEverything(Game1.spriteBatch); + } + if (Game1.emoteMenu != null && !this.takingMapScreenshot) + { + Game1.emoteMenu.draw(Game1.spriteBatch); + } + if (Game1.HostPaused && !this.takingMapScreenshot) + { + string msg2 = Game1.content.LoadString("Strings\\StringsFromCSFiles:DayTimeMoneyBox.cs.10378"); + SpriteText.drawStringWithScrollBackground(Game1.spriteBatch, msg2, 96, 32); + } + events.Rendered.RaiseEmpty(); + Game1.spriteBatch.End(); + this.drawOverlays(Game1.spriteBatch); + this.renderScreenBuffer(target_screen); } } } diff --git a/src/SMAPI/Framework/SGameConstructorHack.cs b/src/SMAPI/Framework/SGameConstructorHack.cs deleted file mode 100644 index f70dec03..00000000 --- a/src/SMAPI/Framework/SGameConstructorHack.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using StardewModdingAPI.Framework.Reflection; -using StardewModdingAPI.Toolkit.Serialization; -using StardewValley; - -namespace StardewModdingAPI.Framework -{ - /// <summary>The static state to use while <see cref="Game1"/> is initializing, which happens before the <see cref="SGame"/> constructor runs.</summary> - internal class SGameConstructorHack - { - /********* - ** Accessors - *********/ - /// <summary>Encapsulates monitoring and logging.</summary> - public IMonitor Monitor { get; } - - /// <summary>Simplifies access to private game code.</summary> - public Reflector Reflection { get; } - - /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> - public JsonHelper JsonHelper { get; } - - /// <summary>A callback to invoke the first time *any* game content manager loads an asset.</summary> - public Action OnLoadingFirstAsset { get; } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="monitor">Encapsulates monitoring and logging.</param> - /// <param name="reflection">Simplifies access to private game code.</param> - /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> - /// <param name="onLoadingFirstAsset">A callback to invoke the first time *any* game content manager loads an asset.</param> - public SGameConstructorHack(IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset) - { - this.Monitor = monitor; - this.Reflection = reflection; - this.JsonHelper = jsonHelper; - this.OnLoadingFirstAsset = onLoadingFirstAsset; - } - } -} diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs index f0fb9485..0908b02a 100644 --- a/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs +++ b/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs @@ -58,7 +58,6 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots this.Inventory = watcher.TryGetInventoryChanges(out SnapshotItemListDiff itemChanges) ? itemChanges : this.EmptyItemListDiff; - } } } diff --git a/src/SMAPI/Framework/Utilities/Countdown.cs b/src/SMAPI/Framework/Utilities/Countdown.cs index 921a35ce..342b4258 100644 --- a/src/SMAPI/Framework/Utilities/Countdown.cs +++ b/src/SMAPI/Framework/Utilities/Countdown.cs @@ -34,7 +34,7 @@ this.Current--; return true; } - + /// <summary>Restart the countdown.</summary> public void Reset() { diff --git a/src/SMAPI/Framework/WatcherCore.cs b/src/SMAPI/Framework/WatcherCore.cs index c89efa44..393f6a37 100644 --- a/src/SMAPI/Framework/WatcherCore.cs +++ b/src/SMAPI/Framework/WatcherCore.cs @@ -56,16 +56,17 @@ namespace StardewModdingAPI.Framework *********/ /// <summary>Construct an instance.</summary> /// <param name="inputState">Manages input visible to the game.</param> - public WatcherCore(SInputState inputState) + /// <param name="gameLocations">The observable list of game locations.</param> + public WatcherCore(SInputState inputState, ObservableCollection<GameLocation> gameLocations) { // init watchers this.CursorWatcher = WatcherFactory.ForEquatable(() => inputState.CursorPosition); - this.MouseWheelScrollWatcher = WatcherFactory.ForEquatable(() => inputState.LastMouse.ScrollWheelValue); + this.MouseWheelScrollWatcher = WatcherFactory.ForEquatable(() => inputState.MouseState.ScrollWheelValue); this.SaveIdWatcher = WatcherFactory.ForEquatable(() => Game1.hasLoadedGame ? Game1.uniqueIDForThisGame : 0); this.WindowSizeWatcher = WatcherFactory.ForEquatable(() => new Point(Game1.viewport.Width, Game1.viewport.Height)); this.TimeWatcher = WatcherFactory.ForEquatable(() => Game1.timeOfDay); this.ActiveMenuWatcher = WatcherFactory.ForReference(() => Game1.activeClickableMenu); - this.LocationsWatcher = new WorldLocationsTracker((ObservableCollection<GameLocation>)Game1.locations, MineShaft.activeMines); + this.LocationsWatcher = new WorldLocationsTracker(gameLocations, MineShaft.activeMines); this.LocaleWatcher = WatcherFactory.ForGenericEquality(() => LocalizedContentManager.CurrentLanguageCode); this.Watchers.AddRange(new IWatcher[] { diff --git a/src/SMAPI/IAssetDataForMap.cs b/src/SMAPI/IAssetDataForMap.cs index 769ca07c..bfaba9ba 100644 --- a/src/SMAPI/IAssetDataForMap.cs +++ b/src/SMAPI/IAssetDataForMap.cs @@ -13,6 +13,6 @@ namespace StardewModdingAPI /// <param name="source">The map from which to copy.</param> /// <param name="sourceArea">The tile area within the source map to copy, or <c>null</c> for the entire source map size. This must be within the bounds of the <paramref name="source"/> map.</param> /// <param name="targetArea">The tile area within the target map to overwrite, or <c>null</c> to patch the whole map. The original content within this area will be erased. This must be within the bounds of the existing map.</param> - public void PatchMap(Map source, Rectangle? sourceArea = null, Rectangle? targetArea = null); + void PatchMap(Map source, Rectangle? sourceArea = null, Rectangle? targetArea = null); } } diff --git a/src/SMAPI/IReflectedField.cs b/src/SMAPI/IReflectedField.cs index 43ddad42..7ff61f29 100644 --- a/src/SMAPI/IReflectedField.cs +++ b/src/SMAPI/IReflectedField.cs @@ -23,4 +23,4 @@ namespace StardewModdingAPI //// <param name="value">The value to set.</param> void SetValue(TValue value); } -}
\ No newline at end of file +} diff --git a/src/SMAPI/IReflectedMethod.cs b/src/SMAPI/IReflectedMethod.cs index de83b98c..646e7301 100644 --- a/src/SMAPI/IReflectedMethod.cs +++ b/src/SMAPI/IReflectedMethod.cs @@ -24,4 +24,4 @@ namespace StardewModdingAPI /// <param name="arguments">The method arguments to pass in.</param> void Invoke(params object[] arguments); } -}
\ No newline at end of file +} diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs index 5c77bf66..71199d59 100644 --- a/src/SMAPI/Metadata/CoreAssetPropagator.cs +++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs @@ -34,9 +34,6 @@ namespace StardewModdingAPI.Metadata /// <summary>Simplifies access to private game code.</summary> private readonly Reflector Reflection; - /// <summary>Encapsulates monitoring and logging.</summary> - private readonly IMonitor Monitor; - /// <summary>Optimized bucket categories for batch reloading assets.</summary> private enum AssetBucket { @@ -57,12 +54,10 @@ namespace StardewModdingAPI.Metadata /// <summary>Initialize the core asset data.</summary> /// <param name="assertAndNormalizeAssetName">Normalizes an asset key to match the cache key and assert that it's valid.</param> /// <param name="reflection">Simplifies access to private code.</param> - /// <param name="monitor">Encapsulates monitoring and logging.</param> - public CoreAssetPropagator(Func<string, string> assertAndNormalizeAssetName, Reflector reflection, IMonitor monitor) + public CoreAssetPropagator(Func<string, string> assertAndNormalizeAssetName, Reflector reflection) { this.AssertAndNormalizeAssetName = assertAndNormalizeAssetName; this.Reflection = reflection; - this.Monitor = monitor; } /// <summary>Reload one of the game's core assets (if applicable).</summary> @@ -457,17 +452,7 @@ namespace StardewModdingAPI.Metadata return false; case "minigames\\titlebuttons": // TitleMenu - { - if (Game1.activeClickableMenu is TitleMenu titleMenu) - { - Texture2D texture = content.Load<Texture2D>(key); - titleMenu.titleButtonsTexture = texture; - foreach (TemporaryAnimatedSprite bird in titleMenu.birds) - bird.texture = texture; - return true; - } - } - return false; + return this.ReloadTitleButtons(content, key); /**** ** Content\TileSheets @@ -574,6 +559,32 @@ namespace StardewModdingAPI.Metadata /**** ** Reload texture methods ****/ + /// <summary>Reload buttons on the title screen.</summary> + /// <param name="content">The content manager through which to reload the asset.</param> + /// <param name="key">The asset key to reload.</param> + /// <returns>Returns whether any textures were reloaded.</returns> + /// <remarks>Derived from the <see cref="TitleMenu"/> constructor and <see cref="TitleMenu.setUpIcons"/>.</remarks> + private bool ReloadTitleButtons(LocalizedContentManager content, string key) + { + if (Game1.activeClickableMenu is TitleMenu titleMenu) + { + Texture2D texture = content.Load<Texture2D>(key); + + titleMenu.titleButtonsTexture = texture; + titleMenu.backButton.texture = texture; + titleMenu.aboutButton.texture = texture; + titleMenu.languageButton.texture = texture; + foreach (ClickableTextureComponent button in titleMenu.buttons) + button.texture = titleMenu.titleButtonsTexture; + foreach (TemporaryAnimatedSprite bird in titleMenu.birds) + bird.texture = texture; + + return true; + } + + return false; + } + /// <summary>Reload the sprites for matching pets or horses.</summary> /// <typeparam name="TAnimal">The animal type.</typeparam> /// <param name="content">The content manager through which to reload the asset.</param> diff --git a/src/SMAPI/Metadata/InstructionMetadata.cs b/src/SMAPI/Metadata/InstructionMetadata.cs index 79d7a7a8..09a199f9 100644 --- a/src/SMAPI/Metadata/InstructionMetadata.cs +++ b/src/SMAPI/Metadata/InstructionMetadata.cs @@ -35,8 +35,9 @@ namespace StardewModdingAPI.Metadata if (platformChanged) yield return new MethodParentRewriter(typeof(SpriteBatch), typeof(SpriteBatchFacade)); - // rewrite for Stardew Valley 1.3 - yield return new StaticFieldToConstantRewriter<int>(typeof(Game1), "tileSize", Game1.tileSize); + // heuristic rewrites + yield return new HeuristicFieldRewriter(this.ValidateReferencesToAssemblies); + yield return new HeuristicMethodRewriter(this.ValidateReferencesToAssemblies); #if HARMONY_2 // rewrite for SMAPI 3.6 (Harmony 1.x => 2.0 update) diff --git a/src/SMAPI/PatchMode.cs b/src/SMAPI/PatchMode.cs index b4286a89..34d3007d 100644 --- a/src/SMAPI/PatchMode.cs +++ b/src/SMAPI/PatchMode.cs @@ -1,4 +1,4 @@ -namespace StardewModdingAPI +namespace StardewModdingAPI { /// <summary>Indicates how an image should be patched.</summary> public enum PatchMode diff --git a/src/SMAPI/Patches/DialogueErrorPatch.cs b/src/SMAPI/Patches/DialogueErrorPatch.cs index 8043eda3..42494390 100644 --- a/src/SMAPI/Patches/DialogueErrorPatch.cs +++ b/src/SMAPI/Patches/DialogueErrorPatch.cs @@ -33,7 +33,7 @@ namespace StardewModdingAPI.Patches /********* ** Accessors *********/ - /// <summary>A unique name for this patch.</summary> + /// <inheritdoc /> public string Name => nameof(DialogueErrorPatch); @@ -50,8 +50,7 @@ namespace StardewModdingAPI.Patches } - /// <summary>Apply the Harmony patch.</summary> - /// <param name="harmony">The Harmony instance.</param> + /// <inheritdoc /> #if HARMONY_2 public void Apply(Harmony harmony) { @@ -78,6 +77,7 @@ namespace StardewModdingAPI.Patches } #endif + /********* ** Private methods *********/ diff --git a/src/SMAPI/Patches/EventErrorPatch.cs b/src/SMAPI/Patches/EventErrorPatch.cs index 4dbb25f3..46651387 100644 --- a/src/SMAPI/Patches/EventErrorPatch.cs +++ b/src/SMAPI/Patches/EventErrorPatch.cs @@ -11,7 +11,7 @@ using StardewValley; namespace StardewModdingAPI.Patches { - /// <summary>A Harmony patch for the <see cref="Dialogue"/> constructor which intercepts invalid dialogue lines and logs an error instead of crashing.</summary> + /// <summary>A Harmony patch for <see cref="GameLocation.checkEventPrecondition"/> which intercepts invalid preconditions and logs an error instead of crashing.</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.")] @@ -27,7 +27,7 @@ namespace StardewModdingAPI.Patches /********* ** Accessors *********/ - /// <summary>A unique name for this patch.</summary> + /// <inheritdoc /> public string Name => nameof(EventErrorPatch); @@ -41,8 +41,7 @@ namespace StardewModdingAPI.Patches EventErrorPatch.MonitorForGame = monitorForGame; } - /// <summary>Apply the Harmony patch.</summary> - /// <param name="harmony">The Harmony instance.</param> + /// <inheritdoc /> #if HARMONY_2 public void Apply(Harmony harmony) { @@ -66,7 +65,7 @@ namespace StardewModdingAPI.Patches ** Private methods *********/ #if HARMONY_2 - /// <summary>The method to call instead of the GameLocation.CheckEventPrecondition.</summary> + /// <summary>The method to call instead of GameLocation.checkEventPrecondition.</summary> /// <param name="__result">The return value of the original method.</param> /// <param name="precondition">The precondition to be parsed.</param> /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> @@ -82,7 +81,7 @@ namespace StardewModdingAPI.Patches return null; } #else - /// <summary>The method to call instead of the GameLocation.CheckEventPrecondition.</summary> + /// <summary>The method to call instead of GameLocation.checkEventPrecondition.</summary> /// <param name="__instance">The instance being patched.</param> /// <param name="__result">The return value of the original method.</param> /// <param name="precondition">The precondition to be parsed.</param> diff --git a/src/SMAPI/Patches/LoadContextPatch.cs b/src/SMAPI/Patches/LoadContextPatch.cs index 768ddd6b..ceda061b 100644 --- a/src/SMAPI/Patches/LoadContextPatch.cs +++ b/src/SMAPI/Patches/LoadContextPatch.cs @@ -33,7 +33,7 @@ namespace StardewModdingAPI.Patches /********* ** Accessors *********/ - /// <summary>A unique name for this patch.</summary> + /// <inheritdoc /> public string Name => nameof(LoadContextPatch); @@ -49,8 +49,7 @@ namespace StardewModdingAPI.Patches LoadContextPatch.OnStageChanged = onStageChanged; } - /// <summary>Apply the Harmony patch.</summary> - /// <param name="harmony">The Harmony instance.</param> + /// <inheritdoc /> #if HARMONY_2 public void Apply(Harmony harmony) #else diff --git a/src/SMAPI/Patches/LoadErrorPatch.cs b/src/SMAPI/Patches/LoadErrorPatch.cs index 5e67b169..f5ee5d71 100644 --- a/src/SMAPI/Patches/LoadErrorPatch.cs +++ b/src/SMAPI/Patches/LoadErrorPatch.cs @@ -34,7 +34,7 @@ namespace StardewModdingAPI.Patches /********* ** Accessors *********/ - /// <summary>A unique name for this patch.</summary> + /// <inheritdoc /> public string Name => nameof(LoadErrorPatch); @@ -51,8 +51,7 @@ namespace StardewModdingAPI.Patches } - /// <summary>Apply the Harmony patch.</summary> - /// <param name="harmony">The Harmony instance.</param> + /// <inheritdoc /> #if HARMONY_2 public void Apply(Harmony harmony) #else diff --git a/src/SMAPI/Patches/ObjectErrorPatch.cs b/src/SMAPI/Patches/ObjectErrorPatch.cs index 4edcc64e..64b8e6b6 100644 --- a/src/SMAPI/Patches/ObjectErrorPatch.cs +++ b/src/SMAPI/Patches/ObjectErrorPatch.cs @@ -23,15 +23,14 @@ namespace StardewModdingAPI.Patches /********* ** Accessors *********/ - /// <summary>A unique name for this patch.</summary> + /// <inheritdoc /> public string Name => nameof(ObjectErrorPatch); /********* ** Public methods *********/ - /// <summary>Apply the Harmony patch.</summary> - /// <param name="harmony">The Harmony instance.</param> + /// <inheritdoc /> #if HARMONY_2 public void Apply(Harmony harmony) #else diff --git a/src/SMAPI/Patches/ScheduleErrorPatch.cs b/src/SMAPI/Patches/ScheduleErrorPatch.cs index cc2238b0..17db07a6 100644 --- a/src/SMAPI/Patches/ScheduleErrorPatch.cs +++ b/src/SMAPI/Patches/ScheduleErrorPatch.cs @@ -29,7 +29,7 @@ namespace StardewModdingAPI.Patches /********* ** Accessors *********/ - /// <summary>A unique name for this patch.</summary> + /// <inheritdoc /> public string Name => nameof(ScheduleErrorPatch); @@ -43,8 +43,7 @@ namespace StardewModdingAPI.Patches ScheduleErrorPatch.MonitorForGame = monitorForGame; } - /// <summary>Apply the Harmony patch.</summary> - /// <param name="harmony">The Harmony instance.</param> + /// <inheritdoc /> #if HARMONY_2 public void Apply(Harmony harmony) #else diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs index 6f3c8c55..23ee8453 100644 --- a/src/SMAPI/Program.cs +++ b/src/SMAPI/Program.cs @@ -1,18 +1,10 @@ using System; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Reflection; -using System.Runtime.CompilerServices; using System.Threading; -#if SMAPI_FOR_WINDOWS -#endif 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 { /// <summary>The main entry point for SMAPI, responsible for hooking into and launching the game.</summary> @@ -22,9 +14,7 @@ namespace StardewModdingAPI ** Fields *********/ /// <summary>The absolute path to search for SMAPI's internal DLLs.</summary> - /// <remarks>We can't use <see cref="Constants.ExecutionPath"/> directly, since <see cref="Constants"/> depends on DLLs loaded from this folder.</remarks> - [SuppressMessage("ReSharper", "AssignNullToNotNullAttribute", Justification = "The assembly location is never null in this context.")] - internal static readonly string DllSearchPath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "smapi-internal"); + internal static readonly string DllSearchPath = EarlyConstants.InternalFilesPath; /********* @@ -41,10 +31,9 @@ namespace StardewModdingAPI Program.AssertGameVersion(); Program.Start(args); } - catch (BadImageFormatException ex) when (ex.FileName == "StardewValley" || ex.FileName == "Stardew Valley") // NOTE: don't use StardewModdingAPI.Constants here, assembly resolution isn't hooked up at this point + catch (BadImageFormatException ex) when (ex.FileName == "StardewValley" || ex.FileName == "Stardew Valley") // don't use EarlyConstants.GameAssemblyName, since we want to check both possible names { - string executableName = Program.GetExecutableAssemblyName(); - Console.WriteLine($"SMAPI failed to initialize because your game's {executableName}.exe seems to be invalid.\nThis may be a pirated version which modified the executable in an incompatible way; if so, you can try a different download or buy a legitimate version.\n\nTechnical details:\n{ex}"); + Console.WriteLine($"SMAPI failed to initialize because your game's {ex.FileName}.exe seems to be invalid.\nThis may be a pirated version which modified the executable in an incompatible way; if so, you can try a different download or buy a legitimate version.\n\nTechnical details:\n{ex}"); } catch (Exception ex) { @@ -70,6 +59,7 @@ namespace StardewModdingAPI if (name.Name.Equals(AssemblyName.GetAssemblyName(dll.FullName).Name, StringComparison.OrdinalIgnoreCase)) return Assembly.LoadFrom(dll.FullName); } + return null; } catch (Exception ex) @@ -80,11 +70,10 @@ namespace StardewModdingAPI } /// <summary>Assert that the game is available.</summary> - /// <remarks>This must be checked *before* any references to <see cref="Constants"/>, and this method should not reference <see cref="Constants"/> itself to avoid errors in Mono.</remarks> + /// <remarks>This must be checked *before* any references to <see cref="Constants"/>, and this method should not reference <see cref="Constants"/> itself to avoid errors in Mono or when the game isn't present.</remarks> private static void AssertGamePresent() { - string gameAssemblyName = Program.GetExecutableAssemblyName(); - if (Type.GetType($"StardewValley.Game1, {gameAssemblyName}", throwOnError: false) == null) + if (Type.GetType($"StardewValley.Game1, {EarlyConstants.GameAssemblyName}", throwOnError: false) == null) Program.PrintErrorAndExit("Oops! SMAPI can't find the game. Make sure you're running StardewModdingAPI.exe in your game folder. See the readme.txt file for details."); } @@ -104,14 +93,6 @@ namespace StardewModdingAPI // max version else if (Constants.MaximumGameVersion != null && Constants.GameVersion.IsNewerThan(Constants.MaximumGameVersion)) Program.PrintErrorAndExit($"Oops! You're running Stardew Valley {Constants.GameVersion}, but this version of SMAPI is only compatible up to Stardew Valley {Constants.MaximumGameVersion}. Please check for a newer version of SMAPI: https://smapi.io."); - - } - - /// <summary>Get the game's executable assembly name.</summary> - private static string GetExecutableAssemblyName() - { - Platform platform = EnvironmentUtility.DetectPlatform(); - return platform == Platform.Windows ? "Stardew Valley" : "StardewValley"; } /// <summary>Initialize SMAPI and launch the game.</summary> diff --git a/src/SMAPI/Properties/AssemblyInfo.cs b/src/SMAPI/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..f8f7f4ea --- /dev/null +++ b/src/SMAPI/Properties/AssemblyInfo.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("SMAPI.Tests")] +[assembly: InternalsVisibleTo("ConsoleCommands")] // for performance monitoring commands +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // Moq for unit testing diff --git a/src/SMAPI/SButton.cs b/src/SMAPI/SButton.cs index bc76c91d..cc412946 100644 --- a/src/SMAPI/SButton.cs +++ b/src/SMAPI/SButton.cs @@ -681,7 +681,7 @@ namespace StardewModdingAPI } // not valid - button = default(InputButton); + button = default; return false; } diff --git a/src/SMAPI/SMAPI.config.json b/src/SMAPI/SMAPI.config.json index 0a6d8372..6ba64fe7 100644 --- a/src/SMAPI/SMAPI.config.json +++ b/src/SMAPI/SMAPI.config.json @@ -34,15 +34,6 @@ copy all the settings, or you may cause bugs due to overridden changes in future "DeveloperMode": true, /** - * Whether to enable experimental parallel rewriting when SMAPI is loading mods. This can - * reduce startup time when you have many mods installed, but is experimental and may cause - * intermittent startup errors. - * - * When this is commented out, it'll be true for local debug builds and false otherwise. - */ - //"RewriteInParallel": false, - - /** * Whether to add a section to the 'mod issues' list for mods which directly use potentially * sensitive .NET APIs like file or shell access. Note that many mods do this legitimately as * part of their normal functionality, so these warnings are meaningless without further diff --git a/src/SMAPI/SMAPI.csproj b/src/SMAPI/SMAPI.csproj index a3dbf52f..7d2b8199 100644 --- a/src/SMAPI/SMAPI.csproj +++ b/src/SMAPI/SMAPI.csproj @@ -59,8 +59,11 @@ <ItemGroup> <Content Include="SMAPI.config.json" CopyToOutputDirectory="PreserveNewest" /> <Content Include="..\SMAPI.Web\wwwroot\SMAPI.metadata.json" Link="SMAPI.metadata.json" CopyToOutputDirectory="PreserveNewest" /> + <None Update="i18n\*" CopyToOutputDirectory="PreserveNewest" /> <None Update="steam_appid.txt" CopyToOutputDirectory="PreserveNewest" /> + + <Compile Include="..\SMAPI.Toolkit\Framework\LowLevelEnvironmentUtility.cs" Link="Framework\Utilities\LowLevelEnvironmentUtility.cs" /> </ItemGroup> <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> diff --git a/src/SMAPI/SemanticVersion.cs b/src/SMAPI/SemanticVersion.cs index 4a175efe..ae616419 100644 --- a/src/SMAPI/SemanticVersion.cs +++ b/src/SMAPI/SemanticVersion.cs @@ -16,19 +16,19 @@ namespace StardewModdingAPI /********* ** Accessors *********/ - /// <summary>The major version incremented for major API changes.</summary> + /// <inheritdoc /> public int MajorVersion => this.Version.MajorVersion; - /// <summary>The minor version incremented for backwards-compatible changes.</summary> + /// <inheritdoc /> public int MinorVersion => this.Version.MinorVersion; - /// <summary>The patch version for backwards-compatible bug fixes.</summary> + /// <inheritdoc /> public int PatchVersion => this.Version.PatchVersion; - /// <summary>An optional prerelease tag.</summary> + /// <inheritdoc /> public string PrereleaseTag => this.Version.PrereleaseTag; - /// <summary>Optional build metadata. This is ignored when determining version precedence.</summary> + /// <inheritdoc /> public string BuildMetadata => this.Version.BuildMetadata; @@ -83,83 +83,68 @@ namespace StardewModdingAPI this.Version = version; } - /// <summary>Whether this is a prerelease version.</summary> + /// <inheritdoc /> public bool IsPrerelease() { return this.Version.IsPrerelease(); } - /// <summary>Get an integer indicating whether this version precedes (less than 0), supersedes (more than 0), or is equivalent to (0) the specified version.</summary> - /// <param name="other">The version to compare with this instance.</param> - /// <exception cref="ArgumentNullException">The <paramref name="other"/> value is null.</exception> + /// <inheritdoc /> /// <remarks>The implementation is defined by Semantic Version 2.0 (https://semver.org/).</remarks> public int CompareTo(ISemanticVersion other) { return this.Version.CompareTo(other); } - /// <summary>Get whether this version is older than the specified version.</summary> - /// <param name="other">The version to compare with this instance.</param> + /// <inheritdoc /> public bool IsOlderThan(ISemanticVersion other) { return this.Version.IsOlderThan(other); } - /// <summary>Get whether this version is older than the specified version.</summary> - /// <param name="other">The version to compare with this instance.</param> - /// <exception cref="FormatException">The specified version is not a valid semantic version.</exception> + /// <inheritdoc /> public bool IsOlderThan(string other) { return this.Version.IsOlderThan(other); } - /// <summary>Get whether this version is newer than the specified version.</summary> - /// <param name="other">The version to compare with this instance.</param> + /// <inheritdoc /> public bool IsNewerThan(ISemanticVersion other) { return this.Version.IsNewerThan(other); } - /// <summary>Get whether this version is newer than the specified version.</summary> - /// <param name="other">The version to compare with this instance.</param> - /// <exception cref="FormatException">The specified version is not a valid semantic version.</exception> + /// <inheritdoc /> public bool IsNewerThan(string other) { return this.Version.IsNewerThan(other); } - /// <summary>Get whether this version is between two specified versions (inclusively).</summary> - /// <param name="min">The minimum version.</param> - /// <param name="max">The maximum version.</param> + /// <inheritdoc /> public bool IsBetween(ISemanticVersion min, ISemanticVersion max) { return this.Version.IsBetween(min, max); } - /// <summary>Get whether this version is between two specified versions (inclusively).</summary> - /// <param name="min">The minimum version.</param> - /// <param name="max">The maximum version.</param> - /// <exception cref="FormatException">One of the specified versions is not a valid semantic version.</exception> + /// <inheritdoc /> public bool IsBetween(string min, string max) { return this.Version.IsBetween(min, max); } - /// <summary>Indicates whether the current object is equal to another object of the same type.</summary> - /// <returns>true if the current object is equal to the <paramref name="other" /> parameter; otherwise, false.</returns> - /// <param name="other">An object to compare with this object.</param> + /// <inheritdoc /> public bool Equals(ISemanticVersion other) { return other != null && this.CompareTo(other) == 0; } - /// <summary>Get a string representation of the version.</summary> + /// <inheritdoc cref="ISemanticVersion.ToString" /> public override string ToString() { return this.Version.ToString(); } - /// <summary>Whether the version uses non-standard extensions, like four-part game versions on some platforms.</summary> + /// <inheritdoc /> public bool IsNonStandard() { return this.Version.IsNonStandard(); diff --git a/src/SMAPI/Utilities/PathUtilities.cs b/src/SMAPI/Utilities/PathUtilities.cs new file mode 100644 index 00000000..ea134468 --- /dev/null +++ b/src/SMAPI/Utilities/PathUtilities.cs @@ -0,0 +1,45 @@ +using System.Diagnostics.Contracts; +using ToolkitPathUtilities = StardewModdingAPI.Toolkit.Utilities.PathUtilities; + +namespace StardewModdingAPI.Utilities +{ + /// <summary>Provides utilities for normalizing file paths.</summary> + public static class PathUtilities + { + /********* + ** Public methods + *********/ + /// <summary>Get the segments from a path (e.g. <c>/usr/bin/example</c> => <c>usr</c>, <c>bin</c>, and <c>example</c>).</summary> + /// <param name="path">The path to split.</param> + /// <param name="limit">The number of segments to match. Any additional segments will be merged into the last returned part.</param> + [Pure] + public static string[] GetSegments(string path, int? limit = null) + { + return ToolkitPathUtilities.GetSegments(path, limit); + } + + /// <summary>Normalize path separators in a file path.</summary> + /// <param name="path">The file path to normalize.</param> + [Pure] + public static string NormalizePathSeparators(string path) + { + return ToolkitPathUtilities.NormalizePathSeparators(path); + } + + /// <summary>Get whether a path is relative and doesn't try to climb out of its containing folder (e.g. doesn't contain <c>../</c>).</summary> + /// <param name="path">The path to check.</param> + [Pure] + public static bool IsSafeRelativePath(string path) + { + return ToolkitPathUtilities.IsSafeRelativePath(path); + } + + /// <summary>Get whether a string is a valid 'slug', containing only basic characters that are safe in all contexts (e.g. filenames, URLs, etc).</summary> + /// <param name="str">The string to check.</param> + [Pure] + public static bool IsSlug(string str) + { + return ToolkitPathUtilities.IsSlug(str); + } + } +} diff --git a/src/SMAPI/Utilities/SDate.cs b/src/SMAPI/Utilities/SDate.cs index 03230334..cd075dcc 100644 --- a/src/SMAPI/Utilities/SDate.cs +++ b/src/SMAPI/Utilities/SDate.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using Newtonsoft.Json; using StardewModdingAPI.Framework; using StardewValley; @@ -35,15 +36,18 @@ namespace StardewModdingAPI.Utilities /// <summary>The index of the season (where 0 is spring, 1 is summer, 2 is fall, and 3 is winter).</summary> /// <remarks>This is used in some game calculations (e.g. seasonal game sprites) and methods (e.g. <see cref="Utility.getSeasonNameFromNumber"/>).</remarks> + [JsonIgnore] public int SeasonIndex { get; } /// <summary>The year.</summary> public int Year { get; } /// <summary>The day of week.</summary> + [JsonIgnore] public DayOfWeek DayOfWeek { get; } /// <summary>The number of days since the game began (starting at 1 for the first day of spring in Y1).</summary> + [JsonIgnore] public int DaysSinceStart { get; } @@ -62,6 +66,7 @@ namespace StardewModdingAPI.Utilities /// <param name="season">The season name.</param> /// <param name="year">The year.</param> /// <exception cref="ArgumentException">One of the arguments has an invalid value (like day 35).</exception> + [JsonConstructor] public SDate(int day, string season, int year) : this(day, season, year, allowDayZero: false) { } @@ -264,7 +269,6 @@ namespace StardewModdingAPI.Utilities this.Year = year; this.DayOfWeek = this.GetDayOfWeek(day); this.DaysSinceStart = this.GetDaysSinceStart(day, season, year); - } /// <summary>Get whether a date represents 0 spring Y1, which is the date during the in-game intro.</summary> |