diff --git a/.gitignore b/.gitignore index a89ccd21f..61d09595c 100644 --- a/.gitignore +++ b/.gitignore @@ -32,8 +32,8 @@ _ReSharper*/ appsettings.Development.json # Azure generated files -src/SMAPI.Web/Properties/PublishProfiles/*.pubxml -src/SMAPI.Web/Properties/ServiceDependencies/* - Web Deploy/ +src/SMAPI.Web/Properties/PublishProfiles +src/SMAPI.Web/Properties/ServiceDependencies # macOS .DS_Store diff --git a/build/common.targets b/build/common.targets index aec6e8791..f1b5e59e0 100644 --- a/build/common.targets +++ b/build/common.targets @@ -7,11 +7,10 @@ repo. It imports the other MSBuild files as needed. - 3.18.6 + 4.0.8 SMAPI latest $(AssemblySearchPaths);{GAC} - $(DefineConstants);SMAPI_DEPRECATED true @@ -34,13 +33,10 @@ repo. It imports the other MSBuild files as needed. warning | builds | summary | rationale ┄┄┄┄┄┄┄ | ┄┄┄┄┄┄┄┄┄┄ | ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ | ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ CS0436 | all | local type conflicts with imported type | SMAPI needs to use certain low-level code during very early compatibility checks, before it's safe to load any other DLLs. - CS0612 | deprecated | member is obsolete | internal references to deprecated code when deprecated code is enabled. - CS0618 | deprecated | member is obsolete (with message) | internal references to deprecated code when deprecated code is enabled. CA1416 | all | platform code available on all platforms | Compiler doesn't recognize the #if constants used by SMAPI. CS0809 | all | obsolete overload for non-obsolete member | This is deliberate to signal to mods that certain APIs are only implemented for the game and shouldn't be called by mods. NU1701 | all | NuGet package targets older .NET version | All such packages are carefully tested to make sure they do work. --> - $(NoWarn);CS0612;CS0618 $(NoWarn);CS0436;CA1416;CS0809;NU1701 diff --git a/build/deploy-local-smapi.targets b/build/deploy-local-smapi.targets index e6c018001..d30967fe5 100644 --- a/build/deploy-local-smapi.targets +++ b/build/deploy-local-smapi.targets @@ -55,7 +55,7 @@ This assumes `find-game-folder.targets` has already been imported and validated. - + diff --git a/build/unix/prepare-install-package.sh b/build/unix/prepare-install-package.sh index 3e33c2770..cfd24afd4 100755 --- a/build/unix/prepare-install-package.sh +++ b/build/unix/prepare-install-package.sh @@ -13,7 +13,7 @@ ########## # paths gamePath="/home/pathoschild/Stardew Valley" -bundleModNames=("ConsoleCommands" "ErrorHandler" "SaveBackup") +bundleModNames=("ConsoleCommands" "SaveBackup") # build configuration buildConfig="Release" @@ -69,7 +69,7 @@ for folder in ${folders[@]}; do for modName in ${bundleModNames[@]}; do echo "Compiling $modName for $folder..." echo "-------------------------------------------------" - dotnet publish src/SMAPI.Mods.$modName --configuration $buildConfig -v minimal --runtime "$runtime" -p:OS="$msbuildPlatformName" -p:GamePath="$gamePath" -p:CopyToGameFolder="false" + dotnet publish src/SMAPI.Mods.$modName --configuration $buildConfig -v minimal --runtime "$runtime" -p:OS="$msbuildPlatformName" -p:GamePath="$gamePath" -p:CopyToGameFolder="false" --self-contained false echo "" echo "" done diff --git a/build/unix/set-smapi-version.sh b/build/unix/set-smapi-version.sh index 02b5e615a..3ab48eb5c 100755 --- a/build/unix/set-smapi-version.sh +++ b/build/unix/set-smapi-version.sh @@ -21,6 +21,6 @@ cd "`dirname "$0"`/../.." # apply changes sed "s/.+<\/Version>/$version<\/Version>/" "build/common.targets" --in-place --regexp-extended sed "s/RawApiVersion = \".+?\";/RawApiVersion = \"$version\";/" "src/SMAPI/Constants.cs" --in-place --regexp-extended -for modName in "ConsoleCommands" "ErrorHandler" "SaveBackup"; do +for modName in "ConsoleCommands" "SaveBackup"; do sed "s/\"(Version|MinimumApiVersion)\": \".+?\"/\"\1\": \"$version\"/g" "src/SMAPI.Mods.$modName/manifest.json" --in-place --regexp-extended done diff --git a/build/windows/prepare-install-package.ps1 b/build/windows/prepare-install-package.ps1 index 434d24663..48c013ff0 100644 --- a/build/windows/prepare-install-package.ps1 +++ b/build/windows/prepare-install-package.ps1 @@ -17,10 +17,11 @@ ########## # paths $gamePath = "C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley" -$bundleModNames = "ConsoleCommands", "ErrorHandler", "SaveBackup" +$bundleModNames = "ConsoleCommands", "SaveBackup" # build configuration $buildConfig = "Release" +$framework = "net6.0" $folders = "linux", "macOS", "windows" $runtimes = @{ linux = "linux-x64"; macOS = "osx-x64"; windows = "win-x64" } $msBuildPlatformNames = @{ linux = "Unix"; macOS = "OSX"; windows = "Windows_NT" } @@ -72,20 +73,20 @@ foreach ($folder in $folders) { echo "Compiling SMAPI for $folder..." echo "-------------------------------------------------" - dotnet publish src/SMAPI --configuration $buildConfig -v minimal --runtime "$runtime" -p:OS="$msbuildPlatformName" -p:GamePath="$gamePath" -p:CopyToGameFolder="false" --self-contained true + dotnet publish src/SMAPI --configuration $buildConfig -v minimal --runtime "$runtime" --framework "$framework" -p:OS="$msbuildPlatformName" -p:TargetFrameworks="$framework" -p:GamePath="$gamePath" -p:CopyToGameFolder="false" --self-contained true echo "" echo "" echo "Compiling installer for $folder..." echo "-------------------------------------------------" - dotnet publish src/SMAPI.Installer --configuration $buildConfig -v minimal --runtime "$runtime" -p:OS="$msbuildPlatformName" -p:GamePath="$gamePath" -p:CopyToGameFolder="false" -p:PublishTrimmed=True -p:TrimMode=Link --self-contained true + dotnet publish src/SMAPI.Installer --configuration $buildConfig -v minimal --runtime "$runtime" --framework "$framework" -p:OS="$msbuildPlatformName" -p:TargetFrameworks="$framework" -p:GamePath="$gamePath" -p:CopyToGameFolder="false" -p:PublishTrimmed=True -p:TrimMode=Link --self-contained true echo "" echo "" foreach ($modName in $bundleModNames) { echo "Compiling $modName for $folder..." echo "-------------------------------------------------" - dotnet publish src/SMAPI.Mods.$modName --configuration $buildConfig -v minimal --runtime "$runtime" -p:OS="$msbuildPlatformName" -p:GamePath="$gamePath" -p:CopyToGameFolder="false" + dotnet publish src/SMAPI.Mods.$modName --configuration $buildConfig -v minimal --runtime "$runtime" --framework "$framework" -p:OS="$msbuildPlatformName" -p:TargetFrameworks="$framework" -p:GamePath="$gamePath" -p:CopyToGameFolder="false" --self-contained false echo "" echo "" } diff --git a/build/windows/set-smapi-version.ps1 b/build/windows/set-smapi-version.ps1 index ff6b20964..1e1099757 100644 --- a/build/windows/set-smapi-version.ps1 +++ b/build/windows/set-smapi-version.ps1 @@ -20,6 +20,6 @@ cd "$PSScriptRoot/../.." # apply changes In-Place-Regex -Path "build/common.targets" -Search ".+" -Replace "$version" In-Place-Regex -Path "src/SMAPI/Constants.cs" -Search "RawApiVersion = `".+?`";" -Replace "RawApiVersion = `"$version`";" -ForEach ($modName in "ConsoleCommands","ErrorHandler","SaveBackup") { +ForEach ($modName in "ConsoleCommands","SaveBackup") { In-Place-Regex -Path "src/SMAPI.Mods.$modName/manifest.json" -Search "`"(Version|MinimumApiVersion)`": `".+?`"" -Replace "`"`$1`": `"$version`"" } diff --git a/docs/README.md b/docs/README.md index d3aaae64f..95cb98480 100644 --- a/docs/README.md +++ b/docs/README.md @@ -63,6 +63,7 @@ Chinese | ✓ [fully translated](../src/SMAPI/i18n/zh.json) French | ✓ [fully translated](../src/SMAPI/i18n/fr.json) German | ✓ [fully translated](../src/SMAPI/i18n/de.json) Hungarian | ✓ [fully translated](../src/SMAPI/i18n/hu.json) +Indonesian | ✓ [fully translated](../src/SMAPI/i18n/id.json) Italian | ✓ [fully translated](../src/SMAPI/i18n/it.json) Japanese | ✓ [fully translated](../src/SMAPI/i18n/ja.json) Korean | ✓ [fully translated](../src/SMAPI/i18n/ko.json) diff --git a/docs/release-notes.md b/docs/release-notes.md index 3cc2239db..7fdb5bb92 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,11 +1,136 @@ ← [README](README.md) # Release notes - +Released 19 March 2024 for Stardew Valley 1.6.0 or later. See [release highlights](https://www.patreon.com/posts/100388693). + +* For players: + * Updated for Stardew Valley 1.6. + * Added support for overriding SMAPI configuration per `Mods` folder (thanks to Shockah!). + * Improved performance. + * Improved compatibility rewriting to handle more cases (thanks to SinZ for his contributions!). + * Removed the bundled `ErrorHandler` mod, which is now integrated into Stardew Valley 1.6. + * Removed obsolete console commands: `list_item_types` (no longer needed) and `player_setimmunity` (broke in 1.6 and rarely used). + * Removed support for seamlessly updating from SMAPI 2.11.3 and earlier (released in 2019). + _If needed, you can update to SMAPI 3.18.0 first and then install the latest version._ + +* For mod authors: + * Updated to .NET 6. + * Added [`RenderingStep` and `RenderedStep` events](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Events#Display.RenderingStep), which let you handle a specific step in the game's render cycle. + * Added support for [custom update manifests](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Update_checks#Custom_update_manifest) (thanks to Jamie Taylor!). + * Removed all deprecated APIs. + * SMAPI no longer intercepts output written to the console. Mods which directly access `Console` will be listed under mod warnings. + * Calling `Monitor.VerboseLog` with an interpolated string no longer evaluates the string if verbose mode is disabled (thanks to atravita!). This only applies to mods compiled in SMAPI 4.0.0 or later. + * Fixed redundant `TRACE` logs for a broken mod which references members with the wrong types. + +* For the web UI: + * Updated JSON validator for Content Patcher 2.0.0. + * Fixed uploaded log/JSON file expiry alway shown as renewed. + * Fixed update check for mods with a prerelease version tag not recognized by the ModDrop API. SMAPI now parses the version itself if needed. + +* For SMAPI developers: + * Added `LogTechnicalDetailsForBrokenMods` option in `smapi-internal/config.json`, which adds more technical info to the SMAPI log when a mod is broken. This is mainly useful for creating compatibility rewriters. ## 3.18.6 Released 05 October 2023 for Stardew Valley 1.5.6 or later. diff --git a/docs/technical/smapi.md b/docs/technical/smapi.md index d15911438..b7b6afabb 100644 --- a/docs/technical/smapi.md +++ b/docs/technical/smapi.md @@ -42,6 +42,7 @@ argument | purpose -------- | ------- `--developer-mode`
`--developer-mode-off` | Enable or disable features intended for mod developers. Currently this only makes `TRACE`-level messages appear in the console. `--no-terminal` | SMAPI won't log anything to the console. On Linux/macOS only, this will also prevent the launch script from trying to open a terminal window. (Messages will still be written to the log file.) +`--prefer-terminal-name` | On Linux/macOS only, the terminal with which to open the SMAPI console. For example, `--prefer-terminal-name=xterm` to use xterm regardless of which terminal is the default one. `--use-current-shell` | On Linux/macOS only, the launch script won't try to open a terminal window. All console output will be sent to the shell running the launch script. `--mods-path` | The path to search for mods, if not the standard `Mods` folder. This can be a path relative to the game folder (like `--mods-path "Mods (test)"`) or an absolute path. @@ -55,6 +56,7 @@ environment variable | purpose `SMAPI_DEVELOPER_MODE` | Equivalent to `--developer-mode` and `--developer-mode-off` above. The value must be `true` or `false`. `SMAPI_MODS_PATH` | Equivalent to `--mods-path` above. `SMAPI_NO_TERMINAL` | Equivalent to `--no-terminal` above. +`$SMAPI_PREFER_TERMINAL_NAME` | Equivalent to `--prefer-terminal-name` above. `SMAPI_USE_CURRENT_SHELL` | Equivalent to `--use-current-shell` above. ### Compile flags @@ -64,7 +66,6 @@ SMAPI uses a small number of conditional compilation constants, which you can se flag | purpose ---- | ------- `SMAPI_FOR_WINDOWS` | Whether SMAPI is being compiled for Windows; if not set, the code assumes Linux/macOS. Set automatically in `common.targets`. -`SMAPI_DEPRECATED` | Whether to include deprecated code in the build. ## Compile from source code ### Main project diff --git a/docs/technical/web.md b/docs/technical/web.md index f0d43fb14..fefe15353 100644 --- a/docs/technical/web.md +++ b/docs/technical/web.md @@ -275,7 +275,6 @@ field | summary `brokeIn` | The SMAPI or Stardew Valley version that broke this mod, if any. `betaCompatibilityStatus`
`betaCompatibilitySummary`
`betaBrokeIn` | Equivalent to the preceding fields, but for beta versions of SMAPI or Stardew Valley. - @@ -324,6 +323,15 @@ Example response with `includeExtendedMetadata: true`: ] ``` +### `/mods/metrics` +The `/mods/metrics` endpoint returns a summary of update-check metrics since the server was last +deployed or restarted. + +Example request: +```js +GET https://smapi.io/api/v3.0/mods/metrics +``` + ## Short URLs The SMAPI web services provides a few short URLs for convenience: diff --git a/src/SMAPI.Installer/Framework/InstallerContext.cs b/src/SMAPI.Installer/Framework/InstallerContext.cs index a2c63dd89..44c17d312 100644 --- a/src/SMAPI.Installer/Framework/InstallerContext.cs +++ b/src/SMAPI.Installer/Framework/InstallerContext.cs @@ -48,13 +48,6 @@ public ISemanticVersion GetInstallerVersion() return new SemanticVersion(raw); } - /// Get whether a folder seems to contain the game files. - /// The folder to check. - public bool LooksLikeGameFolder(DirectoryInfo dir) - { - return this.GameScanner.LooksLikeGameFolder(dir); - } - /// Get whether a folder seems to contain the game, and which version it contains if so. /// The folder to check. public GameFolderType GetGameFolderType(DirectoryInfo dir) diff --git a/src/SMAPI.Installer/InteractiveInstaller.cs b/src/SMAPI.Installer/InteractiveInstaller.cs index 22f88f2f6..bfdac4949 100644 --- a/src/SMAPI.Installer/InteractiveInstaller.cs +++ b/src/SMAPI.Installer/InteractiveInstaller.cs @@ -29,8 +29,7 @@ internal class InteractiveInstaller /// The mod IDs which the installer should allow as bundled mods. private readonly string[] BundledModIDs = { "SMAPI.SaveBackup", - "SMAPI.ConsoleCommands", - "SMAPI.ErrorHandler" + "SMAPI.ConsoleCommands" }; /// Get the absolute file or folder paths to remove when uninstalling SMAPI. @@ -41,7 +40,7 @@ private IEnumerable GetUninstallPaths(DirectoryInfo installDir, Director { string GetInstallPath(string path) => Path.Combine(installDir.FullName, path); - // current files + // installed files yield return GetInstallPath("StardewModdingAPI"); // Linux/macOS only yield return GetInstallPath("StardewModdingAPI.deps.json"); yield return GetInstallPath("StardewModdingAPI.dll"); @@ -54,12 +53,12 @@ private IEnumerable GetUninstallPaths(DirectoryInfo installDir, Director yield return GetInstallPath("smapi-internal"); yield return GetInstallPath("steam_appid.txt"); -#if SMAPI_DEPRECATED - // obsolete + // obsolete files yield return GetInstallPath("libgdiplus.dylib"); // before 3.13 (macOS only) yield return GetInstallPath(Path.Combine("Mods", ".cache")); // 1.3-1.4 - yield return GetInstallPath(Path.Combine("Mods", "TrainerMod")); // *–2.0 (renamed to ConsoleCommands) - yield return GetInstallPath("Mono.Cecil.Rocks.dll"); // 1.3–1.8 + yield return GetInstallPath(Path.Combine("Mods", "ErrorHandler")); // before 4.0 (no longer needed) + yield return GetInstallPath(Path.Combine("Mods", "TrainerMod")); // before 2.0 (renamed to ConsoleCommands) + yield return GetInstallPath("Mono.Cecil.Rocks.dll"); // 1.3-1.8 yield return GetInstallPath("StardewModdingAPI-settings.json"); // 1.0-1.4 yield return GetInstallPath("StardewModdingAPI.AssemblyRewriters.dll"); // 1.3-2.5.5 yield return GetInstallPath("0Harmony.dll"); // moved in 2.8 @@ -78,14 +77,8 @@ private IEnumerable GetUninstallPaths(DirectoryInfo installDir, Director yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.xml"); // moved in 2.8 yield return GetInstallPath("StardewModdingAPI-x64.exe"); // before 3.13 - if (modsDir.Exists) - { - foreach (DirectoryInfo modDir in modsDir.EnumerateDirectories()) - yield return Path.Combine(modDir.FullName, ".cache"); // 1.4–1.7 - } -#endif - - yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley", "ErrorLogs"); // remove old log files + // old log files + yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley", "ErrorLogs"); } /// Handles writing text to the console. @@ -516,11 +509,6 @@ public void Run(string[] args) .Replace(@"""UseScheme"": ""AutoDetect""", $@"""UseScheme"": ""{scheme}"""); File.WriteAllText(paths.ApiConfigPath, text); } - -#if SMAPI_DEPRECATED - // remove obsolete appdata mods - this.InteractivelyRemoveAppDataMods(paths.ModsDir, bundledModsDir, allowUserInput); -#endif } } Console.WriteLine(); @@ -707,48 +695,50 @@ private string InteractivelyChoose(string message, string[] options, string inde return null; } - switch (context.GetGameFolderType(dir)) + GameFolderType type = context.GetGameFolderType(dir); + switch (type) { case GameFolderType.Valid: return dir; - case GameFolderType.Legacy154OrEarlier: - this.PrintWarning($"{errorPrefix} that directory seems to have Stardew Valley 1.5.4 or earlier."); - this.PrintWarning("Please update your game to the latest version to use SMAPI."); - return null; - - case GameFolderType.LegacyCompatibilityBranch: - this.PrintWarning($"{errorPrefix} that directory seems to have the Stardew Valley legacy 'compatibility' branch."); - this.PrintWarning("Unfortunately SMAPI is only compatible with the modern version of the game."); - this.PrintWarning("Please update your game to the main branch to use SMAPI."); - return null; - - case GameFolderType.NoGameFound: - this.PrintWarning($"{errorPrefix} that directory doesn't contain a Stardew Valley executable."); - return null; - default: - this.PrintWarning($"{errorPrefix} that directory doesn't seem to contain a valid game install."); + foreach (string message in this.GetInvalidFolderWarning(type)) + this.PrintWarning(message); return null; } } + // get valid install paths & log invalid ones + List defaultPaths = new(); + foreach ((DirectoryInfo dir, GameFolderType type) in this.DetectGameFolders(toolkit, context)) + { + if (type is GameFolderType.Valid) + { + defaultPaths.Add(dir); + continue; + } + + this.PrintDebug($"Ignored game folder: {dir.FullName}"); + foreach (string message in this.GetInvalidFolderWarning(type)) + this.PrintDebug(message); + this.PrintDebug("\n"); + } + // let user choose detected path - DirectoryInfo[] defaultPaths = this.DetectGameFolders(toolkit, context).ToArray(); if (defaultPaths.Any()) { this.PrintInfo("Where do you want to add or remove SMAPI?"); Console.WriteLine(); - for (int i = 0; i < defaultPaths.Length; i++) + for (int i = 0; i < defaultPaths.Count; i++) this.PrintInfo($"[{i + 1}] {defaultPaths[i].FullName}"); - this.PrintInfo($"[{defaultPaths.Length + 1}] Enter a custom game path."); + this.PrintInfo($"[{defaultPaths.Count + 1}] Enter a custom game path."); Console.WriteLine(); - string[] validOptions = Enumerable.Range(1, defaultPaths.Length + 1).Select(p => p.ToString(CultureInfo.InvariantCulture)).ToArray(); + string[] validOptions = Enumerable.Range(1, defaultPaths.Count + 1).Select(p => p.ToString(CultureInfo.InvariantCulture)).ToArray(); string choice = this.InteractivelyChoose("Type the number next to your choice, then press enter.", validOptions); int index = int.Parse(choice, CultureInfo.InvariantCulture) - 1; - if (index < defaultPaths.Length) + if (index < defaultPaths.Count) return defaultPaths[index]; } else @@ -778,9 +768,9 @@ private string InteractivelyChoose(string message, string[] options, string inde } // get directory - if (File.Exists(path)) - path = Path.GetDirectoryName(path)!; DirectoryInfo directory = new(path); + if (!directory.Exists && (path.EndsWith(".dll") || path.EndsWith(".exe") || File.Exists(path)) && directory.Parent is { Exists: true }) + directory = directory.Parent; // validate path if (!directory.Exists) @@ -789,29 +779,16 @@ private string InteractivelyChoose(string message, string[] options, string inde continue; } - switch (context.GetGameFolderType(directory)) + GameFolderType type = context.GetGameFolderType(directory); + switch (type) { case GameFolderType.Valid: this.PrintInfo(" OK!"); return directory; - case GameFolderType.Legacy154OrEarlier: - this.PrintWarning("That directory seems to have Stardew Valley 1.5.4 or earlier."); - this.PrintWarning("Please update your game to the latest version to use SMAPI."); - continue; - - case GameFolderType.LegacyCompatibilityBranch: - this.PrintWarning("That directory seems to have the Stardew Valley legacy 'compatibility' branch."); - this.PrintWarning("Unfortunately SMAPI is only compatible with the modern version of the game."); - this.PrintWarning("Please update your game to the main branch to use SMAPI."); - continue; - - case GameFolderType.NoGameFound: - this.PrintWarning("That directory doesn't contain a Stardew Valley executable."); - continue; - default: - this.PrintWarning("That directory doesn't seem to contain a valid game install."); + foreach (string message in this.GetInvalidFolderWarning(type)) + this.PrintWarning(message); continue; } } @@ -820,7 +797,7 @@ private string InteractivelyChoose(string message, string[] options, string inde /// Get the possible game paths to update. /// The mod toolkit. /// The installer context. - private IEnumerable DetectGameFolders(ModToolkit toolkit, InstallerContext context) + private IEnumerable<(DirectoryInfo, GameFolderType)> DetectGameFolders(ModToolkit toolkit, InstallerContext context) { HashSet foundPaths = new HashSet(); @@ -829,10 +806,10 @@ private IEnumerable DetectGameFolders(ModToolkit toolkit, Install DirectoryInfo? curPath = new FileInfo(Assembly.GetExecutingAssembly().Location).Directory; while (curPath?.Parent != null) // must be in a folder (not at the root) { - if (context.LooksLikeGameFolder(curPath)) + if (context.GetGameFolderType(curPath) == GameFolderType.Valid) { foundPaths.Add(curPath.FullName); - yield return curPath; + yield return (curPath, GameFolderType.Valid); break; } @@ -841,98 +818,42 @@ private IEnumerable DetectGameFolders(ModToolkit toolkit, Install } // game paths detected by toolkit - foreach (DirectoryInfo dir in toolkit.GetGameFolders()) + foreach ((DirectoryInfo, GameFolderType) pair in toolkit.GetGameFoldersIncludingInvalid()) { - if (foundPaths.Add(dir.FullName)) - yield return dir; + if (foundPaths.Add(pair.Item1.FullName)) + yield return pair; } } -#if SMAPI_DEPRECATED - /// Interactively move mods out of the app data directory. - /// The directory which should contain all mods. - /// The installer directory containing packaged mods. - /// Whether the installer can ask for user input from the terminal. - private void InteractivelyRemoveAppDataMods(DirectoryInfo properModsDir, DirectoryInfo packagedModsDir, bool allowUserInput) + private string[] GetInvalidFolderWarning(GameFolderType type) { - // get packaged mods to delete - string[] packagedModNames = packagedModsDir.GetDirectories().Select(p => p.Name).ToArray(); - - // get path - string appDataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley"); - DirectoryInfo modDir = new(Path.Combine(appDataPath, "Mods")); - - // check if migration needed - if (!modDir.Exists) - return; - this.PrintDebug($"Found an obsolete mod path: {modDir.FullName}"); - this.PrintDebug(" Support for mods here was dropped in SMAPI 1.0 (it was never officially supported)."); - - // move mods if no conflicts (else warn) - foreach (FileSystemInfo entry in modDir.EnumerateFileSystemInfos().Where(this.ShouldCopy)) + switch (type) { - // get type - bool isDir = entry is DirectoryInfo; - if (!isDir && entry is not FileInfo) - continue; // should never happen - - // delete packaged mods (newer version bundled into SMAPI) - if (isDir && packagedModNames.Contains(entry.Name, StringComparer.OrdinalIgnoreCase)) - { - this.PrintDebug($" Deleting {entry.Name} because it's bundled into SMAPI..."); - this.InteractivelyDelete(entry.FullName, allowUserInput); - continue; - } + case GameFolderType.Valid: + return new[] { "OK!" }; // should never happen - // check paths - string newPath = Path.Combine(properModsDir.FullName, entry.Name); - if (isDir ? Directory.Exists(newPath) : File.Exists(newPath)) - { - this.PrintWarning($" Can't move {entry.Name} because it already exists in your game's mod directory."); - continue; - } - - // move into mods - this.PrintDebug($" Moving {entry.Name} into the game's mod directory..."); - this.Move(entry, newPath); - } - - // delete if empty - if (modDir.EnumerateFileSystemInfos().Any()) - this.PrintWarning(" You have files in this folder which couldn't be moved automatically. These will be ignored by SMAPI."); - else - { - this.PrintDebug(" Deleted empty directory."); - modDir.Delete(recursive: true); - } - } - - /// Move a filesystem entry to a new parent directory. - /// The filesystem entry to move. - /// The destination path. - /// We can't use or , because those don't work across partitions. - private void Move(FileSystemInfo entry, string newPath) - { - // file - if (entry is FileInfo file) - { - file.CopyTo(newPath); - file.Delete(); - } + case GameFolderType.LegacyVersion: + return new[] + { + "That directory seems to have Stardew Valley 1.5.6 or earlier.", + "Please update your game to the latest version to use SMAPI." + }; - // directory - else - { - Directory.CreateDirectory(newPath); + case GameFolderType.LegacyCompatibilityBranch: + return new[] + { + "That directory seems to have the Stardew Valley legacy 'compatibility' branch.", + "Unfortunately SMAPI is only compatible with the modern version of the game.", + "Please update your game to the main branch to use SMAPI." + }; - DirectoryInfo directory = (DirectoryInfo)entry; - foreach (FileSystemInfo child in directory.EnumerateFileSystemInfos().Where(this.ShouldCopy)) - this.Move(child, Path.Combine(newPath, child.Name)); + case GameFolderType.NoGameFound: + return new[] { "That directory doesn't contain a Stardew Valley executable." }; - directory.Delete(recursive: true); + default: + return new[] { "That directory doesn't seem to contain a valid game install." }; } } -#endif /// Get whether a file or folder should be copied from the installer files. /// The file or folder info. diff --git a/src/SMAPI.Installer/SMAPI.Installer.csproj b/src/SMAPI.Installer/SMAPI.Installer.csproj index 928e5c187..4d4d0214c 100644 --- a/src/SMAPI.Installer/SMAPI.Installer.csproj +++ b/src/SMAPI.Installer/SMAPI.Installer.csproj @@ -2,7 +2,7 @@ StardewModdingAPI.Installer The SMAPI installer for players. - net5.0 + net6.0 Exe false diff --git a/src/SMAPI.Installer/assets/runtimeconfig.json b/src/SMAPI.Installer/assets/runtimeconfig.json index bd6a5240f..8741544fe 100644 --- a/src/SMAPI.Installer/assets/runtimeconfig.json +++ b/src/SMAPI.Installer/assets/runtimeconfig.json @@ -1,10 +1,10 @@ { "runtimeOptions": { - "tfm": "net5.0", + "tfm": "net6.0", "includedFrameworks": [ { "name": "Microsoft.NETCore.App", - "version": "5.0.0", + "version": "6.0.0", "rollForward": "latestMinor" } ], diff --git a/src/SMAPI.Installer/assets/unix-launcher.sh b/src/SMAPI.Installer/assets/unix-launcher.sh index 751e219ea..2e98e9668 100644 --- a/src/SMAPI.Installer/assets/unix-launcher.sh +++ b/src/SMAPI.Installer/assets/unix-launcher.sh @@ -13,6 +13,8 @@ SKIP_TERMINAL=false # Whether to avoid opening a separate terminal, but still send the usual log output to the console. USE_CURRENT_SHELL=false +# Specify terminal name to open and output logs +PREFER_TERMINAL_NAME="" ########## ## Read environment variables @@ -23,6 +25,9 @@ fi if [ "$SMAPI_USE_CURRENT_SHELL" == "true" ]; then USE_CURRENT_SHELL=true fi +if [ "$SMAPI_PREFER_TERMINAL_NAME" != "" ]; then + PREFER_TERMINAL_NAME=$SMAPI_PREFER_TERMINAL_NAME +fi ########## @@ -32,6 +37,7 @@ while [ "$#" -gt 0 ]; do case "$1" in --skip-terminal ) SKIP_TERMINAL=true; shift ;; --use-current-shell ) USE_CURRENT_SHELL=true; shift ;; + --prefer-terminal-name=* ) PREFER_TERMINAL_NAME="${1#*=}"; shift ;; # ${1#*=} removes everything up to the equals sign from $1 -- ) shift; break ;; * ) shift ;; esac @@ -92,13 +98,18 @@ else # run in terminal if [ "$USE_CURRENT_SHELL" == "false" ]; then - # select terminal (prefer xterm for best compatibility, then known supported terminals) - for terminal in xterm gnome-terminal kitty terminator xfce4-terminal konsole terminal termite alacritty mate-terminal x-terminal-emulator wezterm; do - if command -v "$terminal" 2>/dev/null; then - export TERMINAL_NAME=$terminal - break; - fi - done + # if user said preferred terminal + if [ "$PREFER_TERMINAL_NAME" != "" ]; then + export TERMINAL_NAME=$PREFER_TERMINAL_NAME + else + # select terminal (prefer xterm for best compatibility, then known supported terminals) + for terminal in xterm gnome-terminal kitty terminator xfce4-terminal konsole terminal termite alacritty mate-terminal x-terminal-emulator wezterm; do + if command -v "$terminal" 2>/dev/null; then + export TERMINAL_NAME=$terminal + break; + fi + done + fi # find the true shell behind x-terminal-emulator if [ "$TERMINAL_NAME" = "x-terminal-emulator" ]; then diff --git a/src/SMAPI.Internal.Patching/BasePatcher.cs b/src/SMAPI.Internal.Patching/BasePatcher.cs deleted file mode 100644 index c1936ccc1..000000000 --- a/src/SMAPI.Internal.Patching/BasePatcher.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System; -using System.Reflection; -using HarmonyLib; - -namespace StardewModdingAPI.Internal.Patching -{ - /// Provides base implementation logic for instances. - internal abstract class BasePatcher : IPatcher - { - /********* - ** Public methods - *********/ - /// - public abstract void Apply(Harmony harmony, IMonitor monitor); - - - /********* - ** Protected methods - *********/ - /// Get a method and assert that it was found. - /// The type containing the method. - /// The method parameter types, or null if it's not overloaded. - protected ConstructorInfo RequireConstructor(params Type[] parameters) - { - return PatchHelper.RequireConstructor(parameters); - } - - /// Get a method and assert that it was found. - /// The type containing the method. - /// The method name. - /// The method parameter types, or null if it's not overloaded. - /// The method generic types, or null if it's not generic. - protected MethodInfo RequireMethod(string name, Type[]? parameters = null, Type[]? generics = null) - { - return PatchHelper.RequireMethod(name, parameters, generics); - } - - /// Get a Harmony patch method on the current patcher instance. - /// The method name. - /// The patch priority to apply, usually specified using Harmony's enum, or null to keep the default value. - protected HarmonyMethod GetHarmonyMethod(string name, int? priority = null) - { - HarmonyMethod method = new( - AccessTools.Method(this.GetType(), name) - ?? throw new InvalidOperationException($"Can't find patcher method {PatchHelper.GetMethodString(this.GetType(), name)}.") - ); - - if (priority.HasValue) - method.priority = priority.Value; - - return method; - } - } -} diff --git a/src/SMAPI.Internal.Patching/HarmonyPatcher.cs b/src/SMAPI.Internal.Patching/HarmonyPatcher.cs deleted file mode 100644 index 6f30c241a..000000000 --- a/src/SMAPI.Internal.Patching/HarmonyPatcher.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using HarmonyLib; - -namespace StardewModdingAPI.Internal.Patching -{ - /// Simplifies applying instances to the game. - internal static class HarmonyPatcher - { - /********* - ** Public methods - *********/ - /// Apply the given Harmony patchers. - /// The mod ID applying the patchers. - /// The monitor with which to log any errors. - /// The patchers to apply. - public static Harmony Apply(string id, IMonitor monitor, params IPatcher[] patchers) - { - Harmony harmony = new(id); - - foreach (IPatcher patcher in patchers) - { - try - { - patcher.Apply(harmony, monitor); - } - catch (Exception ex) - { - monitor.Log($"Couldn't apply runtime patch '{patcher.GetType().Name}' to the game. Some SMAPI features may not work correctly. See log file for details.", LogLevel.Error); - monitor.Log($"Technical details:\n{ex.GetLogSummary()}"); - } - } - - return harmony; - } - } -} diff --git a/src/SMAPI.Internal.Patching/IPatcher.cs b/src/SMAPI.Internal.Patching/IPatcher.cs deleted file mode 100644 index a732d64ff..000000000 --- a/src/SMAPI.Internal.Patching/IPatcher.cs +++ /dev/null @@ -1,16 +0,0 @@ -using HarmonyLib; - -namespace StardewModdingAPI.Internal.Patching -{ - /// A set of Harmony patches to apply. - internal interface IPatcher - { - /********* - ** Public methods - *********/ - /// Apply the Harmony patches for this instance. - /// The Harmony instance. - /// The monitor with which to log any errors. - public void Apply(Harmony harmony, IMonitor monitor); - } -} diff --git a/src/SMAPI.Internal.Patching/PatchHelper.cs b/src/SMAPI.Internal.Patching/PatchHelper.cs deleted file mode 100644 index edd8ef57f..000000000 --- a/src/SMAPI.Internal.Patching/PatchHelper.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System; -using System.Linq; -using System.Reflection; -using System.Text; -using HarmonyLib; - -namespace StardewModdingAPI.Internal.Patching -{ - /// Provides utility methods for patching game code with Harmony. - internal static class PatchHelper - { - /********* - ** Public methods - *********/ - /// Get a constructor and assert that it was found. - /// The type containing the method. - /// The method parameter types, or null if it's not overloaded. - /// The type has no matching constructor. - public static ConstructorInfo RequireConstructor(Type[]? parameters = null) - { - return - AccessTools.Constructor(typeof(TTarget), parameters) - ?? throw new InvalidOperationException($"Can't find constructor {PatchHelper.GetMethodString(typeof(TTarget), null, parameters)} to patch."); - } - - /// Get a method and assert that it was found. - /// The type containing the method. - /// The method name. - /// The method parameter types, or null if it's not overloaded. - /// The method generic types, or null if it's not generic. - /// The type has no matching method. - public static MethodInfo RequireMethod(string name, Type[]? parameters = null, Type[]? generics = null) - { - return - AccessTools.Method(typeof(TTarget), name, parameters, generics) - ?? throw new InvalidOperationException($"Can't find method {PatchHelper.GetMethodString(typeof(TTarget), name, parameters, generics)} to patch."); - } - - /// Get a human-readable representation of a method target. - /// The type containing the method. - /// The method name, or null for a constructor. - /// The method parameter types, or null if it's not overloaded. - /// The method generic types, or null if it's not generic. - public static string GetMethodString(Type type, string? name, Type[]? parameters = null, Type[]? generics = null) - { - StringBuilder str = new(); - - // type - str.Append(type.FullName); - - // method name (if not constructor) - if (name != null) - { - str.Append('.'); - str.Append(name); - } - - // generics - if (generics?.Any() == true) - { - str.Append('<'); - str.Append(string.Join(", ", generics.Select(p => p.FullName))); - str.Append('>'); - } - - // parameters - if (parameters?.Any() == true) - { - str.Append('('); - str.Append(string.Join(", ", parameters.Select(p => p.FullName))); - str.Append(')'); - } - - return str.ToString(); - } - } -} diff --git a/src/SMAPI.Internal.Patching/SMAPI.Internal.Patching.projitems b/src/SMAPI.Internal.Patching/SMAPI.Internal.Patching.projitems deleted file mode 100644 index 4fa2a0628..000000000 --- a/src/SMAPI.Internal.Patching/SMAPI.Internal.Patching.projitems +++ /dev/null @@ -1,17 +0,0 @@ - - - - $(MSBuildAllProjects);$(MSBuildThisFileFullPath) - true - 6c16e948-3e5c-47a7-bf4b-07a7469a87a5 - - - SMAPI.Internal.Patching - - - - - - - - \ No newline at end of file diff --git a/src/SMAPI.Internal.Patching/SMAPI.Internal.Patching.shproj b/src/SMAPI.Internal.Patching/SMAPI.Internal.Patching.shproj deleted file mode 100644 index 1a102c826..000000000 --- a/src/SMAPI.Internal.Patching/SMAPI.Internal.Patching.shproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - 6c16e948-3e5c-47a7-bf4b-07a7469a87a5 - 14.0 - - - - - - - - diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj b/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj index 86d35e1c0..c63935c25 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj @@ -1,6 +1,6 @@  - net5.0 + net6.0 latest diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ArgumentParser.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ArgumentParser.cs index 66f2f1052..e42aea212 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ArgumentParser.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ArgumentParser.cs @@ -15,9 +15,6 @@ internal class ArgumentParser : IReadOnlyList /// The command name for errors. private readonly string CommandName; - /// The arguments to parse. - private readonly string[] Args; - /// Writes messages to the console and log file. private readonly IMonitor Monitor; @@ -25,12 +22,15 @@ internal class ArgumentParser : IReadOnlyList /********* ** Accessors *********/ + /// The arguments to parse. + public string[] Values { get; } + /// Get the number of arguments. - public int Count => this.Args.Length; + public int Count => this.Values.Length; /// Get the argument at the specified index in the list. /// The zero-based index of the element to get. - public string this[int index] => this.Args[index]; + public string this[int index] => this.Values[index]; /********* @@ -38,12 +38,12 @@ internal class ArgumentParser : IReadOnlyList *********/ /// Construct an instance. /// The command name for errors. - /// The arguments to parse. + /// The arguments to parse. /// Writes messages to the console and log file. - public ArgumentParser(string commandName, string[] args, IMonitor monitor) + public ArgumentParser(string commandName, string[] values, IMonitor monitor) { this.CommandName = commandName; - this.Args = args; + this.Values = values; this.Monitor = monitor; } @@ -58,20 +58,20 @@ public bool TryGet(int index, string name, [NotNullWhen(true)] out string? value value = null; // validate - if (this.Args.Length < index + 1) + if (this.Values.Length < index + 1) { if (required) this.LogError($"Argument {index} ({name}) is required."); return false; } - if (oneOf?.Any() == true && !oneOf.Contains(this.Args[index], StringComparer.OrdinalIgnoreCase)) + if (oneOf?.Any() == true && !oneOf.Contains(this.Values[index], StringComparer.OrdinalIgnoreCase)) { this.LogError($"Argument {index} ({name}) must be one of {string.Join(", ", oneOf)}."); return false; } // get value - value = this.Args[index]; + value = this.Values[index]; return true; } @@ -111,7 +111,7 @@ public bool TryGetInt(int index, string name, out int value, bool required = tru /// An enumerator that can be used to iterate through the collection. public IEnumerator GetEnumerator() { - return ((IEnumerable)this.Args).GetEnumerator(); + return ((IEnumerable)this.Values).GetEnumerator(); } /// Returns an enumerator that iterates through a collection. diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ConsoleCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ConsoleCommand.cs index 44b7824e3..2133817c4 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ConsoleCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ConsoleCommand.cs @@ -16,9 +16,6 @@ internal abstract class ConsoleCommand : IConsoleCommand /// The command description. public string Description { get; } - /// Whether the command may need to perform logic when the player presses a button. This value shouldn't change. - public bool MayNeedInput { get; } - /// Whether the command may need to perform logic when the game updates. This value shouldn't change. public bool MayNeedUpdate { get; } @@ -48,13 +45,11 @@ public virtual void OnButtonPressed(IMonitor monitor, SButton button) { } /// Construct an instance. /// The command name the user must type. /// The command description. - /// Whether the command may need to perform logic when the player presses a button. /// Whether the command may need to perform logic when the game updates. - protected ConsoleCommand(string name, string description, bool mayNeedInput = false, bool mayNeedUpdate = false) + protected ConsoleCommand(string name, string description, bool mayNeedUpdate = false) { this.Name = name; this.Description = description; - this.MayNeedInput = mayNeedInput; this.MayNeedUpdate = mayNeedUpdate; } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/IConsoleCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/IConsoleCommand.cs index 9c82bbd34..05dfcf321 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/IConsoleCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/IConsoleCommand.cs @@ -15,9 +15,6 @@ internal interface IConsoleCommand /// Whether the command may need to perform logic when the game updates. This value shouldn't change. bool MayNeedUpdate { get; } - /// Whether the command may need to perform logic when the player presses a button. This value shouldn't change. - bool MayNeedInput { get; } - /********* ** Public methods @@ -31,10 +28,5 @@ internal interface IConsoleCommand /// Perform any logic needed on update tick. /// Writes messages to the console and log file. void OnUpdated(IMonitor monitor); - - /// Perform any logic when input is received. - /// Writes messages to the console and log file. - /// The button that was pressed. - void OnButtonPressed(IMonitor monitor, SButton button); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ApplySaveFixCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ApplySaveFixCommand.cs index f2194cff3..01303194f 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ApplySaveFixCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ApplySaveFixCommand.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using StardewValley; +using StardewValley.SaveMigrations; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other { @@ -39,7 +40,7 @@ public override void Handle(IMonitor monitor, string command, ArgumentParser arg } // validate fix ID - if (!Enum.TryParse(rawFixId, ignoreCase: true, out SaveGame.SaveFixes fixId)) + if (!Enum.TryParse(rawFixId, ignoreCase: true, out SaveFixes fixId)) { monitor.Log($"Invalid save ID '{rawFixId}'. Type 'help apply_save_fix' for details.", LogLevel.Error); return; @@ -50,7 +51,7 @@ public override void Handle(IMonitor monitor, string command, ArgumentParser arg monitor.Log($"Trying to apply save fix ID: '{fixId}'.", LogLevel.Warn); try { - Game1.applySaveFix(fixId); + SaveMigrator.ApplySingleSaveFix(fixId, this.GetLoadedItems()); monitor.Log("Save fix applied.", LogLevel.Info); } catch (Exception ex) @@ -64,12 +65,24 @@ public override void Handle(IMonitor monitor, string command, ArgumentParser arg /********* ** Private methods *********/ + /// Get all item instances in the world. + private List GetLoadedItems() + { + List loadedItems = new(); + Utility.ForEachItem(item => + { + loadedItems.Add(item); + return true; + }); + return loadedItems; + } + /// Get the valid save fix IDs. private IEnumerable GetSaveIds() { - foreach (SaveGame.SaveFixes id in Enum.GetValues(typeof(SaveGame.SaveFixes))) + foreach (SaveFixes id in Enum.GetValues(typeof(SaveFixes))) { - if (id == SaveGame.SaveFixes.MAX) + if (id == SaveFixes.MAX) continue; yield return id.ToString(); diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/DebugCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/DebugCommand.cs index cf1dcbcec..2099b0284 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/DebugCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/DebugCommand.cs @@ -20,15 +20,13 @@ public DebugCommand() /// The command arguments. public override void Handle(IMonitor monitor, string command, ArgumentParser args) { - // submit command - string debugCommand = string.Join(" ", args); string oldOutput = Game1.debugOutput; - Game1.game1.parseDebugInput(debugCommand); - - // show result - monitor.Log(Game1.debugOutput != oldOutput - ? $"> {Game1.debugOutput}" - : "Sent debug command to the game, but there was no output.", LogLevel.Info); + if (DebugCommands.TryHandle(args.Values)) // if it returns false, the game will log an error itself + { + monitor.Log(Game1.debugOutput != oldOutput + ? $"> {Game1.debugOutput}" + : "Sent debug command to the game, but there was no output.", LogLevel.Info); + } } } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/LogContextCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/LogContextCommand.cs new file mode 100644 index 000000000..270faa9d1 --- /dev/null +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/LogContextCommand.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework; + +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other +{ + /// A command which logs contextual info like keys pressed or menus changed until it's disabled. + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] + internal class LogContextCommand : ConsoleCommand + { + /********* + ** Public methods + *********/ + /// Construct an instance. + public LogContextCommand() + : base("log_context", "Prints contextual info like keys pressed or menus changed until it's disabled.", mayNeedUpdate: true) { } + + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + Monitor.ForceLogContext = true; + + monitor.Log( + Monitor.ForceLogContext ? "OK, logging contextual info until you run this command again." : "OK, no longer logging contextual info.", + LogLevel.Info + ); + } + } +} diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/RegenerateBundles.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/RegenerateBundles.cs index 159d7c4af..e239ed3cf 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/RegenerateBundles.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/RegenerateBundles.cs @@ -63,7 +63,7 @@ public override void Handle(IMonitor monitor, string command, ArgumentParser arg } // get private fields - IWorldState state = Game1.netWorldState.Value; + NetWorldState state = Game1.netWorldState.Value; var bundleData = state.GetType().GetField("_bundleData", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance)?.GetValue(state) as IDictionary ?? throw new InvalidOperationException("Can't access '_bundleData' field on world state."); var netBundleData = state.GetType().GetField("netBundleData", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance)?.GetValue(state) as NetStringDictionary diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/TestInputCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/TestInputCommand.cs deleted file mode 100644 index 67325c7c6..000000000 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/TestInputCommand.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other -{ - /// A command which logs the keys being pressed for 30 seconds once enabled. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class TestInputCommand : ConsoleCommand - { - /********* - ** Fields - *********/ - /// Whether the command should print input. - private bool Enabled; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - public TestInputCommand() - : base("test_input", "Prints all input to the console for 30 seconds.", mayNeedUpdate: true, mayNeedInput: true) { } - - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) - { - this.Enabled = !this.Enabled; - - monitor.Log( - this.Enabled ? "OK, logging all player input until you run this command again." : "OK, no longer logging player input.", - LogLevel.Info - ); - } - - /// Perform any logic when input is received. - /// Writes messages to the console and log file. - /// The button that was pressed. - public override void OnButtonPressed(IMonitor monitor, SButton button) - { - if (this.Enabled) - monitor.Log($"Pressed {button}", LogLevel.Info); - } - } -} diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs index 74d3d9df1..ea5ef75d4 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs @@ -1,6 +1,5 @@ using System; using System.Linq; -using StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData; using StardewValley; using Object = StardewValley.Object; @@ -15,9 +14,6 @@ internal class AddCommand : ConsoleCommand /// Provides methods for searching and constructing items. private readonly ItemRepository Items = new(); - /// The type names recognized by this command. - private readonly string[] ValidTypes = Enum.GetNames(typeof(ItemType)).Concat(new[] { "Name" }).ToArray(); - /********* ** Public methods @@ -40,69 +36,115 @@ public override void Handle(IMonitor monitor, string command, ArgumentParser arg } // read arguments - if (!args.TryGet(0, "item type", out string? type, oneOf: this.ValidTypes)) + if (!this.TryReadArguments(args, out string? id, out string? name, out int? count, out int? quality)) return; - if (!args.TryGetInt(2, "count", out int count, min: 1, required: false)) - count = 1; - if (!args.TryGetInt(3, "quality", out int quality, min: Object.lowQuality, max: Object.bestQuality, required: false)) - quality = Object.lowQuality; // find matching item - SearchableItem? match = Enum.TryParse(type, true, out ItemType itemType) - ? this.FindItemByID(monitor, args, itemType) - : this.FindItemByName(monitor, args); + SearchableItem? match = id != null + ? this.FindItemByID(monitor, id) + : this.FindItemByName(monitor, name); if (match == null) return; // apply count - match.Item.Stack = count; + match.Item.Stack = count ?? 1; // apply quality - if (match.Item is Object obj) - obj.Quality = quality; - else if (match.Item is Tool tool) - tool.UpgradeLevel = quality; + if (quality != null) + { + if (match.Item is Object obj) + obj.Quality = quality.Value; + else if (match.Item is Tool tool && args.Count >= 3) + tool.UpgradeLevel = quality.Value; + } // add to inventory Game1.player.addItemByMenuIfNecessary(match.Item); - monitor.Log($"OK, added {match.Name} ({match.Type} #{match.ID}) to your inventory.", LogLevel.Info); + monitor.Log($"OK, added {match.Name} (ID: {match.QualifiedItemId}) to your inventory.", LogLevel.Info); } /********* ** Private methods *********/ + /// Parse the arguments from the user if they're valid. + /// The arguments to parse. + /// The ID of the item to add, or null if searching by . + /// The name of the item to add, or null if searching by . + /// The number of the item to add. + /// The item quality to set. + /// Returns whether the arguments are valid. + private bool TryReadArguments(ArgumentParser args, out string? id, out string? name, out int? count, out int? quality) + { + // get id or 'name' flag + if (!args.TryGet(0, "id or 'name'", out id, required: true)) + { + name = null; + count = null; + quality = null; + return false; + } + + // get name + int argOffset = 0; + if (string.Equals(id, "name", StringComparison.OrdinalIgnoreCase)) + { + id = null; + if (!args.TryGet(1, "item name", out name)) + { + count = null; + quality = null; + return false; + } + + argOffset = 1; + } + else + name = null; + + // get count + count = null; + if (args.TryGetInt(1 + argOffset, "count", out int rawCount, min: 1, required: false)) + count = rawCount; + + // get quality + quality = null; + if (args.TryGetInt(2 + argOffset, "quality", out int rawQuality, min: Object.lowQuality, max: Object.bestQuality, required: false)) + quality = rawQuality; + + return true; + } + + /// Get a matching item by its ID. /// Writes messages to the console and log file. - /// The command arguments. - /// The item type. - private SearchableItem? FindItemByID(IMonitor monitor, ArgumentParser args, ItemType type) + /// The qualified item ID. + private SearchableItem? FindItemByID(IMonitor monitor, string id) { - // read arguments - if (!args.TryGetInt(1, "item ID", out int id, min: 0)) - return null; + SearchableItem? item = this.Items + .GetAll() + .Where(p => string.Equals(p.QualifiedItemId, id, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(p => p.QualifiedItemId == id) // prefer case-sensitive match + .FirstOrDefault(); - // find matching item - SearchableItem? item = this.Items.GetAll().FirstOrDefault(p => p.Type == type && p.ID == id); if (item == null) - monitor.Log($"There's no {type} item with ID {id}.", LogLevel.Error); + monitor.Log($"There's no item with the qualified ID {id}.", LogLevel.Error); + return item; } /// Get a matching item by its name. /// Writes messages to the console and log file. - /// The command arguments. - private SearchableItem? FindItemByName(IMonitor monitor, ArgumentParser args) + /// The partial item name to match. + private SearchableItem? FindItemByName(IMonitor monitor, string? name) { - // read arguments - if (!args.TryGet(1, "item name", out string? name)) + if (string.IsNullOrWhiteSpace(name)) return null; - // find matching items SearchableItem[] matches = this.Items.GetAll().Where(p => p.NameContains(name)).ToArray(); if (!matches.Any()) { - monitor.Log($"There's no item with name '{name}'. You can use the 'list_items [name]' command to search for items.", LogLevel.Error); + monitor.Log($"There's no item whose name contains '{name}'. You can use 'list_items' command to list all items, or search like 'list_items {name}'.", LogLevel.Error); return null; } @@ -115,26 +157,24 @@ public override void Handle(IMonitor monitor, string command, ArgumentParser arg string options = this.GetTableString( data: matches, header: new[] { "type", "name", "command" }, - getRow: item => new[] { item.Type.ToString(), item.DisplayName, $"player_add {item.Type} {item.ID}" } + getRow: item => new[] { item.Type.ToString(), item.DisplayName, $"player_add {item.QualifiedItemId}" } ); - monitor.Log($"There's no item with name '{name}'. Do you mean one of these?\n\n{options}", LogLevel.Info); + monitor.Log($"Multiple items have a name containing '{name}'. Do you mean one of these?\n\n{options}", LogLevel.Info); return null; } /// Get the command description. private static string GetDescription() { - string[] typeValues = Enum.GetNames(typeof(ItemType)); return "Gives the player an item.\n" + "\n" - + "Usage: player_add [count] [quality]\n" - + $"- type: the item type (one of {string.Join(", ", typeValues)}).\n" - + "- item: the item ID (use the 'list_items' command to see a list).\n" + + "Usage: player_add [count] [quality]\n" + + "- item id: the item ID (use the 'list_items' command to see a list).\n" + "- count (optional): how many of the item to give.\n" + $"- quality (optional): one of {Object.lowQuality} (normal), {Object.medQuality} (silver), {Object.highQuality} (gold), or {Object.bestQuality} (iridium).\n" + "\n" - + "Usage: player_add name \"\" [count] [quality]\n" - + "- name: the item name to search (use the 'list_items' command to see a list). This will add the item immediately if it's an exact match, else show a table of matching items.\n" + + "Usage: player_add name \"\" [count] [quality]\n" + + "- item name: the item name to search (use the 'list_items' command to see a list). This will add the item immediately if it's an exact match, else show a table of matching items.\n" + "- count (optional): how many of the item to give.\n" + $"- quality (optional): one of {Object.lowQuality} (normal), {Object.medQuality} (silver), {Object.highQuality} (gold), or {Object.bestQuality} (iridium).\n" + "\n" diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemTypesCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemTypesCommand.cs deleted file mode 100644 index ef35ad195..000000000 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemTypesCommand.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData; - -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player -{ - /// A command which list item types. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class ListItemTypesCommand : ConsoleCommand - { - /********* - ** Fields - *********/ - /// Provides methods for searching and constructing items. - private readonly ItemRepository Items = new(); - - - /********* - ** Public methods - *********/ - /// Construct an instance. - public ListItemTypesCommand() - : base("list_item_types", "Lists item types you can filter in other commands.\n\nUsage: list_item_types") { } - - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) - { - // validate - if (!Context.IsWorldReady) - { - monitor.Log("You need to load a save to use this command.", LogLevel.Error); - return; - } - - // handle - ItemType[] matches = - ( - from item in this.Items.GetAll() - orderby item.Type.ToString() - select item.Type - ) - .Distinct() - .ToArray(); - string summary = "Searching...\n"; - if (matches.Any()) - monitor.Log(summary + this.GetTableString(matches, new[] { "type" }, val => new[] { val.ToString() }), LogLevel.Info); - else - monitor.Log(summary + "No item types found.", LogLevel.Info); - } - } -} diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs index 73d5b79dd..1f949f303 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; -using StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { @@ -47,7 +46,7 @@ select item .ToArray(); string summary = "Searching...\n"; if (matches.Any()) - monitor.Log(summary + this.GetTableString(matches, new[] { "type", "name", "id" }, val => new[] { val.Type.ToString(), val.Name, val.ID.ToString() }), LogLevel.Info); + monitor.Log(summary + this.GetTableString(matches, new[] { "name", "id" }, val => new[] { val.Name, val.QualifiedItemId }), LogLevel.Info); else monitor.Log(summary + "No items found", LogLevel.Info); } @@ -67,7 +66,7 @@ private IEnumerable GetItems(string[] searchWords) // find matches return ( from item in this.Items.GetAll() - let term = $"{item.ID}|{item.Type}|{item.Name}|{item.DisplayName}" + let term = $"{item.QualifiedItemId}|{item.Type}|{item.Name}|{item.DisplayName}" where getAll || searchWords.All(word => term.IndexOf(word, StringComparison.CurrentCultureIgnoreCase) != -1) select item ); diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs index ea9f1d829..d267ae21b 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs @@ -48,7 +48,8 @@ public override void Handle(IMonitor monitor, string command, ArgumentParser arg break; case "pants": - Game1.player.pantsColor.Value = color; + Game1.player.changePantsColor(color); + Game1.player.UpdateClothing(); monitor.Log("OK, your pants color is updated.", LogLevel.Info); break; } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetFarmTypeCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetFarmTypeCommand.cs index b2035d429..e270c2ddf 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetFarmTypeCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetFarmTypeCommand.cs @@ -73,8 +73,8 @@ private void HandleList(IMonitor monitor) if (customTypes.Any()) { result.AppendLine("Or one of these custom farm types:"); - foreach (var type in customTypes.Values.OrderBy(p => p.ID)) - result.AppendLine($" - {type.ID} ({this.GetCustomName(type)})"); + foreach (var type in customTypes.Values.OrderBy(p => p.Id)) + result.AppendLine($" - {type.Id} ({this.GetCustomName(type)})"); } else result.AppendLine("Or a custom farm type (though none is loaded currently)."); @@ -104,7 +104,7 @@ private void HandleVanillaFarmType(int type, IMonitor monitor) /// Writes messages to the console and log file. private void HandleCustomFarmType(string id, IMonitor monitor) { - if (Game1.whichModFarm?.ID == id) + if (Game1.whichModFarm?.Id == id) { monitor.Log($"Your current farm is already set to {id} ({this.GetCustomName(Game1.whichModFarm)}).", LogLevel.Info); return; @@ -200,9 +200,9 @@ private IDictionary GetVanillaFarmTypes() private string? GetCustomName(ModFarmType? farmType) { if (string.IsNullOrWhiteSpace(farmType?.TooltipStringPath)) - return farmType?.ID; + return farmType?.Id; - return Game1.content.LoadString(farmType.TooltipStringPath)?.Split('_')[0] ?? farmType.ID; + return Game1.content.LoadString(farmType.TooltipStringPath)?.Split('_')[0] ?? farmType.Id; } /// Get the available custom farm types by ID. @@ -210,12 +210,12 @@ private IDictionary GetCustomFarmTypes() { IDictionary farmTypes = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (ModFarmType farmType in Game1.content.Load>("Data\\AdditionalFarms")) + foreach (ModFarmType farmType in DataLoader.AdditionalFarms(Game1.content)) { - if (string.IsNullOrWhiteSpace(farmType.ID)) + if (string.IsNullOrWhiteSpace(farmType.Id)) continue; - farmTypes[farmType.ID] = farmType; + farmTypes[farmType.Id] = farmType; } return farmTypes; diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetImmunityCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetImmunityCommand.cs deleted file mode 100644 index 1065bd219..000000000 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetImmunityCommand.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using StardewValley; - -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player -{ - /// A command which edits the player's current immunity. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class SetImmunityCommand : ConsoleCommand - { - /********* - ** Public methods - *********/ - /// Construct an instance. - public SetImmunityCommand() - : base("player_setimmunity", "Sets the player's immunity.\n\nUsage: player_setimmunity [value]\n- value: an integer amount.") { } - - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) - { - // validate - if (!args.Any()) - { - monitor.Log($"You currently have {Game1.player.immunity} immunity. Specify a value to change it.", LogLevel.Info); - return; - } - - // handle - if (args.TryGetInt(0, "amount", out int amount, min: 0)) - { - Game1.player.immunity = amount; - monitor.Log($"OK, you now have {Game1.player.immunity} immunity.", LogLevel.Info); - } - } - } -} diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxStaminaCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxStaminaCommand.cs index 8c794e757..a17d039fd 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxStaminaCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxStaminaCommand.cs @@ -24,15 +24,15 @@ public override void Handle(IMonitor monitor, string command, ArgumentParser arg // validate if (!args.Any()) { - monitor.Log($"You currently have {Game1.player.MaxStamina} max stamina. Specify a value to change it.", LogLevel.Info); + monitor.Log($"You currently have {Game1.player.maxStamina} base max stamina. Specify a value to change it.", LogLevel.Info); return; } // handle if (args.TryGetInt(0, "amount", out int amount, min: 1)) { - Game1.player.MaxStamina = amount; - monitor.Log($"OK, you now have {Game1.player.MaxStamina} max stamina.", LogLevel.Info); + Game1.player.maxStamina.Value = amount; + monitor.Log($"OK, you now have {Game1.player.maxStamina.Value} base max stamina.", LogLevel.Info); } } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs index 8193ff27c..473faad86 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs @@ -12,7 +12,7 @@ internal class SetStyleCommand : ConsoleCommand *********/ /// Construct an instance. public SetStyleCommand() - : base("player_changestyle", "Sets the style of a player feature.\n\nUsage: player_changestyle .\n- target: what to change (one of 'hair', 'shirt', 'skin', 'acc', 'shoe', 'swim', or 'gender').\n- value: the integer style ID.") { } + : base("player_changestyle", "Sets the style of a player feature.\n\nUsage: player_changestyle .\n- target: what to change (one of 'hair', 'shirt', 'skin', 'acc', 'shoe', 'swim', or 'gender').\n- value: the style ID.") { } /// Handle the command. /// Writes messages to the console and log file. @@ -23,15 +23,27 @@ public override void Handle(IMonitor monitor, string command, ArgumentParser arg // parse arguments if (!args.TryGet(0, "target", out string? target, oneOf: new[] { "hair", "shirt", "acc", "skin", "shoe", "swim", "gender" })) return; - if (!args.TryGetInt(1, "style ID", out int styleID)) + if (!args.TryGet(1, "style ID", out string? styleID)) return; + bool AssertIntStyle(out int id) + { + if (int.TryParse(styleID, out id)) + return true; + + monitor.Log($"The style ID must be a numeric integer for the '{target}' target.", LogLevel.Error); + return false; + } + // handle switch (target) { case "hair": - Game1.player.changeHairStyle(styleID); - monitor.Log("OK, your hair style is updated.", LogLevel.Info); + if (AssertIntStyle(out int hairId)) + { + Game1.player.changeHairStyle(hairId); + monitor.Log("OK, your hair style is updated.", LogLevel.Info); + } break; case "shirt": @@ -40,13 +52,19 @@ public override void Handle(IMonitor monitor, string command, ArgumentParser arg break; case "acc": - Game1.player.changeAccessory(styleID); - monitor.Log("OK, your accessory style is updated.", LogLevel.Info); + if (AssertIntStyle(out int accId)) + { + Game1.player.changeAccessory(accId); + monitor.Log("OK, your accessory style is updated.", LogLevel.Info); + } break; case "skin": - Game1.player.changeSkinColor(styleID); - monitor.Log("OK, your skin color is updated.", LogLevel.Info); + if (AssertIntStyle(out int skinId)) + { + Game1.player.changeSkinColor(skinId); + monitor.Log("OK, your skin color is updated.", LogLevel.Info); + } break; case "shoe": @@ -55,36 +73,46 @@ public override void Handle(IMonitor monitor, string command, ArgumentParser arg break; case "swim": - switch (styleID) + if (AssertIntStyle(out int swimId)) { - case 0: - Game1.player.changeOutOfSwimSuit(); - monitor.Log("OK, you're no longer in your swimming suit.", LogLevel.Info); - break; - case 1: - Game1.player.changeIntoSwimsuit(); - monitor.Log("OK, you're now in your swimming suit.", LogLevel.Info); - break; - default: - this.LogUsageError(monitor, "The swim value should be 0 (no swimming suit) or 1 (swimming suit)."); - break; + switch (swimId) + { + case 0: + Game1.player.changeOutOfSwimSuit(); + monitor.Log("OK, you're no longer in your swimming suit.", LogLevel.Info); + break; + + case 1: + Game1.player.changeIntoSwimsuit(); + monitor.Log("OK, you're now in your swimming suit.", LogLevel.Info); + break; + + default: + this.LogUsageError(monitor, "The swim value should be 0 (no swimming suit) or 1 (swimming suit)."); + break; + } } break; case "gender": - switch (styleID) + if (AssertIntStyle(out int genderId)) { - case 0: - Game1.player.changeGender(true); - monitor.Log("OK, you're now male.", LogLevel.Info); - break; - case 1: - Game1.player.changeGender(false); - monitor.Log("OK, you're now female.", LogLevel.Info); - break; - default: - this.LogUsageError(monitor, "The gender value should be 0 (male) or 1 (female)."); - break; + switch (genderId) + { + case 0: + Game1.player.changeGender(true); + monitor.Log("OK, you're now male.", LogLevel.Info); + break; + + case 1: + Game1.player.changeGender(false); + monitor.Log("OK, you're now female.", LogLevel.Info); + break; + + default: + this.LogUsageError(monitor, "The gender value should be 0 (male) or 1 (female)."); + break; + } } break; } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs index 4905b89ad..9a1f114b0 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs @@ -3,7 +3,6 @@ using System.Linq; using Microsoft.Xna.Framework; using StardewValley; -using StardewValley.Locations; using StardewValley.Objects; using StardewValley.TerrainFeatures; using SObject = StardewValley.Object; @@ -143,7 +142,7 @@ obj.Name is "Weeds" or "Stone" this.RemoveFurniture(location, _ => true) + this.RemoveObjects(location, _ => true) + this.RemoveTerrainFeatures(location, _ => true) - + this.RemoveLargeTerrainFeatures(location, p => everything || p is not Bush bush || bush.isDestroyable(location, p.currentTileLocation)) + + this.RemoveLargeTerrainFeatures(location, p => everything || p is not Bush bush || bush.isDestroyable()) + this.RemoveResourceClumps(location, _ => true); monitor.Log($"Done! Removed {removed} entities from {location.Name}.", LogLevel.Info); break; @@ -233,15 +232,6 @@ private int RemoveResourceClumps(GameLocation location, FuncAn item type that can be searched and added to the player through the console. - internal enum ItemType - { - /// A big craftable object in - BigCraftable, - - /// A item. - Boots, - - /// A item. - Clothing, - - /// A flooring item. - Flooring, - - /// A item. - Furniture, - - /// A item. - Hat, - - /// Any object in (except rings). - Object, - - /// A item. - Ring, - - /// A tool. - Tool, - - /// A wall item. - Wallpaper, - - /// A or item. - Weapon - } -} diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs index 52bf149ec..f1d9b591c 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs @@ -2,14 +2,11 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; -using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Content; -using StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData; using StardewValley; -using StardewValley.GameData.FishPond; -using StardewValley.Menus; +using StardewValley.GameData.FishPonds; +using StardewValley.ItemTypeDefinitions; using StardewValley.Objects; -using StardewValley.Tools; using SObject = StardewValley.Object; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework @@ -17,21 +14,14 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework /// Provides methods for searching and constructing items. internal class ItemRepository { - /********* - ** Fields - *********/ - /// The custom ID offset for items don't have a unique ID in the game. - private readonly int CustomIDOffset = 1000; - - /********* ** Public methods *********/ /// Get all spawnable items. - /// The item types to fetch (or null for any type). + /// Only include items for the given . /// Whether to include flavored variants like "Sunflower Honey". [SuppressMessage("ReSharper", "AccessToModifiedClosure", Justification = $"{nameof(ItemRepository.TryCreate)} invokes the lambda immediately.")] - public IEnumerable GetAll(ItemType[]? itemTypes = null, bool includeVariants = true) + public IEnumerable GetAll(string? onlyType = null, bool includeVariants = true) { // // @@ -45,161 +35,79 @@ public IEnumerable GetAll(ItemType[]? itemTypes = null, bool inc IEnumerable GetAllRaw() { - HashSet? types = itemTypes?.Any() == true ? new HashSet(itemTypes) : null; - bool ShouldGet(ItemType type) => types == null || types.Contains(type); - - // get tools - if (ShouldGet(ItemType.Tool)) + // get from item data definitions + foreach (IItemDataDefinition itemType in ItemRegistry.ItemTypes) { - for (int q = Tool.stone; q <= Tool.iridium; q++) + if (onlyType != null && itemType.Identifier != onlyType) + continue; + + switch (itemType.Identifier) { - int quality = q; - - yield return this.TryCreate(ItemType.Tool, ToolFactory.axe, _ => ToolFactory.getToolFromDescription(ToolFactory.axe, quality)); - yield return this.TryCreate(ItemType.Tool, ToolFactory.hoe, _ => ToolFactory.getToolFromDescription(ToolFactory.hoe, quality)); - yield return this.TryCreate(ItemType.Tool, ToolFactory.pickAxe, _ => ToolFactory.getToolFromDescription(ToolFactory.pickAxe, quality)); - yield return this.TryCreate(ItemType.Tool, ToolFactory.wateringCan, _ => ToolFactory.getToolFromDescription(ToolFactory.wateringCan, quality)); - if (quality != Tool.iridium) - yield return this.TryCreate(ItemType.Tool, ToolFactory.fishingRod, _ => ToolFactory.getToolFromDescription(ToolFactory.fishingRod, quality)); - } - yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset, _ => new MilkPail()); // these don't have any sort of ID, so we'll just assign some arbitrary ones - yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 1, _ => new Shears()); - yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 2, _ => new Pan()); - yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 3, _ => new Wand()); - } + // objects + case "(O)": + { + ObjectDataDefinition objectDataDefinition = (ObjectDataDefinition)ItemRegistry.GetTypeDefinition(ItemRegistry.type_object); - // clothing - if (ShouldGet(ItemType.Clothing)) - { - foreach (int id in this.GetShirtIds()) - yield return this.TryCreate(ItemType.Clothing, id, p => new Clothing(p.ID)); - } + foreach (string id in itemType.GetAllIds()) + { + // base item + SearchableItem? result = this.TryCreate(itemType.Identifier, id, p => ItemRegistry.Create(itemType.Identifier + p.Id)); - // wallpapers - if (ShouldGet(ItemType.Wallpaper)) - { - for (int id = 0; id < 112; id++) - yield return this.TryCreate(ItemType.Wallpaper, id, p => new Wallpaper(p.ID) { Category = SObject.furnitureCategory }); - } + // ring + if (result?.Item is Ring) + yield return result; - // flooring - if (ShouldGet(ItemType.Flooring)) - { - for (int id = 0; id < 56; id++) - yield return this.TryCreate(ItemType.Flooring, id, p => new Wallpaper(p.ID, isFloor: true) { Category = SObject.furnitureCategory }); - } + // journal scraps + else if (result?.QualifiedItemId == "(O)842") + { + foreach (SearchableItem? journalScrap in this.GetSecretNotes(itemType, isJournalScrap: true)) + yield return journalScrap; + } - // equipment - if (ShouldGet(ItemType.Boots)) - { - foreach (int id in this.TryLoad("Data\\Boots").Keys) - yield return this.TryCreate(ItemType.Boots, id, p => new Boots(p.ID)); - } - if (ShouldGet(ItemType.Hat)) - { - foreach (int id in this.TryLoad("Data\\hats").Keys) - yield return this.TryCreate(ItemType.Hat, id, p => new Hat(p.ID)); - } + // secret notes + else if (result?.QualifiedItemId == "(O)79") + { + foreach (SearchableItem? secretNote in this.GetSecretNotes(itemType, isJournalScrap: false)) + yield return secretNote; + } - // weapons - if (ShouldGet(ItemType.Weapon)) - { - Dictionary weaponsData = this.TryLoad("Data\\weapons"); - foreach (KeyValuePair pair in weaponsData) - { - string rawFields = pair.Value; - yield return this.TryCreate(ItemType.Weapon, pair.Key, p => - { - string[] fields = rawFields.Split('/'); - bool isSlingshot = fields.Length > 8 && fields[8] == "4"; - return isSlingshot - ? new Slingshot(p.ID) - : new MeleeWeapon(p.ID); - }); - } - } + // object + else + { + yield return result?.QualifiedItemId == "(O)340" + ? this.TryCreate(itemType.Identifier, result.Id, _ => objectDataDefinition.CreateFlavoredHoney(null)) // game creates "Wild Honey" when there's no ingredient, instead of the base Honey item + : result; + + if (includeVariants) + { + foreach (SearchableItem? variant in this.GetFlavoredObjectVariants(objectDataDefinition, result?.Item as SObject, itemType)) + yield return variant; + } + } + } + } + break; - // furniture - if (ShouldGet(ItemType.Furniture)) - { - foreach (int id in this.TryLoad("Data\\Furniture").Keys) - yield return this.TryCreate(ItemType.Furniture, id, p => Furniture.GetFurnitureInstance(p.ID)); + // no special handling needed + default: + foreach (string id in itemType.GetAllIds()) + yield return this.TryCreate(itemType.Identifier, id, p => ItemRegistry.Create(itemType.Identifier + p.Id)); + break; + } } - // craftables - if (ShouldGet(ItemType.BigCraftable)) + // wallpapers + if (onlyType is null or "(WP)") { - foreach (int id in Game1.bigCraftablesInformation.Keys) - yield return this.TryCreate(ItemType.BigCraftable, id, p => new SObject(Vector2.Zero, p.ID)); + for (int id = 0; id < 112; id++) + yield return this.TryCreate("(WP)", id.ToString(), p => new Wallpaper(int.Parse(p.Id)) { Category = SObject.furnitureCategory }); } - // objects - if (ShouldGet(ItemType.Object) || ShouldGet(ItemType.Ring)) + // flooring + if (onlyType is null or "(FL)") { - foreach (int id in Game1.objectInformation.Keys) - { - string[]? fields = Game1.objectInformation[id]?.Split('/'); - - // ring - if (id != 801 && fields?.Length >= 4 && fields[3] == "Ring") // 801 = wedding ring, which isn't an equippable ring - { - if (ShouldGet(ItemType.Ring)) - yield return this.TryCreate(ItemType.Ring, id, p => new Ring(p.ID)); - } - - // journal scrap - else if (id == 842) - { - if (ShouldGet(ItemType.Object)) - { - foreach (SearchableItem? journalScrap in this.GetSecretNotes(isJournalScrap: true)) - yield return journalScrap; - } - } - - // secret notes - else if (id == 79) - { - if (ShouldGet(ItemType.Object)) - { - foreach (SearchableItem? secretNote in this.GetSecretNotes(isJournalScrap: false)) - yield return secretNote; - } - } - - // object - else if (ShouldGet(ItemType.Object)) - { - // spawn main item - SearchableItem? mainItem = this.TryCreate(ItemType.Object, id, p => - { - // roe - if (p.ID == 812) - return new ColoredObject(p.ID, 1, Color.White); - - // Wild Honey - if (p.ID == 340) - { - return new SObject(Vector2.Zero, 340, "Wild Honey", false, true, false, false) - { - Name = "Wild Honey", - preservedParentSheetIndex = { -1 } - }; - } - - // else plain item - return new SObject(p.ID, 1); - }); - yield return mainItem; - - // flavored items - if (includeVariants && mainItem?.Item != null) - { - foreach (SearchableItem? variant in this.GetFlavoredObjectVariants((SObject)mainItem.Item)) - yield return variant; - } - } - } + for (int id = 0; id < 56; id++) + yield return this.TryCreate("(FL)", id.ToString(), p => new Wallpaper(int.Parse(p.Id), isFloor: true) { Category = SObject.furnitureCategory }); } } @@ -215,16 +123,17 @@ select item ** Private methods *********/ /// Get the individual secret note or journal scrap items. + /// The object data definition. /// Whether to get journal scraps. /// Derived from . - private IEnumerable GetSecretNotes(bool isJournalScrap) + private IEnumerable GetSecretNotes(IItemDataDefinition itemType, bool isJournalScrap) { // get base item ID - int baseId = isJournalScrap ? 842 : 79; + string baseId = isJournalScrap ? "842" : "79"; // get secret note IDs var ids = this - .TryLoad("Data\\SecretNotes") + .TryLoad(() => DataLoader.SecretNotes(Game1.content)) .Keys .Where(isJournalScrap ? id => (id >= GameLocation.JOURNAL_INDEX) @@ -236,15 +145,13 @@ select item ); // build items - foreach (int id in ids) + foreach (int i in ids) { - int fakeId = this.CustomIDOffset * 8 + id; - if (isJournalScrap) - fakeId += GameLocation.JOURNAL_INDEX; + int id = i; // avoid closure capture - yield return this.TryCreate(ItemType.Object, fakeId, _ => + yield return this.TryCreate(itemType.Identifier, $"{baseId}/{id}", _ => { - SObject note = new(baseId, 1); + Item note = ItemRegistry.Create(itemType.Identifier + baseId); note.Name = $"{note.Name} #{id}"; return note; }); @@ -252,78 +159,50 @@ select item } /// Get flavored variants of a base item (like Blueberry Wine for Blueberry), if any. + /// The item data definition for object items. /// A sample of the base item. - private IEnumerable GetFlavoredObjectVariants(SObject item) + /// The object data definition. + private IEnumerable GetFlavoredObjectVariants(ObjectDataDefinition objectDataDefinition, SObject? item, IItemDataDefinition itemType) { - int id = item.ParentSheetIndex; + if (item is null) + yield break; + + string id = item.ItemId; + // by category switch (item.Category) { // fruit products case SObject.FruitsCategory: - // wine - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 2 + id, _ => new SObject(348, 1) - { - Name = $"{item.Name} Wine", - Price = item.Price * 3, - preserve = { SObject.PreserveType.Wine }, - preservedParentSheetIndex = { id } - }); - - // jelly - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 3 + id, _ => new SObject(344, 1) - { - Name = $"{item.Name} Jelly", - Price = 50 + item.Price * 2, - preserve = { SObject.PreserveType.Jelly }, - preservedParentSheetIndex = { id } - }); + yield return this.TryCreate(itemType.Identifier, $"348/{id}", _ => objectDataDefinition.CreateFlavoredWine(item)); + yield return this.TryCreate(itemType.Identifier, $"344/{id}", _ => objectDataDefinition.CreateFlavoredJelly(item)); + break; + + // greens + case SObject.GreensCategory: + yield return this.TryCreate(itemType.Identifier, $"342/{id}", _ => objectDataDefinition.CreateFlavoredPickle(item)); break; // vegetable products case SObject.VegetableCategory: - // juice - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 4 + id, _ => new SObject(350, 1) - { - Name = $"{item.Name} Juice", - Price = (int)(item.Price * 2.25d), - preserve = { SObject.PreserveType.Juice }, - preservedParentSheetIndex = { id } - }); - - // pickled - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, _ => new SObject(342, 1) - { - Name = $"Pickled {item.Name}", - Price = 50 + item.Price * 2, - preserve = { SObject.PreserveType.Pickle }, - preservedParentSheetIndex = { id } - }); + yield return this.TryCreate(itemType.Identifier, $"350/{id}", _ => objectDataDefinition.CreateFlavoredJuice(item)); + yield return this.TryCreate(itemType.Identifier, $"342/{id}", _ => objectDataDefinition.CreateFlavoredPickle(item)); break; // flower honey case SObject.flowersCategory: - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, _ => - { - SObject honey = new(Vector2.Zero, 340, $"{item.Name} Honey", false, true, false, false) - { - Name = $"{item.Name} Honey", - preservedParentSheetIndex = { id } - }; - honey.Price += item.Price * 2; - return honey; - }); + yield return this.TryCreate(itemType.Identifier, $"340/{id}", _ => objectDataDefinition.CreateFlavoredHoney(item)); break; // roe and aged roe (derived from FishPond.GetFishProduce) - case SObject.sellAtFishShopCategory when id == 812: + case SObject.sellAtFishShopCategory when item.QualifiedItemId == "(O)812": { this.GetRoeContextTagLookups(out HashSet simpleTags, out List> complexTags); - foreach (var pair in Game1.objectInformation) + foreach (string key in Game1.objectData.Keys) { // get input - SObject? input = this.TryCreate(ItemType.Object, pair.Key, p => new SObject(p.ID, 1))?.Item as SObject; + SObject? input = this.TryCreate(itemType.Identifier, key, p => new SObject(p.Id, 1))?.Item as SObject; if (input == null) continue; @@ -335,49 +214,21 @@ select item if (!inputTags.Any(tag => simpleTags.Contains(tag)) && !complexTags.Any(set => set.All(tag => input.HasContextTag(tag)))) continue; - // yield roe - SObject? roe = null; - Color color = this.GetRoeColor(input); - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + id, _ => - { - roe = new ColoredObject(812, 1, color) - { - name = $"{input.Name} Roe", - preserve = { Value = SObject.PreserveType.Roe }, - preservedParentSheetIndex = { Value = input.ParentSheetIndex } - }; - roe.Price += input.Price / 2; - return roe; - }); - - // aged roe - if (roe != null && pair.Key != 698) // aged sturgeon roe is caviar, which is a separate item - { - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + id, _ => new ColoredObject(447, 1, color) - { - name = $"Aged {input.Name} Roe", - Category = -27, - preserve = { Value = SObject.PreserveType.AgedRoe }, - preservedParentSheetIndex = { Value = input.ParentSheetIndex }, - Price = roe.Price * 2 - }); - } + // create roe + SearchableItem? roe = this.TryCreate(itemType.Identifier, $"812/{input.ItemId}", _ => objectDataDefinition.CreateFlavoredRoe(input)); + yield return roe; + + // create aged roe + if (roe?.Item is SObject roeObj && input.QualifiedItemId != "(O)698") // skip aged sturgeon roe (which is a separate caviar item) + yield return this.TryCreate(itemType.Identifier, $"447/{input.ItemId}", _ => objectDataDefinition.CreateFlavoredAgedRoe(roeObj)); } } break; } - // ginger => pickled ginger - if (id == 829 && item.Category != SObject.VegetableCategory) - { - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, _ => new SObject(342, 1) - { - Name = $"Pickled {item.Name}", - Price = 50 + item.Price * 2, - preserve = { SObject.PreserveType.Pickle }, - preservedParentSheetIndex = { id } - }); - } + // by context tag + if (item.HasContextTag("preserves_pickle") && item.Category is not (SObject.GreensCategory or SObject.VegetableCategory)) + yield return this.TryCreate(itemType.Identifier, $"342/{id}", _ => objectDataDefinition.CreateFlavoredPickle(item)); } /// Get optimized lookups to match items which produce roe in a fish pond. @@ -388,9 +239,9 @@ private void GetRoeContextTagLookups(out HashSet simpleTags, out List
  • (); complexTags = new List>(); - foreach (FishPondData data in Game1.content.Load>("Data\\FishPondData")) + foreach (FishPondData data in this.TryLoad(() => DataLoader.FishPondData(Game1.content))) { - if (data.ProducedItems.All(p => p.ItemID != 812)) + if (data.ProducedItems.All(p => p.ItemId is not ("812" or "(O)812"))) continue; // doesn't produce roe if (data.RequiredTags.Count == 1 && !data.RequiredTags[0].StartsWith("!")) @@ -400,34 +251,37 @@ private void GetRoeContextTagLookups(out HashSet simpleTags, out List
  • Try to load a data file, and return empty data if it's invalid. - /// The asset key type. - /// The asset value type. - /// The data asset name. - private Dictionary TryLoad(string assetName) - where TKey : notnull + /// Try to load a data asset, and return empty data if it's invalid. + /// The asset type. + /// A callback which loads the asset. + private TAsset TryLoad(Func load) + where TAsset : new() { try { - return Game1.content.Load>(assetName); + return load(); } catch (ContentLoadException) { // generally due to a player incorrectly replacing a data file with an XNB mod - return new Dictionary(); + return new TAsset(); } } /// Create a searchable item if valid. /// The item type. - /// The unique ID (if different from the item's parent sheet index). + /// The locally unique item key. /// Create an item instance. - private SearchableItem? TryCreate(ItemType type, int id, Func createItem) + private SearchableItem? TryCreate(string type, string key, Func createItem) { try { - var item = new SearchableItem(type, id, createItem); + SearchableItem item = new SearchableItem(type, key, createItem); item.Item.getDescription(); // force-load item data, so it crashes here if it's invalid + + if (item.Item.Name is null or "Error Item") + return null; + return item; } catch @@ -435,53 +289,5 @@ private Dictionary TryLoad(string assetName) return null; // if some item data is invalid, just don't include it } } - - /// Get the color to use a given fish's roe. - /// The fish whose roe to color. - /// Derived from . - private Color GetRoeColor(SObject fish) - { - return fish.ParentSheetIndex == 698 // sturgeon - ? new Color(61, 55, 42) - : (TailoringMenu.GetDyeColor(fish) ?? Color.Orange); - } - - /// Get valid shirt IDs. - /// - /// Shirts have a possible range of 1000–1999, but not all of those IDs are valid. There are two sets of IDs: - /// - /// - /// - /// Shirts which exist in . - /// - /// - /// Shirts with a dynamic ID and no entry in . These automatically - /// use the generic shirt entry with ID -1 and are mapped to a calculated position in the - /// Characters/Farmer/shirts spritesheet. There's no constant we can use, but some known valid - /// ranges are 1000–1111 (used in for the customization screen and - /// 1000–1127 (used in and ). - /// Based on the spritesheet, the max valid ID is 1299. - /// - /// - /// - private IEnumerable GetShirtIds() - { - // defined shirt items - foreach (int id in Game1.clothingInformation.Keys) - { - if (id < 0) - continue; // placeholder data for character customization clothing below - - yield return id; - } - - // dynamic shirts - HashSet clothingIds = new HashSet(Game1.clothingInformation.Keys); - for (int id = 1000; id <= 1299; id++) - { - if (!clothingIds.Contains(id)) - yield return id; - } - } } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/SearchableItem.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/SearchableItem.cs similarity index 73% rename from src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/SearchableItem.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/SearchableItem.cs index 3675a9631..a931d2065 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/SearchableItem.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/SearchableItem.cs @@ -1,7 +1,8 @@ using System; using StardewValley; +using StardewValley.ItemTypeDefinitions; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework { /// A game item with metadata. internal class SearchableItem @@ -9,8 +10,8 @@ internal class SearchableItem /********* ** Accessors *********/ - /// The item type. - public ItemType Type { get; } + /// The value for the item type. + public string Type { get; } /// A sample item instance. public Item Item { get; } @@ -18,8 +19,11 @@ internal class SearchableItem /// Create an item instance. public Func CreateItem { get; } - /// The item's unique ID for its type. - public int ID { get; } + /// The unqualified item ID. + public string Id { get; } + + /// The qualified item ID. + public string QualifiedItemId { get; } /// The item's default name. public string Name => this.Item.Name; @@ -33,12 +37,13 @@ internal class SearchableItem *********/ /// Construct an instance. /// The item type. - /// The unique ID (if different from the item's parent sheet index). + /// The unqualified item ID. /// Create an item instance. - public SearchableItem(ItemType type, int id, Func createItem) + public SearchableItem(string type, string id, Func createItem) { this.Type = type; - this.ID = id; + this.Id = id; + this.QualifiedItemId = this.Type + this.Id; this.CreateItem = () => createItem(this); this.Item = createItem(this); } diff --git a/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs b/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs index dbfca8158..3fdea370a 100644 --- a/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs +++ b/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using StardewModdingAPI.Events; using StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands; namespace StardewModdingAPI.Mods.ConsoleCommands @@ -18,9 +17,6 @@ public class ModEntry : Mod /// The commands which may need to handle update ticks. private IConsoleCommand[] UpdateHandlers = null!; - /// The commands which may need to handle input. - private IConsoleCommand[] InputHandlers = null!; - /********* ** Public methods @@ -35,27 +31,16 @@ public override void Entry(IModHelper helper) helper.ConsoleCommands.Add(command.Name, command.Description, (name, args) => this.HandleCommand(command, name, args)); // cache commands - this.InputHandlers = this.Commands.Where(p => p.MayNeedInput).ToArray(); this.UpdateHandlers = this.Commands.Where(p => p.MayNeedUpdate).ToArray(); // hook events helper.Events.GameLoop.UpdateTicked += this.OnUpdateTicked; - helper.Events.Input.ButtonPressed += this.OnButtonPressed; } /********* ** Private methods *********/ - /// The method invoked when a button is pressed. - /// The event sender. - /// The event arguments. - private void OnButtonPressed(object? sender, ButtonPressedEventArgs e) - { - foreach (IConsoleCommand command in this.InputHandlers) - command.OnButtonPressed(this.Monitor, e.Button); - } - /// The method invoked when the game updates its state. /// The event sender. /// The event arguments. diff --git a/src/SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj b/src/SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj index e3db8b477..c00a6f4fe 100644 --- a/src/SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj +++ b/src/SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj @@ -2,7 +2,7 @@ ConsoleCommands StardewModdingAPI.Mods.ConsoleCommands - net5.0 + net6.0 false @@ -22,6 +22,4 @@ - - diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json index a97124e50..9cb9e9936 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.18.6", + "Version": "4.0.8", "Description": "Adds SMAPI console commands that let you manipulate the game.", "UniqueID": "SMAPI.ConsoleCommands", "EntryDll": "ConsoleCommands.dll", - "MinimumApiVersion": "3.18.6" + "MinimumApiVersion": "4.0.8" } diff --git a/src/SMAPI.Mods.ErrorHandler/ModEntry.cs b/src/SMAPI.Mods.ErrorHandler/ModEntry.cs deleted file mode 100644 index 25056b5e4..000000000 --- a/src/SMAPI.Mods.ErrorHandler/ModEntry.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System; -using System.Reflection; -using StardewModdingAPI.Events; -using StardewModdingAPI.Internal.Patching; -#if SMAPI_DEPRECATED -using StardewModdingAPI.Mods.ErrorHandler.ModPatches; -#endif -using StardewModdingAPI.Mods.ErrorHandler.Patches; -using StardewValley; - -namespace StardewModdingAPI.Mods.ErrorHandler -{ - /// The main entry point for the mod. - public class ModEntry : Mod - { - /********* - ** Private methods - *********/ - /// Whether custom content was removed from the save data to avoid a crash. - private bool IsSaveContentRemoved; - - - /********* - ** Public methods - *********/ - /// The mod entry point, called after the mod is first loaded. - /// Provides simplified APIs for writing mods. - public override void Entry(IModHelper helper) - { - // get SMAPI core types - IMonitor monitorForGame = this.GetMonitorForGame(); - - // apply patches - HarmonyPatcher.Apply(this.ModManifest.UniqueID, this.Monitor, - // game patches - new DialoguePatcher(monitorForGame, this.Helper.Reflection), - new EventPatcher(monitorForGame), - new GameLocationPatcher(monitorForGame), - new IClickableMenuPatcher(), - new NpcPatcher(monitorForGame), - new ObjectPatcher(), - new SaveGamePatcher(this.Monitor, this.OnSaveContentRemoved), - new SpriteBatchPatcher(), - new UtilityPatcher() - -#if SMAPI_DEPRECATED - // mod patches - , new PyTkPatcher(helper.ModRegistry) -#endif - ); - - // hook events - this.Helper.Events.GameLoop.SaveLoaded += this.OnSaveLoaded; - } - - - /********* - ** Private methods - *********/ - /// Raised after custom content is removed from the save data to avoid a crash. - internal void OnSaveContentRemoved() - { - this.IsSaveContentRemoved = true; - } - - /// The method invoked when a save is loaded. - /// The event sender. - /// The event arguments. - private void OnSaveLoaded(object? sender, SaveLoadedEventArgs e) - { - // show in-game warning for removed save content - if (this.IsSaveContentRemoved) - { - this.IsSaveContentRemoved = false; - Game1.addHUDMessage(new HUDMessage(this.Helper.Translation.Get("warn.invalid-content-removed"), HUDMessage.error_type)); - } - } - - /// Get the monitor with which to log game errors. - private IMonitor GetMonitorForGame() - { - // get SMAPI core - Type coreType = Type.GetType("StardewModdingAPI.Framework.SCore, StardewModdingAPI", throwOnError: false) - ?? throw new InvalidOperationException("Can't access SMAPI's core type. This mod may not work correctly."); - object core = coreType.GetProperty("Instance", BindingFlags.Static | BindingFlags.NonPublic)?.GetValue(null) - ?? throw new InvalidOperationException("Can't access SMAPI's core instance. This mod may not work correctly."); - - // get monitor - MethodInfo getMonitorForGame = coreType.GetMethod("GetMonitorForGame") - ?? throw new InvalidOperationException("Can't access the SMAPI's 'GetMonitorForGame' method. This mod may not work correctly."); - - return (IMonitor?)getMonitorForGame.Invoke(core, Array.Empty()) ?? this.Monitor; - } - } -} diff --git a/src/SMAPI.Mods.ErrorHandler/ModPatches/PyTkPatcher.cs b/src/SMAPI.Mods.ErrorHandler/ModPatches/PyTkPatcher.cs deleted file mode 100644 index f084902a5..000000000 --- a/src/SMAPI.Mods.ErrorHandler/ModPatches/PyTkPatcher.cs +++ /dev/null @@ -1,81 +0,0 @@ -#if SMAPI_DEPRECATED -using System; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using HarmonyLib; -using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Graphics; -using StardewModdingAPI.Framework; -using StardewModdingAPI.Framework.Content; -using StardewModdingAPI.Internal; -using StardewModdingAPI.Internal.Patching; - -// -// This is part of a three-part fix for PyTK 1.23.* and earlier. When removing this, search -// 'Platonymous.Toolkit' to find the other part in SMAPI and Content Patcher. -// - -namespace StardewModdingAPI.Mods.ErrorHandler.ModPatches -{ - /// Harmony patches for the PyTK mod for compatibility with newer SMAPI versions. - /// Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments. - [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.")] - [SuppressMessage("ReSharper", "StringLiteralTypo", Justification = "'Platonymous' is part of the mod ID.")] - internal class PyTkPatcher : BasePatcher - { - /********* - ** Fields - *********/ - /// The PyTK mod metadata, if it's installed. - private static IModMetadata? PyTk; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The mod registry from which to read PyTK metadata. - public PyTkPatcher(IModRegistry modRegistry) - { - IModMetadata? pyTk = (IModMetadata?)modRegistry.Get(@"Platonymous.Toolkit"); - if (pyTk is not null && pyTk.Manifest.Version.IsOlderThan("1.24.0")) - PyTkPatcher.PyTk = pyTk; - } - - /// - public override void Apply(Harmony harmony, IMonitor monitor) - { - try - { - // get mod info - IModMetadata? pyTk = PyTkPatcher.PyTk; - if (pyTk is null) - return; - - // get patch method - const string patchMethodName = "PatchImage"; - MethodInfo? patch = AccessTools.Method(pyTk.Mod!.GetType(), patchMethodName); - if (patch is null) - { - monitor.Log("Failed applying compatibility patch for PyTK. Its image scaling feature may not work correctly.", LogLevel.Warn); - monitor.Log($"Couldn't find patch method '{pyTk.Mod.GetType().FullName}.{patchMethodName}'."); - return; - } - - // apply patch - harmony = new($"{harmony.Id}.compatibility-patches.PyTK"); - harmony.Patch( - original: AccessTools.Method(typeof(AssetDataForImage), nameof(AssetDataForImage.PatchImage), new[] { typeof(Texture2D), typeof(Rectangle), typeof(Rectangle), typeof(PatchMode) }), - prefix: new HarmonyMethod(patch) - ); - } - catch (Exception ex) - { - monitor.Log("Failed applying compatibility patch for PyTK. Its image scaling feature may not work correctly.", LogLevel.Warn); - monitor.Log(ex.GetLogSummary()); - } - } - } -} -#endif diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/DialoguePatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/DialoguePatcher.cs deleted file mode 100644 index e98eec3c9..000000000 --- a/src/SMAPI.Mods.ErrorHandler/Patches/DialoguePatcher.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using HarmonyLib; -using StardewModdingAPI.Internal; -using StardewModdingAPI.Internal.Patching; -using StardewValley; - -namespace StardewModdingAPI.Mods.ErrorHandler.Patches -{ - /// Harmony patches for which intercept invalid dialogue lines and logs an error instead of crashing. - /// Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments. - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - internal class DialoguePatcher : BasePatcher - { - /********* - ** Fields - *********/ - /// Writes messages to the console and log file on behalf of the game. - private static IMonitor MonitorForGame = null!; - - /// Simplifies access to private code. - private static IReflectionHelper Reflection = null!; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// Writes messages to the console and log file on behalf of the game. - /// Simplifies access to private code. - public DialoguePatcher(IMonitor monitorForGame, IReflectionHelper reflector) - { - DialoguePatcher.MonitorForGame = monitorForGame; - DialoguePatcher.Reflection = reflector; - } - - /// - public override void Apply(Harmony harmony, IMonitor monitor) - { - harmony.Patch( - original: this.RequireConstructor(typeof(string), typeof(NPC)), - finalizer: this.GetHarmonyMethod(nameof(DialoguePatcher.Finalize_Constructor)) - ); - } - - - /********* - ** Private methods - *********/ - /// The method to call when the Dialogue constructor throws an exception. - /// The instance being patched. - /// The dialogue being parsed. - /// The NPC for which the dialogue is being parsed. - /// The exception thrown by the wrapped method, if any. - /// Returns the exception to throw, if any. - private static Exception? Finalize_Constructor(Dialogue __instance, string masterDialogue, NPC? speaker, Exception? __exception) - { - if (__exception != null) - { - // log message - string? name = !string.IsNullOrWhiteSpace(speaker?.Name) ? speaker.Name : null; - DialoguePatcher.MonitorForGame.Log($"Failed parsing dialogue string{(name != null ? $" for {name}" : "")}:\n{masterDialogue}\n{__exception.GetLogSummary()}", LogLevel.Error); - - // set default dialogue - IReflectedMethod parseDialogueString = DialoguePatcher.Reflection.GetMethod(__instance, "parseDialogueString"); - IReflectedMethod checkForSpecialDialogueAttributes = DialoguePatcher.Reflection.GetMethod(__instance, "checkForSpecialDialogueAttributes"); - parseDialogueString.Invoke("..."); - checkForSpecialDialogueAttributes.Invoke(); - } - - return null; - } - } -} diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/EventPatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/EventPatcher.cs deleted file mode 100644 index 073c62cc0..000000000 --- a/src/SMAPI.Mods.ErrorHandler/Patches/EventPatcher.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using HarmonyLib; -using StardewModdingAPI.Internal.Patching; -using StardewValley; - -namespace StardewModdingAPI.Mods.ErrorHandler.Patches -{ - /// Harmony patches for which intercept errors to log more details. - /// Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments. - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - internal class EventPatcher : BasePatcher - { - /********* - ** Fields - *********/ - /// Writes messages to the console and log file on behalf of the game. - private static IMonitor MonitorForGame = null!; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// Writes messages to the console and log file on behalf of the game. - public EventPatcher(IMonitor monitorForGame) - { - EventPatcher.MonitorForGame = monitorForGame; - } - - /// - public override void Apply(Harmony harmony, IMonitor monitor) - { - harmony.Patch( - original: this.RequireMethod(nameof(Event.LogErrorAndHalt)), - postfix: this.GetHarmonyMethod(nameof(EventPatcher.After_LogErrorAndHalt)) - ); - } - - - /********* - ** Private methods - *********/ - /// The method to call after . - /// The exception being logged. - private static void After_LogErrorAndHalt(Exception e) - { - EventPatcher.MonitorForGame.Log(e.ToString(), LogLevel.Error); - } - } -} diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/GameLocationPatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/GameLocationPatcher.cs deleted file mode 100644 index 9247fa487..000000000 --- a/src/SMAPI.Mods.ErrorHandler/Patches/GameLocationPatcher.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using HarmonyLib; -using StardewModdingAPI.Internal.Patching; -using StardewValley; -using xTile; - -namespace StardewModdingAPI.Mods.ErrorHandler.Patches -{ - /// Harmony patches for which intercept errors instead of crashing. - /// Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments. - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - internal class GameLocationPatcher : BasePatcher - { - /********* - ** Fields - *********/ - /// Writes messages to the console and log file on behalf of the game. - private static IMonitor MonitorForGame = null!; - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// Writes messages to the console and log file on behalf of the game. - public GameLocationPatcher(IMonitor monitorForGame) - { - GameLocationPatcher.MonitorForGame = monitorForGame; - } - - /// - public override void Apply(Harmony harmony, IMonitor monitor) - { - harmony.Patch( - original: this.RequireMethod(nameof(GameLocation.checkEventPrecondition)), - finalizer: this.GetHarmonyMethod(nameof(GameLocationPatcher.Finalize_CheckEventPrecondition)) - ); - harmony.Patch( - original: this.RequireMethod(nameof(GameLocation.updateSeasonalTileSheets)), - finalizer: this.GetHarmonyMethod(nameof(GameLocationPatcher.Finalize_UpdateSeasonalTileSheets)) - ); - } - - - /********* - ** Private methods - *********/ - /// The method to call when throws an exception. - /// The return value of the original method. - /// The precondition to be parsed. - /// The exception thrown by the wrapped method, if any. - /// Returns the exception to throw, if any. - private static Exception? Finalize_CheckEventPrecondition(ref int __result, string precondition, Exception? __exception) - { - if (__exception != null) - { - __result = -1; - GameLocationPatcher.MonitorForGame.Log($"Failed parsing event precondition ({precondition}):\n{__exception.InnerException}", LogLevel.Error); - } - - return null; - } - - /// The method to call when throws an exception. - /// The instance being patched. - /// The map whose tilesheets to update. - /// The exception thrown by the wrapped method, if any. - /// Returns the exception to throw, if any. - private static Exception? Finalize_UpdateSeasonalTileSheets(GameLocation __instance, Map map, Exception? __exception) - { - if (__exception != null) - GameLocationPatcher.MonitorForGame.Log($"Failed updating seasonal tilesheets for location '{__instance.NameOrUniqueName}': \n{__exception}", LogLevel.Error); - - return null; - } - } -} diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/IClickableMenuPatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/IClickableMenuPatcher.cs deleted file mode 100644 index b65a695ac..000000000 --- a/src/SMAPI.Mods.ErrorHandler/Patches/IClickableMenuPatcher.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using HarmonyLib; -using StardewModdingAPI.Internal.Patching; -using StardewValley; -using StardewValley.Menus; -using SObject = StardewValley.Object; - -namespace StardewModdingAPI.Mods.ErrorHandler.Patches -{ - /// Harmony patches for which intercept crashes due to invalid items. - /// Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments. - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - internal class IClickableMenuPatcher : BasePatcher - { - /********* - ** Public methods - *********/ - /// - public override void Apply(Harmony harmony, IMonitor monitor) - { - harmony.Patch( - original: this.RequireMethod(nameof(IClickableMenu.drawToolTip)), - prefix: this.GetHarmonyMethod(nameof(IClickableMenuPatcher.Before_DrawTooltip)) - ); - } - - - /********* - ** Private methods - *********/ - /// The method to call instead of . - /// The item for which to draw a tooltip. - /// Returns whether to execute the original method. - private static bool Before_DrawTooltip(Item hoveredItem) - { - // invalid edible item cause crash when drawing tooltips - if (hoveredItem is SObject obj && obj.Edibility != -300 && !Game1.objectInformation.ContainsKey(obj.ParentSheetIndex)) - return false; - - return true; - } - } -} diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/NpcPatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/NpcPatcher.cs deleted file mode 100644 index 11f7ec696..000000000 --- a/src/SMAPI.Mods.ErrorHandler/Patches/NpcPatcher.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using HarmonyLib; -using StardewModdingAPI.Internal; -using StardewModdingAPI.Internal.Patching; -using StardewValley; - -namespace StardewModdingAPI.Mods.ErrorHandler.Patches -{ - /// Harmony patches for which intercept crashes due to invalid schedule data. - /// Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments. - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - internal class NpcPatcher : BasePatcher - { - /********* - ** Fields - *********/ - /// Writes messages to the console and log file on behalf of the game. - private static IMonitor MonitorForGame = null!; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// Writes messages to the console and log file on behalf of the game. - public NpcPatcher(IMonitor monitorForGame) - { - NpcPatcher.MonitorForGame = monitorForGame; - } - - /// - public override void Apply(Harmony harmony, IMonitor monitor) - { - harmony.Patch( - original: this.RequireMethod($"get_{nameof(NPC.CurrentDialogue)}"), - finalizer: this.GetHarmonyMethod(nameof(NpcPatcher.Finalize_CurrentDialogue)) - ); - - harmony.Patch( - original: this.RequireMethod(nameof(NPC.parseMasterSchedule)), - finalizer: this.GetHarmonyMethod(nameof(NpcPatcher.Finalize_ParseMasterSchedule)) - ); - } - - - /********* - ** Private methods - *********/ - /// The method to call when throws an exception. - /// The instance being patched. - /// The return value of the original method. - /// The exception thrown by the wrapped method, if any. - /// Returns the exception to throw, if any. - private static Exception? Finalize_CurrentDialogue(NPC __instance, ref Stack __result, Exception? __exception) - { - if (__exception == null) - return null; - - NpcPatcher.MonitorForGame.Log($"Failed loading current dialogue for NPC {__instance.Name}:\n{__exception.GetLogSummary()}", LogLevel.Error); - __result = new Stack(); - - return null; - } - - /// The method to call instead of . - /// The raw schedule data to parse. - /// The instance being patched. - /// The patched method's return value. - /// The exception thrown by the wrapped method, if any. - /// Returns the exception to throw, if any. - private static Exception? Finalize_ParseMasterSchedule(string rawData, NPC __instance, ref Dictionary __result, Exception? __exception) - { - if (__exception != null) - { - NpcPatcher.MonitorForGame.Log($"Failed parsing schedule for NPC {__instance.Name}:\n{rawData}\n{__exception.GetLogSummary()}", LogLevel.Error); - __result = new Dictionary(); - } - - return null; - } - } -} diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/ObjectPatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/ObjectPatcher.cs deleted file mode 100644 index 09a6fbbdd..000000000 --- a/src/SMAPI.Mods.ErrorHandler/Patches/ObjectPatcher.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using HarmonyLib; -using StardewModdingAPI.Internal.Patching; -using StardewValley; -using SObject = StardewValley.Object; - -namespace StardewModdingAPI.Mods.ErrorHandler.Patches -{ - /// Harmony patches for which intercept crashes due to invalid items. - /// Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments. - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - internal class ObjectPatcher : BasePatcher - { - /********* - ** Public methods - *********/ - /// - public override void Apply(Harmony harmony, IMonitor monitor) - { - // object.getDescription - harmony.Patch( - original: this.RequireMethod(nameof(SObject.getDescription)), - prefix: this.GetHarmonyMethod(nameof(ObjectPatcher.Before_Object_GetDescription)) - ); - - // object.getDisplayName - harmony.Patch( - original: this.RequireMethod("loadDisplayName"), - finalizer: this.GetHarmonyMethod(nameof(ObjectPatcher.Finalize_Object_loadDisplayName)) - ); - } - - - /********* - ** Private methods - *********/ - /// The method to call instead of . - /// The instance being patched. - /// The patched method's return value. - /// Returns whether to execute the original method. - private static bool Before_Object_GetDescription(SObject __instance, ref string __result) - { - // invalid bigcraftables crash instead of showing '???' like invalid non-bigcraftables - if (!__instance.IsRecipe && __instance.bigCraftable.Value && !Game1.bigCraftablesInformation.ContainsKey(__instance.ParentSheetIndex)) - { - __result = "???"; - return false; - } - - return true; - } - - /// The method to call after . - /// The patched method's return value. - /// The exception thrown by the wrapped method, if any. - /// Returns the exception to throw, if any. - private static Exception? Finalize_Object_loadDisplayName(ref string __result, Exception? __exception) - { - if (__exception is KeyNotFoundException) - { - __result = "???"; - return null; - } - - return __exception; - } - } -} diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/SaveGamePatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/SaveGamePatcher.cs deleted file mode 100644 index 490bbfb6d..000000000 --- a/src/SMAPI.Mods.ErrorHandler/Patches/SaveGamePatcher.cs +++ /dev/null @@ -1,171 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using HarmonyLib; -using Microsoft.Xna.Framework.Content; -using StardewModdingAPI.Internal; -using StardewModdingAPI.Internal.Patching; -using StardewValley; -using StardewValley.Buildings; -using StardewValley.Locations; - -namespace StardewModdingAPI.Mods.ErrorHandler.Patches -{ - /// Harmony patches for which prevent some errors due to broken save data. - /// Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments. - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - internal class SaveGamePatcher : BasePatcher - { - /********* - ** Fields - *********/ - /// Writes messages to the console and log file. - private static IMonitor Monitor = null!; - - /// A callback invoked when custom content is removed from the save data to avoid a crash. - private static Action OnContentRemoved = null!; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// Writes messages to the console and log file. - /// A callback invoked when custom content is removed from the save data to avoid a crash. - public SaveGamePatcher(IMonitor monitor, Action onContentRemoved) - { - SaveGamePatcher.Monitor = monitor; - SaveGamePatcher.OnContentRemoved = onContentRemoved; - } - - /// - public override void Apply(Harmony harmony, IMonitor monitor) - { - harmony.Patch( - original: this.RequireMethod(nameof(SaveGame.loadDataToLocations)), - prefix: this.GetHarmonyMethod(nameof(SaveGamePatcher.Before_LoadDataToLocations)) - ); - - harmony.Patch( - original: this.RequireMethod(nameof(SaveGame.LoadFarmType)), - finalizer: this.GetHarmonyMethod(nameof(SaveGamePatcher.Finalize_LoadFarmType)) - ); - } - - - /********* - ** Private methods - *********/ - /// The method to call instead of . - /// The game locations being loaded. - /// Returns whether to execute the original method. - private static bool Before_LoadDataToLocations(List gamelocations) - { - // missing locations/NPCs - IDictionary npcs = Game1.content.Load>("Data\\NPCDispositions"); - if (SaveGamePatcher.RemoveBrokenContent(gamelocations, npcs)) - SaveGamePatcher.OnContentRemoved(); - - return true; - } - - /// The method to call after throws an exception. - /// The exception thrown by the wrapped method, if any. - /// Returns the exception to throw, if any. - private static Exception? Finalize_LoadFarmType(Exception? __exception) - { - // missing custom farm type - if (__exception?.Message.Contains("not a valid farm type") == true && !int.TryParse(SaveGame.loaded.whichFarm, out _)) - { - SaveGamePatcher.Monitor.Log(__exception.GetLogSummary(), LogLevel.Error); - SaveGamePatcher.Monitor.Log($"Removed invalid custom farm type '{SaveGame.loaded.whichFarm}' to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom farm type mod?)", LogLevel.Warn); - - SaveGame.loaded.whichFarm = Farm.default_layout.ToString(); - SaveGame.LoadFarmType(); - SaveGamePatcher.OnContentRemoved(); - - __exception = null; - } - - return __exception; - } - - /// Remove content which no longer exists in the game data. - /// The current game locations. - /// The NPC data. - private static bool RemoveBrokenContent(IEnumerable locations, IDictionary npcs) - { - bool removedAny = false; - - foreach (GameLocation location in locations) - removedAny |= SaveGamePatcher.RemoveBrokenContent(location, npcs); - - return removedAny; - } - - /// Remove content which no longer exists in the game data. - /// The current game location. - /// The NPC data. - private static bool RemoveBrokenContent(GameLocation? location, IDictionary npcs) - { - bool removedAny = false; - if (location == null) - return false; - - // check buildings - if (location is BuildableGameLocation buildableLocation) - { - foreach (Building building in buildableLocation.buildings.ToArray()) - { - try - { - BluePrint _ = new(building.buildingType.Value); - } - catch (ContentLoadException) - { - SaveGamePatcher.Monitor.Log($"Removed invalid building type '{building.buildingType.Value}' in {location.Name} ({building.tileX}, {building.tileY}) to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom building mod?)", LogLevel.Warn); - buildableLocation.buildings.Remove(building); - removedAny = true; - continue; - } - - SaveGamePatcher.RemoveBrokenContent(building.indoors.Value, npcs); - } - } - - // check NPCs - foreach (NPC npc in location.characters.ToArray()) - { - if (npc.isVillager() && !npcs.ContainsKey(npc.Name)) - { - try - { - npc.reloadSprite(); // this won't crash for special villagers like Bouncer - } - catch - { - SaveGamePatcher.Monitor.Log($"Removed invalid villager '{npc.Name}' in {location.Name} ({npc.getTileLocation()}) to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom NPC mod?)", LogLevel.Warn); - location.characters.Remove(npc); - removedAny = true; - } - } - } - - // check objects - foreach (var pair in location.objects.Pairs.ToArray()) - { - // SpaceCore can leave null values when removing its custom content - if (pair.Value == null) - { - location.Objects.Remove(pair.Key); - SaveGamePatcher.Monitor.Log($"Removed invalid null object in {location.Name} ({pair.Key}) to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom item mod?)", LogLevel.Warn); - removedAny = true; - } - } - - return removedAny; - } - } -} diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/SpriteBatchPatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/SpriteBatchPatcher.cs deleted file mode 100644 index d369e0ef9..000000000 --- a/src/SMAPI.Mods.ErrorHandler/Patches/SpriteBatchPatcher.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using HarmonyLib; -using Microsoft.Xna.Framework.Graphics; -using StardewModdingAPI.Internal.Patching; - -namespace StardewModdingAPI.Mods.ErrorHandler.Patches -{ - /// Harmony patches for which validate textures earlier. - /// Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments. - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - internal class SpriteBatchPatcher : BasePatcher - { - /********* - ** Public methods - *********/ - /// - public override void Apply(Harmony harmony, IMonitor monitor) - { - harmony.Patch( - original: this.RequireMethod("CheckValid", new[] { typeof(Texture2D) }), - postfix: this.GetHarmonyMethod(nameof(SpriteBatchPatcher.After_CheckValid)) - ); - } - - - /********* - ** Private methods - *********/ - /// The method to call after . - /// The texture to validate. - private static void After_CheckValid(Texture2D? texture) - { - if (texture?.IsDisposed == true) - throw new ObjectDisposedException("Cannot draw this texture because it's disposed."); - } - } -} diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/UtilityPatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/UtilityPatcher.cs deleted file mode 100644 index 6d75a5817..000000000 --- a/src/SMAPI.Mods.ErrorHandler/Patches/UtilityPatcher.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using HarmonyLib; -using StardewModdingAPI.Internal.Patching; -using StardewValley; - -namespace StardewModdingAPI.Mods.ErrorHandler.Patches -{ - /// A Harmony patch for methods to log more detailed errors. - /// Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments. - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - internal class UtilityPatcher : BasePatcher - { - /********* - ** Public methods - *********/ - /// - public override void Apply(Harmony harmony, IMonitor monitor) - { - harmony.Patch( - original: this.RequireMethod(nameof(Utility.getItemFromStandardTextDescription)), - finalizer: this.GetHarmonyMethod(nameof(UtilityPatcher.Finalize_GetItemFromStandardTextDescription)) - ); - } - - - /********* - ** Private methods - *********/ - /// The method to call when throws an exception. - /// The item text description to parse. - /// The delimiter by which to split the text description. - /// The exception thrown by the wrapped method, if any. - /// Returns the exception to throw, if any. - private static Exception? Finalize_GetItemFromStandardTextDescription(string description, char delimiter, ref Exception? __exception) - { - return __exception != null - ? new FormatException($"Failed to parse item text description \"{description}\" with delimiter \"{delimiter}\".", __exception) - : null; - } - } -} diff --git a/src/SMAPI.Mods.ErrorHandler/SMAPI.Mods.ErrorHandler.csproj b/src/SMAPI.Mods.ErrorHandler/SMAPI.Mods.ErrorHandler.csproj deleted file mode 100644 index 53c37e97d..000000000 --- a/src/SMAPI.Mods.ErrorHandler/SMAPI.Mods.ErrorHandler.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - ErrorHandler - StardewModdingAPI.Mods.ErrorHandler - net5.0 - false - - - - - - - - - - - - - - - - - - - - - diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/de.json b/src/SMAPI.Mods.ErrorHandler/i18n/de.json deleted file mode 100644 index 1de6301cf..000000000 --- a/src/SMAPI.Mods.ErrorHandler/i18n/de.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - // warning messages - "warn.invalid-content-removed": "Ungültiger Inhalt wurde entfernt, um einen Absturz zu verhindern (siehe SMAPI Konsole für weitere Informationen)." -} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/default.json b/src/SMAPI.Mods.ErrorHandler/i18n/default.json deleted file mode 100644 index b74dcea00..000000000 --- a/src/SMAPI.Mods.ErrorHandler/i18n/default.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - // warning messages - "warn.invalid-content-removed": "Invalid content was removed to prevent a crash (see the SMAPI console for info)." -} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/es.json b/src/SMAPI.Mods.ErrorHandler/i18n/es.json deleted file mode 100644 index 8ba10b705..000000000 --- a/src/SMAPI.Mods.ErrorHandler/i18n/es.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - // warning messages - "warn.invalid-content-removed": "Se ha quitado contenido inválido para evitar un cierre forzoso (revisa la consola de SMAPI para más información)." -} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/fr.json b/src/SMAPI.Mods.ErrorHandler/i18n/fr.json deleted file mode 100644 index 769785263..000000000 --- a/src/SMAPI.Mods.ErrorHandler/i18n/fr.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - // warning messages - "warn.invalid-content-removed": "Le contenu non valide a été supprimé afin d'éviter un plantage (voir la console de SMAPI pour plus d'informations)." -} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/hu.json b/src/SMAPI.Mods.ErrorHandler/i18n/hu.json deleted file mode 100644 index 92aca7d08..000000000 --- a/src/SMAPI.Mods.ErrorHandler/i18n/hu.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - // warning messages - "warn.invalid-content-removed": "Érvénytelen elemek kerültek eltávolításra, hogy a játék ne omoljon össze (további információk a SMAPI konzolon)." -} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/it.json b/src/SMAPI.Mods.ErrorHandler/i18n/it.json deleted file mode 100644 index 5182972ee..000000000 --- a/src/SMAPI.Mods.ErrorHandler/i18n/it.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - // warning messages - "warn.invalid-content-removed": "Contenuto non valido rimosso per prevenire un crash (Guarda la console di SMAPI per maggiori informazioni)." -} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/ja.json b/src/SMAPI.Mods.ErrorHandler/i18n/ja.json deleted file mode 100644 index 559c7fbe8..000000000 --- a/src/SMAPI.Mods.ErrorHandler/i18n/ja.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - // warning messages - "warn.invalid-content-removed": "クラッシュを防ぐために無効なコンテンツを取り除きました (詳細はSMAPIコンソールを参照)" -} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/ko.json b/src/SMAPI.Mods.ErrorHandler/i18n/ko.json deleted file mode 100644 index 48f05c26c..000000000 --- a/src/SMAPI.Mods.ErrorHandler/i18n/ko.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - // warning messages - "warn.invalid-content-removed": "충돌을 방지하기 위해 잘못된 컨텐츠가 제거되었습니다 (자세한 내용은 SMAPI 콘솔 참조)." -} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/pl.json b/src/SMAPI.Mods.ErrorHandler/i18n/pl.json deleted file mode 100644 index f080bcd48..000000000 --- a/src/SMAPI.Mods.ErrorHandler/i18n/pl.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - // warning messages - "warn.invalid-content-removed": "Nieprawidłowa zawartość została usunięta, aby zapobiec awarii (zobacz konsolę SMAPI po więcej informacji)." -} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/pt.json b/src/SMAPI.Mods.ErrorHandler/i18n/pt.json deleted file mode 100644 index 8ea8cec94..000000000 --- a/src/SMAPI.Mods.ErrorHandler/i18n/pt.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - // warning messages - "warn.invalid-content-removed": "Conteúdo inválido foi removido para prevenir uma falha (veja o console do SMAPI para mais informações)." -} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/ru.json b/src/SMAPI.Mods.ErrorHandler/i18n/ru.json deleted file mode 100644 index e9c3b313a..000000000 --- a/src/SMAPI.Mods.ErrorHandler/i18n/ru.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - // warning messages - "warn.invalid-content-removed": "Недопустимое содержимое было удалено, чтобы предотвратить сбой (см. информацию в консоли SMAPI)" -} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/th.json b/src/SMAPI.Mods.ErrorHandler/i18n/th.json deleted file mode 100644 index e2a67dda2..000000000 --- a/src/SMAPI.Mods.ErrorHandler/i18n/th.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - // warning messages - "warn.invalid-content-removed": "ทำการลบเนื้อหาที่ไม่ถูกต้องออก เพื่อป้องกันไฟล์เกมเสียหาย (ดูรายละเอียดที่หน้าคอลโซลของ SMAPI)" -} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/tr.json b/src/SMAPI.Mods.ErrorHandler/i18n/tr.json deleted file mode 100644 index a05ab1522..000000000 --- a/src/SMAPI.Mods.ErrorHandler/i18n/tr.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - // warning messages - "warn.invalid-content-removed": "Yanlış paketlenmiş bir içerik, oyunun çökmemesi için yüklenmedi (SMAPI konsol penceresinde detaylı bilgi mevcut)." -} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/uk.json b/src/SMAPI.Mods.ErrorHandler/i18n/uk.json deleted file mode 100644 index a58102abe..000000000 --- a/src/SMAPI.Mods.ErrorHandler/i18n/uk.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - // warning messages - "warn.invalid-content-removed": "Недійсний вміст видалено, щоб запобігти аварійному завершенню роботи (Додаткову інформацію див. на консолі SMAPI)." -} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/zh.json b/src/SMAPI.Mods.ErrorHandler/i18n/zh.json deleted file mode 100644 index e959aa40f..000000000 --- a/src/SMAPI.Mods.ErrorHandler/i18n/zh.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - // warning messages - "warn.invalid-content-removed": "非法内容已移除以防游戏闪退(查看SMAPI控制台获得更多信息)" -} diff --git a/src/SMAPI.Mods.ErrorHandler/manifest.json b/src/SMAPI.Mods.ErrorHandler/manifest.json deleted file mode 100644 index fb268fa03..000000000 --- a/src/SMAPI.Mods.ErrorHandler/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Name": "Error Handler", - "Author": "SMAPI", - "Version": "3.18.6", - "Description": "Handles some common vanilla errors to log more useful info or avoid breaking the game.", - "UniqueID": "SMAPI.ErrorHandler", - "EntryDll": "ErrorHandler.dll", - "MinimumApiVersion": "3.18.6" -} diff --git a/src/SMAPI.Mods.SaveBackup/SMAPI.Mods.SaveBackup.csproj b/src/SMAPI.Mods.SaveBackup/SMAPI.Mods.SaveBackup.csproj index a8b0dfdbc..03130ff72 100644 --- a/src/SMAPI.Mods.SaveBackup/SMAPI.Mods.SaveBackup.csproj +++ b/src/SMAPI.Mods.SaveBackup/SMAPI.Mods.SaveBackup.csproj @@ -2,7 +2,7 @@ SaveBackup StardewModdingAPI.Mods.SaveBackup - net5.0 + net6.0 false diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json index 94a1ce79c..2bd6e053c 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.18.6", + "Version": "4.0.8", "Description": "Automatically backs up all your saves once per day into its folder.", "UniqueID": "SMAPI.SaveBackup", "EntryDll": "SaveBackup.dll", - "MinimumApiVersion": "3.18.6" + "MinimumApiVersion": "4.0.8" } diff --git a/src/SMAPI.Tests.ModApiConsumer/SMAPI.Tests.ModApiConsumer.csproj b/src/SMAPI.Tests.ModApiConsumer/SMAPI.Tests.ModApiConsumer.csproj index 7fef4ebdd..b4b1a14ec 100644 --- a/src/SMAPI.Tests.ModApiConsumer/SMAPI.Tests.ModApiConsumer.csproj +++ b/src/SMAPI.Tests.ModApiConsumer/SMAPI.Tests.ModApiConsumer.csproj @@ -1,6 +1,6 @@  - net5.0 + net6.0 diff --git a/src/SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj b/src/SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj index 7fef4ebdd..b4b1a14ec 100644 --- a/src/SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj +++ b/src/SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj @@ -1,6 +1,6 @@  - net5.0 + net6.0 diff --git a/src/SMAPI.Tests/Core/ModResolverTests.cs b/src/SMAPI.Tests/Core/ModResolverTests.cs index 70c782ab6..55df823ae 100644 --- a/src/SMAPI.Tests/Core/ModResolverTests.cs +++ b/src/SMAPI.Tests/Core/ModResolverTests.cs @@ -82,6 +82,7 @@ public void ReadBasicManifest_CanReadFile() [nameof(IManifest.UniqueID)] = $"{Sample.String()}.{Sample.String()}", [nameof(IManifest.EntryDll)] = $"{Sample.String()}.dll", [nameof(IManifest.MinimumApiVersion)] = $"{Sample.Int()}.{Sample.Int()}.{Sample.Int()}-{Sample.String()}", + [nameof(IManifest.MinimumGameVersion)] = $"{Sample.Int()}.{Sample.Int()}.{Sample.Int()}-{Sample.String()}", [nameof(IManifest.Dependencies)] = new[] { originalDependency }, ["ExtraString"] = Sample.String(), ["ExtraInt"] = Sample.Int() @@ -112,7 +113,8 @@ public void ReadBasicManifest_CanReadFile() Assert.AreEqual(original[nameof(IManifest.Description)], mod.Manifest.Description, "The manifest's description doesn't match."); Assert.AreEqual(original[nameof(IManifest.EntryDll)], mod.Manifest.EntryDll, "The manifest's entry DLL doesn't match."); Assert.AreEqual(original[nameof(IManifest.MinimumApiVersion)], mod.Manifest.MinimumApiVersion?.ToString(), "The manifest's minimum API version doesn't match."); - Assert.AreEqual(original[nameof(IManifest.Version)]?.ToString(), mod.Manifest.Version?.ToString(), "The manifest's version doesn't match."); + Assert.AreEqual(original[nameof(IManifest.MinimumGameVersion)], mod.Manifest.MinimumGameVersion?.ToString(), "The manifest's minimum game version doesn't match."); + Assert.AreEqual(original[nameof(IManifest.Version)].ToString(), mod.Manifest.Version.ToString(), "The manifest's version doesn't match."); Assert.IsNotNull(mod.Manifest.ExtraFields, "The extra fields should not be null."); Assert.AreEqual(2, mod.Manifest.ExtraFields.Count, "The extra fields should contain two values."); @@ -133,7 +135,7 @@ public void ReadBasicManifest_CanReadFile() [Test(Description = "Assert that validation doesn't fail if there are no mods installed.")] public void ValidateManifests_NoMods_DoesNothing() { - new ModResolver().ValidateManifests(Array.Empty(), apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false); + new ModResolver().ValidateManifests(Array.Empty(), apiVersion: new SemanticVersion("1.0.0"), gameVersion: new SemanticVersion("1.0.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false); } [Test(Description = "Assert that validation skips manifests that have already failed without calling any other properties.")] @@ -144,7 +146,7 @@ public void ValidateManifests_Skips_Failed() mock.Setup(p => p.Status).Returns(ModMetadataStatus.Failed); // act - new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false); + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0.0"), gameVersion: new SemanticVersion("1.0.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false); // assert mock.VerifyGet(p => p.Status, Times.Once, "The validation did not check the manifest status."); @@ -161,7 +163,7 @@ public void ValidateManifests_ModStatus_AssumeBroken_Fails() }); // act - new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false); + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0.0"), gameVersion: new SemanticVersion("1.0.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false); // assert mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "The validation did not fail the metadata."); @@ -175,7 +177,21 @@ public void ValidateManifests_MinimumApiVersion_Fails() mock.Setup(p => p.Manifest).Returns(this.GetManifest(minimumApiVersion: "1.1")); // act - new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false); + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0.0"), gameVersion: new SemanticVersion("1.0.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false); + + // assert + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "The validation did not fail the metadata."); + } + + [Test(Description = "Assert that validation fails when the minimum game version is higher than the current Stardew Valley version.")] + public void ValidateManifests_MinimumGameVersion_Fails() + { + // arrange + Mock mock = this.GetMetadata("Mod A", Array.Empty(), allowStatusChange: true); + mock.Setup(p => p.Manifest).Returns(this.GetManifest(minimumGameVersion: "1.6.9")); + + // act + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0.0"), gameVersion: new SemanticVersion("1.0.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false); // assert mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "The validation did not fail the metadata."); @@ -190,7 +206,7 @@ public void ValidateManifests_MissingEntryDLL_Fails() Directory.CreateDirectory(directoryPath); // act - new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup); + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0.0"), gameVersion: new SemanticVersion("1.0.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup); // assert mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "The validation did not fail the metadata."); @@ -207,7 +223,7 @@ public void ValidateManifests_DuplicateUniqueID_Fails() Mock modB = this.GetMetadata(this.GetManifest(id: "Mod A", name: "Mod B", version: "1.0"), allowStatusChange: true); // act - new ModResolver().ValidateManifests(new[] { modA.Object, modB.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false); + new ModResolver().ValidateManifests(new[] { modA.Object, modB.Object }, apiVersion: new SemanticVersion("1.0.0"), gameVersion: new SemanticVersion("1.0.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false); // assert modA.Verify(p => p.SetStatus(ModMetadataStatus.Failed, ModFailReason.Duplicate, It.IsAny(), It.IsAny()), Times.AtLeastOnce, "The validation did not fail the first mod with a unique ID."); @@ -233,7 +249,7 @@ public void ValidateManifests_Valid_Passes() mock.Setup(p => p.DirectoryPath).Returns(modFolder); // act - new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup); + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0.0"), gameVersion: new SemanticVersion("1.0.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup); // assert // if Moq doesn't throw a method-not-setup exception, the validation didn't override the status. @@ -497,8 +513,9 @@ private IFileLookup GetFileLookup(string rootDirectory) /// The value, or null for a generated value. /// The value. /// The value. + /// The value. /// The value. - private Manifest GetManifest(string? id = null, string? name = null, string? version = null, string? entryDll = null, string? contentPackForID = null, string? minimumApiVersion = null, IManifestDependency[]? dependencies = null) + private Manifest GetManifest(string? id = null, string? name = null, string? version = null, string? entryDll = null, string? contentPackForID = null, string? minimumApiVersion = null, string? minimumGameVersion = null, IManifestDependency[]? dependencies = null) { return new Manifest( uniqueId: id ?? $"{Sample.String()}.{Sample.String()}", @@ -509,6 +526,7 @@ private Manifest GetManifest(string? id = null, string? name = null, string? ver entryDll: entryDll ?? $"{Sample.String()}.dll", contentPackFor: contentPackForID != null ? new ManifestContentPackFor(contentPackForID, null) : null, minimumApiVersion: minimumApiVersion != null ? new SemanticVersion(minimumApiVersion) : null, + minimumGameVersion: minimumGameVersion != null ? new SemanticVersion(minimumGameVersion) : null, dependencies: dependencies ?? Array.Empty(), updateKeys: Array.Empty() ); @@ -561,7 +579,7 @@ private Mock GetMetadata(IManifest manifest, bool allowStatusChang /// Generate a default mod data record. private ModDataRecord GetModDataRecord() { - return new("Default Display Name", new ModDataModel("Sample ID", null, ModWarning.None)); + return new("Default Display Name", new ModDataModel("Sample ID", null, ModWarning.None, false)); } /// Generate a default mod data versioned fields instance. diff --git a/src/SMAPI.Tests/SMAPI.Tests.csproj b/src/SMAPI.Tests/SMAPI.Tests.csproj index ea024f0d8..2e56ad59f 100644 --- a/src/SMAPI.Tests/SMAPI.Tests.csproj +++ b/src/SMAPI.Tests/SMAPI.Tests.csproj @@ -1,6 +1,6 @@  - net5.0 + net6.0 @@ -24,6 +24,7 @@ + diff --git a/src/SMAPI.Tests/Utilities/SDateTests.cs b/src/SMAPI.Tests/Utilities/SDateTests.cs index b9c3d202a..e2ee6238b 100644 --- a/src/SMAPI.Tests/Utilities/SDateTests.cs +++ b/src/SMAPI.Tests/Utilities/SDateTests.cs @@ -60,12 +60,17 @@ private static class Dates [Test(Description = "Assert that the constructor sets the expected values for all valid dates.")] public void Constructor_SetsExpectedValues([ValueSource(nameof(SDateTests.SampleSeasonValues))] string season, [ValueSource(nameof(SDateTests.ValidDays))] int day, [Values(1, 2, 100)] int year) { + // arrange + Season expectedSeason = Enum.Parse(season, ignoreCase: true); + // act SDate date = new(day, season, year); // assert Assert.AreEqual(day, date.Day); - Assert.AreEqual(season.Trim().ToLowerInvariant(), date.Season); + Assert.AreEqual(expectedSeason, date.Season); + Assert.AreEqual((int)expectedSeason, date.SeasonIndex); + Assert.AreEqual(Utility.getSeasonKey(expectedSeason), date.SeasonKey); Assert.AreEqual(year, date.Year); } diff --git a/src/SMAPI.Toolkit.CoreInterfaces/IManifest.cs b/src/SMAPI.Toolkit.CoreInterfaces/IManifest.cs index ee6cc0b6c..a0bb747db 100644 --- a/src/SMAPI.Toolkit.CoreInterfaces/IManifest.cs +++ b/src/SMAPI.Toolkit.CoreInterfaces/IManifest.cs @@ -23,6 +23,9 @@ public interface IManifest /// The minimum SMAPI version required by this mod, if any. ISemanticVersion? MinimumApiVersion { get; } + /// The minimum Stardew Valley version required by this mod, if any. + ISemanticVersion? MinimumGameVersion { get; } + /// The unique mod ID. string UniqueID { get; } diff --git a/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs b/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs index dc226b7c4..555806c6b 100644 --- a/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs +++ b/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs @@ -29,7 +29,7 @@ public interface ISemanticVersion : IComparable, IEquatableWhether this is a prerelease version. -#if NET5_0_OR_GREATER +#if NET6_0_OR_GREATER [MemberNotNullWhen(true, nameof(ISemanticVersion.PrereleaseTag))] #endif bool IsPrerelease(); diff --git a/src/SMAPI.Toolkit.CoreInterfaces/SMAPI.Toolkit.CoreInterfaces.csproj b/src/SMAPI.Toolkit.CoreInterfaces/SMAPI.Toolkit.CoreInterfaces.csproj index 4c92b4dbe..327f8ed96 100644 --- a/src/SMAPI.Toolkit.CoreInterfaces/SMAPI.Toolkit.CoreInterfaces.csproj +++ b/src/SMAPI.Toolkit.CoreInterfaces/SMAPI.Toolkit.CoreInterfaces.csproj @@ -2,7 +2,7 @@ StardewModdingAPI Provides toolkit interfaces which are available to SMAPI mods. - net5.0; netstandard2.0 + net6.0; netstandard2.0 true diff --git a/src/SMAPI.Toolkit/Framework/Clients/NexusExport/INexusExportApiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/NexusExport/INexusExportApiClient.cs new file mode 100644 index 000000000..0e1007b6f --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/Clients/NexusExport/INexusExportApiClient.cs @@ -0,0 +1,13 @@ +using System; +using System.Threading.Tasks; +using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.NexusExport +{ + /// An HTTP client for fetching the mod export from the Nexus Mods export API. + public interface INexusExportApiClient : IDisposable + { + /// Fetch the latest export file from the Nexus Mods export API. + public Task FetchExportAsync(); + } +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/NexusExport/NexusExportApiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/NexusExport/NexusExportApiClient.cs new file mode 100644 index 000000000..e3d235ac5 --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/Clients/NexusExport/NexusExportApiClient.cs @@ -0,0 +1,42 @@ +using System.Threading.Tasks; +using Pathoschild.Http.Client; +using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.NexusExport +{ + /// + public class NexusExportApiClient : INexusExportApiClient + { + /********* + ** Fields + *********/ + /// The underlying HTTP client. + private readonly IClient Client; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The user agent for the Nexus export API. + /// The base URL for the Nexus export API. + public NexusExportApiClient(string userAgent, string baseUrl) + { + this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); + } + + /// + public async Task FetchExportAsync() + { + return await this.Client + .GetAsync("") + .As(); + } + + /// + public void Dispose() + { + this.Client.Dispose(); + } + } +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/NexusExport/ResponseModels/NexusFileExport.cs b/src/SMAPI.Toolkit/Framework/Clients/NexusExport/ResponseModels/NexusFileExport.cs new file mode 100644 index 000000000..d35721e8d --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/Clients/NexusExport/ResponseModels/NexusFileExport.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Newtonsoft.Json; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels +{ + /// The metadata for an uploaded file for a mod from the Nexus Mods export API. + public class NexusFileExport + { + /// The unique internal file identifier. + public long Uid { get; set; } + + /// The file's display name. + public string? Name { get; set; } + + /// The file's display description. + public string? Description { get; set; } + + /// The file name that will be downloaded. + [JsonProperty("uri")] + public string? FileName { get; set; } + + /// The file's semantic version. + public string? Version { get; set; } + + /// The file category ID. + [JsonProperty("category_id")] + public uint CategoryId { get; set; } + + /// Whether this is the main Vortex file. + public bool Primary { get; set; } + + /// The file's size in bytes. + [JsonProperty("size_in_byes")] + public long? SizeInBytes { get; set; } + + /// When the file was uploaded. + [JsonProperty("uploaded_at")] + public long UploadedAt { get; set; } + + /// The extra fields returned by the export API, if any. + [JsonExtensionData] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Used to track any new data provided by the API.")] + public Dictionary? OtherFields; + } +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/NexusExport/ResponseModels/NexusFullExport.cs b/src/SMAPI.Toolkit/Framework/Clients/NexusExport/ResponseModels/NexusFullExport.cs new file mode 100644 index 000000000..89ec9e81c --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/Clients/NexusExport/ResponseModels/NexusFullExport.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels +{ + /// The metadata for all Stardew Valley from the Nexus Mods export API. + public class NexusFullExport + { + /// The mod data indexed by public mod ID. + public Dictionary Data { get; set; } = new(); + + /// When this export was last updated. + [JsonProperty("last_updated")] + public DateTimeOffset LastUpdated { get; set; } + } +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/NexusExport/ResponseModels/NexusModExport.cs b/src/SMAPI.Toolkit/Framework/Clients/NexusExport/ResponseModels/NexusModExport.cs new file mode 100644 index 000000000..786838c3c --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/Clients/NexusExport/ResponseModels/NexusModExport.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Newtonsoft.Json; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels +{ + /// The metadata for a mod from the Nexus Mods export API. + public class NexusModExport + { + /// The unique internal mod identifier (not the public mod ID). + public long Uid { get; set; } + + /// The mod's display name. + public string? Name { get; set; } + + /// The author display name set for the mod. + public string? Author { get; set; } + + /// The username for the user who uploaded the mod. + public string? Uploader { get; set; } + + /// The ID for the user who uploaded the mod. + [JsonProperty("uploader_id")] + public int UploaderId { get; set; } + + /// The mod's semantic version. + public string? Version { get; set; } + + /// The category ID. + [JsonProperty("category_id")] + public int CategoryId { get; set; } + + /// Whether the mod is published by the author. + public bool Published { get; set; } + + /// Whether the mod is hidden by moderators. + public bool Moderated { get; set; } + + /// Whether the mod page is visible to users. + [JsonProperty("allow_view")] + public bool AllowView { get; set; } + + /// Whether the mod is marked as containing adult content. + public bool Adult { get; set; } + + /// The files uploaded for the mod. + public Dictionary Files { get; set; } = new(); + + /// The extra fields returned by the export API, if any. + [JsonExtensionData] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Used to track any new data provided by the API.")] + public Dictionary? OtherFields; + } +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs index a2497dea7..f4f62b4c1 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs @@ -48,7 +48,7 @@ public ChangeDescriptor(ISet add, ISet remove, IReadOnlyDictiona /// Apply the change descriptors to a comma-delimited field. /// The raw field text. /// Returns the modified field. -#if NET5_0_OR_GREATER +#if NET6_0_OR_GREATER [return: NotNullIfNotNull("rawField")] #endif public string? ApplyToCopy(string? rawField) diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs index fc50125fd..586f4b671 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs @@ -51,7 +51,7 @@ public class WikiModEntry public WikiCompatibilityInfo? BetaCompatibility { get; } /// Whether a Stardew Valley or SMAPI beta which affects mod compatibility is in progress. If this is true, should be used for beta versions of SMAPI instead of . -#if NET5_0_OR_GREATER +#if NET6_0_OR_GREATER [MemberNotNullWhen(true, nameof(WikiModEntry.BetaCompatibility))] #endif public bool HasBetaInfo => this.BetaCompatibility != null; diff --git a/src/SMAPI.Toolkit/Framework/GameScanning/GameFolderType.cs b/src/SMAPI.Toolkit/Framework/GameScanning/GameFolderType.cs index d18af59be..5a049cc9e 100644 --- a/src/SMAPI.Toolkit/Framework/GameScanning/GameFolderType.cs +++ b/src/SMAPI.Toolkit/Framework/GameScanning/GameFolderType.cs @@ -9,10 +9,10 @@ public enum GameFolderType /// The folder doesn't contain Stardew Valley. NoGameFound, - /// The folder contains Stardew Valley 1.5.4 or earlier. This version uses XNA Framework and 32-bit .NET Framework 4.5.2 on Windows and Mono on Linux/macOS, and isn't compatible with current versions of SMAPI. - Legacy154OrEarlier, + /// The folder contains Stardew Valley 1.5.6 or earlier, which isn't compatible with current versions of SMAPI. + LegacyVersion, - /// The folder contains Stardew Valley from the game's legacy compatibility branch, which backports newer changes to the format. + /// The folder contains Stardew Valley from the game's legacy compatibility branch, which backports newer changes to the format. LegacyCompatibilityBranch, /// The folder seems to contain Stardew Valley files, but they failed to load for unknown reasons (e.g. corrupted executable). diff --git a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs index 45f3e203f..6e706cc50 100644 --- a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs +++ b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs @@ -40,6 +40,17 @@ public GameScanner() /// Find all valid Stardew Valley install folders. /// This checks default game locations, and on Windows checks the Windows registry for GOG/Steam install data. A folder is considered 'valid' if it contains the Stardew Valley executable for the current OS. public IEnumerable Scan() + { + foreach ((DirectoryInfo folder, GameFolderType type) in this.ScanIncludingInvalid()) + { + if (type is GameFolderType.Valid) + yield return folder; + } + } + + /// Find all valid Stardew Valley install folders. + /// This checks default game locations, and on Windows checks the Windows registry for GOG/Steam install data. A folder is considered 'valid' if it contains the Stardew Valley executable for the current OS. + public IEnumerable<(DirectoryInfo, GameFolderType)> ScanIncludingInvalid() { // get install paths IEnumerable paths = this @@ -52,18 +63,11 @@ public IEnumerable Scan() foreach (string path in paths) { DirectoryInfo folder = new(path); - if (this.LooksLikeGameFolder(folder)) - yield return folder; + if (folder.Exists) + yield return (folder, this.GetGameFolderType(folder)); } } - /// Get whether a folder seems to contain the game. - /// The folder to check. - public bool LooksLikeGameFolder(DirectoryInfo dir) - { - return this.GetGameFolderType(dir) == GameFolderType.Valid; - } - /// Detect the validity of a game folder based on file structure heuristics. /// The folder to check. public GameFolderType GetGameFolderType(DirectoryInfo dir) @@ -72,49 +76,42 @@ public GameFolderType GetGameFolderType(DirectoryInfo dir) if (!dir.Exists) return GameFolderType.NoGameFound; - // apparently valid - if (File.Exists(Path.Combine(dir.FullName, "Stardew Valley.dll"))) - return GameFolderType.Valid; - - // doesn't contain any version of Stardew Valley - FileInfo executable = new(Path.Combine(dir.FullName, "Stardew Valley.exe")); - if (!executable.Exists) - executable = new(Path.Combine(dir.FullName, "StardewValley.exe")); // pre-1.5.5 Linux/macOS executable - if (!executable.Exists) - return GameFolderType.NoGameFound; - - // get assembly version - Version? version; - try - { - version = AssemblyName.GetAssemblyName(executable.FullName).Version; - if (version == null) - return GameFolderType.InvalidUnknown; - } - catch + // invalid folder + if (!File.Exists(Path.Combine(dir.FullName, "Stardew Valley.dll"))) { - // The executable exists but it doesn't seem to be a valid assembly. This would - // happen with Stardew Valley 1.5.5+, but that should have been flagged as a valid - // folder before this point. - return GameFolderType.InvalidUnknown; - } + // get executable + FileInfo executable = new(Path.Combine(dir.FullName, "Stardew Valley.exe")); + if (!executable.Exists) + executable = new(Path.Combine(dir.FullName, "StardewValley.exe")); // pre-1.5.5 Linux/macOS executable + if (!executable.Exists) + return GameFolderType.NoGameFound; + + // get assembly version + Version? version; + try + { + version = AssemblyName.GetAssemblyName(executable.FullName).Version; + if (version == null) + return GameFolderType.InvalidUnknown; + } + catch + { + return GameFolderType.InvalidUnknown; // executable exists, but it doesn't seem to be a valid assembly + } - // ignore Stardew Valley 1.5.5+ at this point - if (version.Major == 1 && version.Minor == 3 && version.Build == 37) - return GameFolderType.InvalidUnknown; + // legacy version that's no longer supported + if (version.Major < 1 || version is { Major: 1, Minor: < 6 }) + return GameFolderType.LegacyVersion; - // incompatible version - if (version.Major == 1 && version.Minor < 4) - { - // Stardew Valley 1.5.4 and earlier have assembly versions <= 1.3.7853.31734 - if (version.Minor < 3 || version.Build <= 7853) - return GameFolderType.Legacy154OrEarlier; + // compatibility branch + if (!File.Exists(Path.Combine(dir.FullName, "MonoGame.Framework.dll"))) + return GameFolderType.LegacyCompatibilityBranch; - // Stardew Valley 1.5.5+ legacy compatibility branch - return GameFolderType.LegacyCompatibilityBranch; + return GameFolderType.InvalidUnknown; } - return GameFolderType.InvalidUnknown; + // apparently valid + return GameFolderType.Valid; } /********* diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDataModel.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDataModel.cs index 5912fb870..d21e87643 100644 --- a/src/SMAPI.Toolkit/Framework/ModData/ModDataModel.cs +++ b/src/SMAPI.Toolkit/Framework/ModData/ModDataModel.cs @@ -28,6 +28,9 @@ internal class ModDataModel /// The mod warnings to suppress, even if they'd normally be shown. public ModWarning SuppressWarnings { get; } + /// Whether to ignore dependencies on this mod ID when it's not loaded. + public bool IgnoreDependencies { get; set; } + /// This field stores properties that aren't mapped to another field before they're parsed into . [JsonExtensionData] public IDictionary ExtensionData { get; } = new Dictionary(); @@ -54,11 +57,13 @@ internal class ModDataModel /// The mod's current unique ID. /// The former mod IDs (if any). /// The mod warnings to suppress, even if they'd normally be shown. - public ModDataModel(string id, string? formerIds, ModWarning suppressWarnings) + /// Whether to ignore dependencies on this mod ID when it's not loaded. + public ModDataModel(string id, string? formerIds, ModWarning suppressWarnings, bool ignoreDependencies) { this.ID = id; this.FormerIDs = formerIds; this.SuppressWarnings = suppressWarnings; + this.IgnoreDependencies = ignoreDependencies; } /// Get a parsed representation of the . diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDataRecord.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDataRecord.cs index ab0e43775..938e9e5a6 100644 --- a/src/SMAPI.Toolkit/Framework/ModData/ModDataRecord.cs +++ b/src/SMAPI.Toolkit/Framework/ModData/ModDataRecord.cs @@ -22,6 +22,9 @@ public class ModDataRecord /// The mod warnings to suppress, even if they'd normally be shown. public ModWarning SuppressWarnings { get; } + /// Whether to ignore dependencies on this mod ID when it's not loaded. + public bool IgnoreDependencies { get; set; } + /// The versioned field data. public ModDataField[] Fields { get; } @@ -38,6 +41,7 @@ internal ModDataRecord(string displayName, ModDataModel model) this.ID = model.ID; this.FormerIDs = model.GetFormerIDs().ToArray(); this.SuppressWarnings = model.SuppressWarnings; + this.IgnoreDependencies = model.IgnoreDependencies; this.Fields = model.GetFields().ToArray(); } diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModWarning.cs b/src/SMAPI.Toolkit/Framework/ModData/ModWarning.cs index 4c76f4171..b3082fa2c 100644 --- a/src/SMAPI.Toolkit/Framework/ModData/ModWarning.cs +++ b/src/SMAPI.Toolkit/Framework/ModData/ModWarning.cs @@ -18,36 +18,19 @@ public enum ModWarning /// The mod patches the game in a way that may impact stability. PatchesGame = 4, -#if SMAPI_DEPRECATED - /// The mod uses the dynamic keyword which won't work on Linux/macOS. - [Obsolete("This value is no longer used by SMAPI and will be removed in the upcoming SMAPI 4.0.0.")] - UsesDynamic = 8, -#endif - /// The mod references specialized 'unvalidated update tick' events which may impact stability. - UsesUnvalidatedUpdateTick = 16, + UsesUnvalidatedUpdateTick = 8, /// The mod has no update keys set. - NoUpdateKeys = 32, + NoUpdateKeys = 16, /// Uses .NET APIs for reading and writing to the console. - AccessesConsole = 64, + AccessesConsole = 32, /// Uses .NET APIs for filesystem access. - AccessesFilesystem = 128, + AccessesFilesystem = 64, /// Uses .NET APIs for shell or process access. - AccessesShell = 256, - -#if SMAPI_DEPRECATED - /// References the legacy System.Configuration.ConfigurationManager assembly and doesn't include a copy in the mod folder, so it'll break in SMAPI 4.0.0. - DetectedLegacyConfigurationDll = 512, - - /// References the legacy System.Runtime.Caching assembly and doesn't include a copy in the mod folder, so it'll break in SMAPI 4.0.0. - DetectedLegacyCachingDll = 1024, - - /// References the legacy System.Security.Permissions assembly and doesn't include a copy in the mod folder, so it'll break in SMAPI 4.0.0. - DetectedLegacyPermissionsDll = 2048 -#endif + AccessesShell = 128 } } diff --git a/src/SMAPI.Toolkit/Framework/SemanticVersionReader.cs b/src/SMAPI.Toolkit/Framework/SemanticVersionReader.cs index 939be7715..fd17820b0 100644 --- a/src/SMAPI.Toolkit/Framework/SemanticVersionReader.cs +++ b/src/SMAPI.Toolkit/Framework/SemanticVersionReader.cs @@ -106,7 +106,7 @@ private static bool TryParseLiteral(char[] raw, ref int index, char ch) /// The index of the next character to read. /// The parsed tag. private static bool TryParseTag(char[] raw, ref int index, -#if NET5_0_OR_GREATER +#if NET6_0_OR_GREATER [NotNullWhen(true)] #endif out string? tag diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs index 47cd3f7e4..195b03671 100644 --- a/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs +++ b/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs @@ -19,6 +19,9 @@ public enum ModSiteKey ModDrop, /// The Nexus Mods mod repository. - Nexus + Nexus, + + /// An arbitrary URL to a JSON file containing update data. + UpdateManifest } } diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs index 960caf96e..6cf1c6d09 100644 --- a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs +++ b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs @@ -22,7 +22,7 @@ public class UpdateKey : IEquatable public string? Subkey { get; } /// Whether the update key seems to be valid. -#if NET5_0_OR_GREATER +#if NET6_0_OR_GREATER [MemberNotNullWhen(true, nameof(UpdateKey.ID))] #endif public bool LooksValid { get; } @@ -58,31 +58,17 @@ public UpdateKey(ModSiteKey site, string? id, string? subkey) /// The raw update key to parse. public static UpdateKey Parse(string? raw) { + if (raw is null) + return new UpdateKey(raw, ModSiteKey.Unknown, null, null); // extract site + ID - string? rawSite; - string? id; - { - string[]? parts = raw?.Trim().Split(':'); - if (parts?.Length != 2) - return new UpdateKey(raw, ModSiteKey.Unknown, null, null); - - rawSite = parts[0].Trim(); - id = parts[1].Trim(); - } - if (string.IsNullOrWhiteSpace(id)) + (string rawSite, string? id) = UpdateKey.SplitTwoParts(raw, ':'); + if (string.IsNullOrEmpty(id)) id = null; // extract subkey string? subkey = null; if (id != null) - { - string[] parts = id.Split('@'); - if (parts.Length == 2) - { - id = parts[0].Trim(); - subkey = $"@{parts[1]}".Trim(); - } - } + (id, subkey) = UpdateKey.SplitTwoParts(id, '@', true); // parse if (!Enum.TryParse(rawSite, true, out ModSiteKey site)) @@ -151,5 +137,23 @@ public static string GetString(ModSiteKey site, string? id, string? subkey = nul { return $"{site}:{id}{subkey}".Trim(); } + + + /********* + ** Private methods + *********/ + /// Split a string into two parts at a delimiter and trim whitespace. + /// The string to split. + /// The character on which to split. + /// Whether to include the delimiter in the second string. + /// Returns a tuple containing the two strings, with the second value null if the delimiter wasn't found. + private static (string, string?) SplitTwoParts(string str, char delimiter, bool keepDelimiter = false) + { + int splitIndex = str.IndexOf(delimiter); + + return splitIndex >= 0 + ? (str.Substring(0, splitIndex).Trim(), str.Substring(splitIndex + (keepDelimiter ? 0 : 1)).Trim()) + : (str.Trim(), null); + } } } diff --git a/src/SMAPI.Toolkit/ModToolkit.cs b/src/SMAPI.Toolkit/ModToolkit.cs index 55b9bdd8a..1d61ec196 100644 --- a/src/SMAPI.Toolkit/ModToolkit.cs +++ b/src/SMAPI.Toolkit/ModToolkit.cs @@ -54,6 +54,13 @@ public IEnumerable GetGameFolders() return new GameScanner().Scan(); } + /// Find all default Stardew Valley install folders which exist, regardless of whether they're valid. + /// This checks default game locations, and on Windows checks the Windows registry for GOG/Steam install data. + public IEnumerable<(DirectoryInfo, GameFolderType)> GetGameFoldersIncludingInvalid() + { + return new GameScanner().ScanIncludingInvalid(); + } + /// Extract mod metadata from the wiki compatibility list. public async Task GetWikiCompatibilityListAsync() { diff --git a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj index a915957e3..7fc1f30bc 100644 --- a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj +++ b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj @@ -2,7 +2,7 @@ StardewModdingAPI.Toolkit A library which encapsulates mod-handling logic for mod managers and tools. Not intended for use by mods. - net5.0; netstandard2.0 + net6.0; netstandard2.0 true diff --git a/src/SMAPI.Toolkit/SemanticVersion.cs b/src/SMAPI.Toolkit/SemanticVersion.cs index 3713758f8..19861cca0 100644 --- a/src/SMAPI.Toolkit/SemanticVersion.cs +++ b/src/SMAPI.Toolkit/SemanticVersion.cs @@ -119,7 +119,7 @@ public bool Equals(ISemanticVersion? other) } /// -#if NET5_0_OR_GREATER +#if NET6_0_OR_GREATER [MemberNotNullWhen(true, nameof(SemanticVersion.PrereleaseTag))] #endif public bool IsPrerelease() @@ -202,7 +202,7 @@ public bool IsNonStandard() /// The parsed representation. /// Returns whether parsing the version succeeded. public static bool TryParse(string? version, -#if NET5_0_OR_GREATER +#if NET6_0_OR_GREATER [NotNullWhen(true)] #endif out ISemanticVersion? parsed @@ -217,7 +217,7 @@ out ISemanticVersion? parsed /// The parsed representation. /// Returns whether parsing the version succeeded. public static bool TryParse(string? version, bool allowNonStandard, -#if NET5_0_OR_GREATER +#if NET6_0_OR_GREATER [NotNullWhen(true)] #endif out ISemanticVersion? parsed diff --git a/src/SMAPI.Toolkit/Serialization/JsonHelper.cs b/src/SMAPI.Toolkit/Serialization/JsonHelper.cs index a5d7e2e89..208cd6567 100644 --- a/src/SMAPI.Toolkit/Serialization/JsonHelper.cs +++ b/src/SMAPI.Toolkit/Serialization/JsonHelper.cs @@ -44,7 +44,7 @@ public static JsonSerializerSettings CreateDefaultSettings() /// The given is empty or invalid. /// The file contains invalid JSON. public bool ReadJsonFileIfExists(string fullPath, -#if NET5_0_OR_GREATER +#if NET6_0_OR_GREATER [NotNullWhen(true)] #endif out TModel? result diff --git a/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs b/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs index 8a449f0a7..63bbc5d2c 100644 --- a/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs +++ b/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs @@ -7,45 +7,48 @@ namespace StardewModdingAPI.Toolkit.Serialization.Models { - /// A manifest which describes a mod for SMAPI. + /// public class Manifest : IManifest { /********* ** Accessors *********/ - /// The mod name. + /// public string Name { get; } - /// A brief description of the mod. + /// public string Description { get; } - /// The mod author's name. + /// public string Author { get; } - /// The mod version. + /// public ISemanticVersion Version { get; } - /// The minimum SMAPI version required by this mod, if any. + /// public ISemanticVersion? MinimumApiVersion { get; } - /// The name of the DLL in the directory that has the Entry method. Mutually exclusive with . + /// + public ISemanticVersion? MinimumGameVersion { get; } + + /// public string? EntryDll { get; } - /// The mod which will read this as a content pack. Mutually exclusive with . + /// [JsonConverter(typeof(ManifestContentPackForConverter))] public IManifestContentPackFor? ContentPackFor { get; } - /// The other mods that must be loaded before this mod. + /// [JsonConverter(typeof(ManifestDependencyArrayConverter))] public IManifestDependency[] Dependencies { get; } - /// The namespaced mod IDs to query for updates (like Nexus:541). + /// public string[] UpdateKeys { get; private set; } - /// The unique mod ID. + /// public string UniqueID { get; } - /// Any manifest fields which didn't match a valid field. + /// [JsonExtensionData] public IDictionary ExtraFields { get; } = new Dictionary(); @@ -68,6 +71,7 @@ public Manifest(string uniqueID, string name, string author, string description, description: description, version: version, minimumApiVersion: null, + minimumGameVersion: null, entryDll: null, contentPackFor: contentPackFor != null ? new ManifestContentPackFor(contentPackFor, null) @@ -84,12 +88,13 @@ public Manifest(string uniqueID, string name, string author, string description, /// A brief description of the mod. /// The mod version. /// The minimum SMAPI version required by this mod, if any. + /// The minimum Stardew Valley version required by this mod, if any. /// The name of the DLL in the directory that has the Entry method. Mutually exclusive with . /// The modID which will read this as a content pack. /// The other mods that must be loaded before this mod. /// The namespaced mod IDs to query for updates (like Nexus:541). [JsonConstructor] - public Manifest(string uniqueId, string name, string author, string description, ISemanticVersion version, ISemanticVersion? minimumApiVersion, string? entryDll, IManifestContentPackFor? contentPackFor, IManifestDependency[]? dependencies, string[]? updateKeys) + public Manifest(string uniqueId, string name, string author, string description, ISemanticVersion version, ISemanticVersion? minimumApiVersion, ISemanticVersion? minimumGameVersion, string? entryDll, IManifestContentPackFor? contentPackFor, IManifestDependency[]? dependencies, string[]? updateKeys) { this.UniqueID = this.NormalizeField(uniqueId); this.Name = this.NormalizeField(name, replaceSquareBrackets: true); @@ -97,6 +102,7 @@ public Manifest(string uniqueId, string name, string author, string description, this.Description = this.NormalizeField(description); this.Version = version; this.MinimumApiVersion = minimumApiVersion; + this.MinimumGameVersion = minimumGameVersion; this.EntryDll = this.NormalizeField(entryDll); this.ContentPackFor = contentPackFor; this.Dependencies = dependencies ?? Array.Empty(); @@ -117,7 +123,7 @@ internal void OverrideUpdateKeys(params string[] updateKeys) /// Normalize a manifest field to strip newlines, trim whitespace, and optionally strip square brackets. /// The input to strip. /// Whether to replace square brackets with round ones. This is used in the mod name to avoid breaking the log format. -#if NET5_0_OR_GREATER +#if NET6_0_OR_GREATER [return: NotNullIfNotNull("input")] #endif private string? NormalizeField(string? input, bool replaceSquareBrackets = false) diff --git a/src/SMAPI.Toolkit/Serialization/Models/ManifestContentPackFor.cs b/src/SMAPI.Toolkit/Serialization/Models/ManifestContentPackFor.cs index f7dc8aa80..fe425d3c5 100644 --- a/src/SMAPI.Toolkit/Serialization/Models/ManifestContentPackFor.cs +++ b/src/SMAPI.Toolkit/Serialization/Models/ManifestContentPackFor.cs @@ -33,7 +33,7 @@ public ManifestContentPackFor(string uniqueId, ISemanticVersion? minimumVersion) *********/ /// Normalize whitespace in a raw string. /// The input to strip. -#if NET5_0_OR_GREATER +#if NET6_0_OR_GREATER [return: NotNullIfNotNull("input")] #endif private string? NormalizeWhitespace(string? input) diff --git a/src/SMAPI.Toolkit/Serialization/Models/ManifestDependency.cs b/src/SMAPI.Toolkit/Serialization/Models/ManifestDependency.cs index fa254ea7c..9d108a789 100644 --- a/src/SMAPI.Toolkit/Serialization/Models/ManifestDependency.cs +++ b/src/SMAPI.Toolkit/Serialization/Models/ManifestDependency.cs @@ -54,7 +54,7 @@ public ManifestDependency(string uniqueID, ISemanticVersion? minimumVersion, boo *********/ /// Normalize whitespace in a raw string. /// The input to strip. -#if NET5_0_OR_GREATER +#if NET6_0_OR_GREATER [return: NotNullIfNotNull("input")] #endif private string? NormalizeWhitespace(string? input) diff --git a/src/SMAPI.Toolkit/Utilities/PathUtilities.cs b/src/SMAPI.Toolkit/Utilities/PathUtilities.cs index 15c24fcaf..d544e4a63 100644 --- a/src/SMAPI.Toolkit/Utilities/PathUtilities.cs +++ b/src/SMAPI.Toolkit/Utilities/PathUtilities.cs @@ -50,7 +50,7 @@ public static string[] GetSegments(string? path, int? limit = null) /// Normalize an asset name to match how MonoGame's content APIs would normalize and cache it. /// The asset name to normalize. [Pure] -#if NET5_0_OR_GREATER +#if NET6_0_OR_GREATER [return: NotNullIfNotNull("assetName")] #endif public static string? NormalizeAssetName(string? assetName) @@ -66,7 +66,7 @@ public static string[] GetSegments(string? path, int? limit = null) /// The file path to normalize. /// This should only be used for file paths. For asset names, use instead. [Pure] -#if NET5_0_OR_GREATER +#if NET6_0_OR_GREATER [return: NotNullIfNotNull("path")] #endif public static string? NormalizePath(string? path) @@ -108,7 +108,7 @@ public static string[] GetSegments(string? path, int? limit = null) [Pure] public static string GetRelativePath(string sourceDir, string targetPath) { -#if NET5_0 +#if NET6_0_OR_GREATER return Path.GetRelativePath(sourceDir, targetPath); #else // NOTE: diff --git a/src/SMAPI.Web/BackgroundService.cs b/src/SMAPI.Web/BackgroundService.cs index 49356f761..d876e9428 100644 --- a/src/SMAPI.Web/BackgroundService.cs +++ b/src/SMAPI.Web/BackgroundService.cs @@ -4,10 +4,16 @@ using System.Threading.Tasks; using Hangfire; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport; +using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; using StardewModdingAPI.Web.Framework.Caching.Mods; +using StardewModdingAPI.Web.Framework.Caching.NexusExport; using StardewModdingAPI.Web.Framework.Caching.Wiki; +using StardewModdingAPI.Web.Framework.Clients.Nexus; +using StardewModdingAPI.Web.Framework.ConfigModels; namespace StardewModdingAPI.Web { @@ -27,10 +33,22 @@ internal class BackgroundService : IHostedService, IDisposable /// The cache in which to store mod data. private static IModCacheRepository? ModCache; + /// The cache in which to store mod data from the Nexus export API. + private static INexusExportCacheRepository? NexusExportCache; + + /// The HTTP client for fetching the mod export from the Nexus Mods export API. + private static INexusExportApiClient? NexusExportApiClient; + + /// The config settings for mod update checks. + private static IOptions? UpdateCheckConfig; + /// Whether the service has been started. - [MemberNotNullWhen(true, nameof(BackgroundService.JobServer), nameof(BackgroundService.WikiCache), nameof(BackgroundService.ModCache))] + [MemberNotNullWhen(true, nameof(BackgroundService.JobServer), nameof(BackgroundService.ModCache), nameof(NexusExportApiClient), nameof(NexusExportCache), nameof(BackgroundService.UpdateCheckConfig), nameof(BackgroundService.WikiCache))] private static bool IsStarted { get; set; } + /// The number of minutes the Nexus export should be considered valid based on its last-updated date before it's ignored. + private static int NexusExportStaleAge => (BackgroundService.UpdateCheckConfig?.Value.SuccessCacheMinutes ?? 0) + 10; + /********* ** Public methods @@ -41,12 +59,20 @@ internal class BackgroundService : IHostedService, IDisposable /// Construct an instance. /// The cache in which to store wiki metadata. /// The cache in which to store mod data. + /// The cache in which to store mod data from the Nexus export API. + /// The HTTP client for fetching the mod export from the Nexus Mods export API. /// The Hangfire storage implementation. + /// The config settings for mod update checks. [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The Hangfire reference forces it to initialize first, since it's needed by the background service.")] - public BackgroundService(IWikiCacheRepository wikiCache, IModCacheRepository modCache, JobStorage hangfireStorage) + public BackgroundService(IWikiCacheRepository wikiCache, IModCacheRepository modCache, INexusExportCacheRepository nexusExportCache, INexusExportApiClient nexusExportApiClient, JobStorage hangfireStorage, IOptions updateCheckConfig) { BackgroundService.WikiCache = wikiCache; BackgroundService.ModCache = modCache; + BackgroundService.NexusExportCache = nexusExportCache; + BackgroundService.NexusExportApiClient = nexusExportApiClient; + BackgroundService.UpdateCheckConfig = updateCheckConfig; + + _ = hangfireStorage; // this parameter is only received so it's initialized before the background service } /// Start the service. @@ -55,13 +81,19 @@ public Task StartAsync(CancellationToken cancellationToken) { this.TryInit(); + bool enableNexusExport = BackgroundService.NexusExportApiClient is not DisabledNexusExportApiClient; + // set startup tasks BackgroundJob.Enqueue(() => BackgroundService.UpdateWikiAsync()); + if (enableNexusExport) + BackgroundJob.Enqueue(() => BackgroundService.UpdateNexusExportAsync()); BackgroundJob.Enqueue(() => BackgroundService.RemoveStaleModsAsync()); // set recurring tasks - RecurringJob.AddOrUpdate(() => BackgroundService.UpdateWikiAsync(), "*/10 * * * *"); // every 10 minutes - RecurringJob.AddOrUpdate(() => BackgroundService.RemoveStaleModsAsync(), "0 * * * *"); // hourly + RecurringJob.AddOrUpdate("update wiki data", () => BackgroundService.UpdateWikiAsync(), "*/10 * * * *"); // every 10 minutes + if (enableNexusExport) + RecurringJob.AddOrUpdate("update Nexus export", () => BackgroundService.UpdateNexusExportAsync(), "*/10 * * * *"); + RecurringJob.AddOrUpdate("remove stale mods", () => BackgroundService.RemoveStaleModsAsync(), "2/10 * * * *"); // offset by 2 minutes so it runs after updates (e.g. 00:02, 00:12, etc) BackgroundService.IsStarted = true; @@ -100,13 +132,34 @@ public static async Task UpdateWikiAsync() BackgroundService.WikiCache.SaveWikiData(wikiCompatList.StableVersion, wikiCompatList.BetaVersion, wikiCompatList.Mods); } + /// Update the cached Nexus mod dump. + [AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 30, 60, 120 })] + public static async Task UpdateNexusExportAsync() + { + if (!BackgroundService.IsStarted) + throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks."); + + NexusFullExport data = await BackgroundService.NexusExportApiClient.FetchExportAsync(); + + var cache = BackgroundService.NexusExportCache; + cache.SetData(data); + if (cache.IsStale(BackgroundService.NexusExportStaleAge)) + cache.SetData(null); // if the export is too old, fetch fresh mod data from the site/API instead + } + /// Remove mods which haven't been requested in over 48 hours. public static Task RemoveStaleModsAsync() { if (!BackgroundService.IsStarted) throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks."); + // remove mods in mod cache BackgroundService.ModCache.RemoveStaleMods(TimeSpan.FromHours(48)); + + // remove stale export cache + if (BackgroundService.NexusExportCache.IsStale(BackgroundService.NexusExportStaleAge)) + BackgroundService.NexusExportCache.SetData(null); + return Task.CompletedTask; } diff --git a/src/SMAPI.Web/Controllers/IndexController.cs b/src/SMAPI.Web/Controllers/IndexController.cs index 522d77cd2..bea118874 100644 --- a/src/SMAPI.Web/Controllers/IndexController.cs +++ b/src/SMAPI.Web/Controllers/IndexController.cs @@ -62,8 +62,8 @@ public async Task Index() // render view IndexVersionModel stableVersionModel = stableVersion != null - ? new IndexVersionModel(stableVersion.Version.ToString(), stableVersion.Release.Body, stableVersion.Asset.DownloadUrl, stableVersionForDevs?.Asset.DownloadUrl) - : new IndexVersionModel("unknown", "", "https://github.com/Pathoschild/SMAPI/releases", null); // just in case something goes wrong + ? new IndexVersionModel(stableVersion.Version.ToString(), stableVersion.Release.Body, stableVersion.Release.WebUrl, stableVersion.Asset.DownloadUrl, stableVersionForDevs?.Asset.DownloadUrl) + : new IndexVersionModel("unknown", "", "https://github.com/Pathoschild/SMAPI/releases", "https://github.com/Pathoschild/SMAPI/releases", null); // just in case something goes wrong // render view var model = new IndexModel(stableVersionModel, this.SiteConfig.OtherBlurb, this.SiteConfig.SupporterList); diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index 71fb42c24..f56c1102f 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -22,7 +22,9 @@ using StardewModdingAPI.Web.Framework.Clients.GitHub; using StardewModdingAPI.Web.Framework.Clients.ModDrop; using StardewModdingAPI.Web.Framework.Clients.Nexus; +using StardewModdingAPI.Web.Framework.Clients.UpdateManifest; using StardewModdingAPI.Web.Framework.ConfigModels; +using StardewModdingAPI.Web.Framework.Metrics; namespace StardewModdingAPI.Web.Controllers { @@ -63,14 +65,15 @@ internal class ModsApiController : Controller /// The GitHub API client. /// The ModDrop API client. /// The Nexus API client. - public ModsApiController(IWebHostEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions config, IChucklefishClient chucklefish, ICurseForgeClient curseForge, IGitHubClient github, IModDropClient modDrop, INexusClient nexus) + /// The API client for arbitrary update manifest URLs. + public ModsApiController(IWebHostEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions config, IChucklefishClient chucklefish, ICurseForgeClient curseForge, IGitHubClient github, IModDropClient modDrop, INexusClient nexus, IUpdateManifestClient updateManifest) { this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "SMAPI.metadata.json")); this.WikiCache = wikiCache; this.ModCache = modCache; this.Config = config; - this.ModSites = new ModSiteManager(new IModSiteClient[] { chucklefish, curseForge, github, modDrop, nexus }); + this.ModSites = new ModSiteManager(new IModSiteClient[] { chucklefish, curseForge, github, modDrop, nexus, updateManifest }); } /// Fetch version metadata for the given mods. @@ -79,6 +82,9 @@ public ModsApiController(IWebHostEnvironment environment, IWikiCacheRepository w [HttpPost] public async Task> PostAsync([FromBody] ModSearchModel? model, [FromRoute] string version) { + ApiMetricsModel metrics = MetricsManager.GetMetricsForNow(); + metrics.TrackRequest(); + if (model?.Mods == null) return Array.Empty(); @@ -97,7 +103,7 @@ public async Task> PostAsync([FromBody] ModSearchMode mod.AddUpdateKeys(config.SmapiInfo.AddBetaUpdateKeys); // fetch result - ModEntryModel result = await this.GetModData(mod, wikiData, model.IncludeExtendedMetadata, model.ApiVersion); + ModEntryModel result = await this.GetModData(mod, wikiData, model.IncludeExtendedMetadata, model.ApiVersion, metrics); if (!model.IncludeExtendedMetadata && (model.ApiVersion == null || mod.InstalledVersion == null)) { result.Errors = result.Errors @@ -112,6 +118,13 @@ public async Task> PostAsync([FromBody] ModSearchMode return mods.Values; } + /// Fetch a summary of update-check metrics since the server was last deployed or restarted. + [HttpGet("metrics")] + public MetricsSummary GetMetrics() + { + return MetricsManager.GetSummary(this.Config.Value); + } + /********* ** Private methods @@ -121,8 +134,9 @@ public async Task> PostAsync([FromBody] ModSearchMode /// The wiki data. /// Whether to include extended metadata for each mod. /// The SMAPI version installed by the player. + /// The metrics to update with update-check results. /// Returns the mod data if found, else null. - private async Task GetModData(ModSearchEntryModel search, WikiModEntry[] wikiData, bool includeExtendedMetadata, ISemanticVersion? apiVersion) + private async Task GetModData(ModSearchEntryModel search, WikiModEntry[] wikiData, bool includeExtendedMetadata, ISemanticVersion? apiVersion, ApiMetricsModel metrics) { // cross-reference data ModDataRecord? record = this.ModDatabase.Get(search.ID); @@ -145,14 +159,19 @@ private async Task GetModData(ModSearchEntryModel search, WikiMod foreach (UpdateKey updateKey in updateKeys) { // validate update key - if (!updateKey.LooksValid) + if ( + !updateKey.LooksValid +#if SMAPI_DEPRECATED + || (updateKey.Site == ModSiteKey.UpdateManifest && apiVersion?.IsNewerThan("4.0.0-alpha") != true) // 4.0-alpha feature, don't make available to released mods in case it changes before release +#endif + ) { errors.Add($"The update key '{updateKey}' isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541', with an optional subkey like 'Nexus:541@subkey'."); continue; } // fetch data - ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey, allowNonStandardVersions, wikiEntry?.Overrides?.ChangeRemoteVersions); + ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey, allowNonStandardVersions, wikiEntry?.Overrides?.ChangeRemoteVersions, metrics); if (data.Status != RemoteModStatus.Ok) { errors.Add(data.Error ?? data.Status.ToString()); @@ -162,17 +181,21 @@ private async Task GetModData(ModSearchEntryModel search, WikiMod // if there's only a prerelease version (e.g. from GitHub), don't override the main version ISemanticVersion? curMain = data.Version; ISemanticVersion? curPreview = data.PreviewVersion; + string? curMainUrl = data.MainModPageUrl; + string? curPreviewUrl = data.PreviewModPageUrl; if (curPreview == null && curMain?.IsPrerelease() == true) { curPreview = curMain; + curPreviewUrl = curMainUrl; curMain = null; + curMainUrl = null; } // handle versions if (this.IsNewer(curMain, main?.Version)) - main = new ModEntryVersionModel(curMain, data.Url!); + main = new ModEntryVersionModel(curMain, curMainUrl ?? data.Url!); if (this.IsNewer(curPreview, optional?.Version)) - optional = new ModEntryVersionModel(curPreview, data.Url!); + optional = new ModEntryVersionModel(curPreview, curPreviewUrl ?? data.Url!); } // get unofficial version @@ -273,29 +296,30 @@ private bool IsNewer([NotNullWhen(true)] ISemanticVersion? current, ISemanticVer /// The namespaced update key. /// Whether to allow non-standard versions. /// The changes to apply to remote versions for update checks. - private async Task GetInfoForUpdateKeyAsync(UpdateKey updateKey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions) + /// The metrics to update with update-check results. + private async Task GetInfoForUpdateKeyAsync(UpdateKey updateKey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions, ApiMetricsModel metrics) { if (!updateKey.LooksValid) return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"Invalid update key '{updateKey}'."); // get mod page + bool wasCached = + this.ModCache.TryGetMod(updateKey.Site, updateKey.ID, out Cached? cachedMod) + && !this.ModCache.IsStale(cachedMod.LastUpdated, cachedMod.Data.Status == RemoteModStatus.TemporaryError ? this.Config.Value.ErrorCacheMinutes : this.Config.Value.SuccessCacheMinutes); IModPage page; + if (wasCached) + page = cachedMod!.Data; + else { - bool isCached = - this.ModCache.TryGetMod(updateKey.Site, updateKey.ID, out Cached? cachedMod) - && !this.ModCache.IsStale(cachedMod.LastUpdated, cachedMod.Data.Status == RemoteModStatus.TemporaryError ? this.Config.Value.ErrorCacheMinutes : this.Config.Value.SuccessCacheMinutes); - - if (isCached) - page = cachedMod!.Data; - else - { - page = await this.ModSites.GetModPageAsync(updateKey); - this.ModCache.SaveMod(updateKey.Site, updateKey.ID, page); - } + page = await this.ModSites.GetModPageAsync(updateKey); + this.ModCache.SaveMod(updateKey.Site, updateKey.ID, page); } + // update metrics + metrics.TrackUpdateKey(updateKey, wasCached, page.IsValid); + // get version info - return this.ModSites.GetPageVersions(page, updateKey.Subkey, allowNonStandardVersions, mapRemoteVersions); + return this.ModSites.GetPageVersions(page, updateKey, allowNonStandardVersions, mapRemoteVersions); } /// Get update keys based on the available mod metadata, while maintaining the precedence order. diff --git a/src/SMAPI.Web/Framework/Caching/ICacheRepository.cs b/src/SMAPI.Web/Framework/Caching/ICacheRepository.cs index 5de7e7312..b29152696 100644 --- a/src/SMAPI.Web/Framework/Caching/ICacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/ICacheRepository.cs @@ -5,7 +5,7 @@ namespace StardewModdingAPI.Web.Framework.Caching /// Encapsulates logic for accessing data in the cache. internal interface ICacheRepository { - /// Whether cached data is stale. + /// Get whether cached data is stale. /// The date when the data was updated. /// The age in minutes before data is considered stale. bool IsStale(DateTimeOffset lastUpdated, int staleMinutes); diff --git a/src/SMAPI.Web/Framework/Caching/NexusExport/INexusExportCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/NexusExport/INexusExportCacheRepository.cs new file mode 100644 index 000000000..2c813f465 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/NexusExport/INexusExportCacheRepository.cs @@ -0,0 +1,32 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels; + +namespace StardewModdingAPI.Web.Framework.Caching.NexusExport +{ + /// Manages cached mod data from the Nexus export API. + internal interface INexusExportCacheRepository : ICacheRepository + { + /********* + ** Methods + *********/ + /// Get whether the export data is currently available. + bool IsLoaded(); + + /// Get when the export data was last fetched, or null if no data is currently available. + DateTimeOffset? GetLastRefreshed(); + + /// Get the cached data for a mod, if it exists in the export. + /// The Nexus mod ID. + /// The fetched metadata. + bool TryGetMod(uint id, [NotNullWhen(true)] out NexusModExport? mod); + + /// Set the cached data to use. + /// The export received from the Nexus Mods API, or null to remove it. + void SetData(NexusFullExport? export); + + /// Get whether the cached data is stale. + /// The age in minutes before data is considered stale. + bool IsStale(int staleMinutes); + } +} diff --git a/src/SMAPI.Web/Framework/Caching/NexusExport/NexusExportCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/NexusExport/NexusExportCacheMemoryRepository.cs new file mode 100644 index 000000000..52f64725a --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/NexusExport/NexusExportCacheMemoryRepository.cs @@ -0,0 +1,59 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels; + +namespace StardewModdingAPI.Web.Framework.Caching.NexusExport +{ + /// Manages cached mod data from the Nexus export API in-memory. + internal class NexusExportCacheMemoryRepository : BaseCacheRepository, INexusExportCacheRepository + { + /********* + ** Fields + *********/ + /// The cached mod data from the Nexus export API. + private NexusFullExport? Data; + + + /********* + ** Public methods + *********/ + /// + public bool IsLoaded() + { + return this.Data?.Data.Count > 0; + } + + /// + public DateTimeOffset? GetLastRefreshed() + { + return this.Data?.LastUpdated; + } + + /// + public bool TryGetMod(uint id, [NotNullWhen(true)] out NexusModExport? mod) + { + var data = this.Data?.Data; + + if (data is null || !data.TryGetValue(id, out mod)) + { + mod = null; + return false; + } + + return true; + } + + /// + public void SetData(NexusFullExport? export) + { + this.Data = export; + } + + /// + public bool IsStale(int staleMinutes) + { + DateTimeOffset? lastUpdated = this.Data?.LastUpdated; + return lastUpdated.HasValue && this.IsStale(lastUpdated.Value, staleMinutes); + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs index 548f17c39..6c9c08efb 100644 --- a/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs +++ b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs @@ -1,3 +1,5 @@ +using System; + namespace StardewModdingAPI.Web.Framework.Clients { /// Generic metadata about a file download on a mod page. @@ -15,6 +17,9 @@ internal class GenericModDownload : IModDownload /// The download's file version. public string? Version { get; } + /// The mod URL page from which to download this update, if different from the URL of the mod page it was fetched from. + public string? ModPageUrl { get; } + /********* ** Public methods @@ -23,11 +28,22 @@ internal class GenericModDownload : IModDownload /// The download's display name. /// The download's description. /// The download's file version. - public GenericModDownload(string name, string? description, string? version) + /// The mod URL page from which to download this update, if different from the URL of the mod page it was fetched from. + public GenericModDownload(string name, string? description, string? version, string? modPageUrl = null) { this.Name = name; this.Description = description; this.Version = version; + this.ModPageUrl = modPageUrl; + } + + /// Get whether the subkey matches this download. + /// The update subkey to check. + public virtual bool MatchesSubkey(string subkey) + { + return + this.Name.Contains(subkey, StringComparison.OrdinalIgnoreCase) + || this.Description?.Contains(subkey, StringComparison.OrdinalIgnoreCase) == true; } } } diff --git a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs index 5353c7e1d..63ca5a95d 100644 --- a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs +++ b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs @@ -40,6 +40,9 @@ internal class GenericModPage : IModPage [MemberNotNullWhen(true, nameof(IModPage.Name), nameof(IModPage.Url))] public bool IsValid => this.Status == RemoteModStatus.Ok; + /// Whether this mod page requires update subkeys and does not allow matching downloads without them. + public bool RequireSubkey { get; set; } = false; + /********* ** Public methods @@ -79,5 +82,19 @@ public IModPage SetError(RemoteModStatus status, string error) return this; } + + /// Get the mod name for an update subkey, if different from the mod page name. + /// The update subkey. + public virtual string? GetName(string? subkey) + { + return this.Name; + } + + /// Get the mod page URL for an update subkey, if different from the mod page it was fetched from. + /// The update subkey. + public virtual string? GetUrl(string? subkey) + { + return this.Url; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs index 9de6f020d..79500d936 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs @@ -17,6 +17,10 @@ internal class GitRelease [JsonProperty("tag_name")] public string Tag { get; } + /// The URL to the release web page. + [JsonProperty("html_url")] + public string WebUrl { get; } + /// The Markdown description for the release. public string Body { get; internal set; } @@ -38,14 +42,16 @@ internal class GitRelease /// Construct an instance. /// The display name. /// The semantic version string. + /// The URL to the release web page. /// The Markdown description for the release. /// Whether this is a draft version. /// Whether this is a prerelease version. /// The attached files. - public GitRelease(string name, string tag, string? body, bool isDraft, bool isPrerelease, GitAsset[]? assets) + public GitRelease(string name, string tag, string webUrl, string? body, bool isDraft, bool isPrerelease, GitAsset[]? assets) { this.Name = name; this.Tag = tag; + this.WebUrl = webUrl; this.Body = body ?? string.Empty; this.IsDraft = isDraft; this.IsPrerelease = isPrerelease; diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs index f5a5f930a..1bb3f1c1e 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using Pathoschild.Http.Client; +using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.UpdateData; using StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels; @@ -73,8 +74,30 @@ public ModDropClient(string userAgent, string apiUrl, string modUrlFormat) if (file.IsOld || file.IsDeleted || file.IsHidden) continue; + // ModDrop drops the version prerelease tag if it's not in their whitelist of allowed suffixes. For + // example, "1.0.0-alpha" is fine but "1.0.0-sdvalpha" will have version field "1.0.0". + // + // If the version is non-prerelease but the file's display name contains a prerelease version, parse it + // out of the name instead. + string version = file.Version; + if (file.Name.Contains(version + "-") && SemanticVersion.TryParse(version, out ISemanticVersion? parsedVersion) && !parsedVersion.IsPrerelease()) + { + string[] parts = file.Name.Split(' '); + if (parts.Length == 1) + continue; // can't safely parse name without spaces (e.g. "mod-1.0.0-release" may not be version 1.0.0-release) + + foreach (string part in parts) + { + if (part.StartsWith(version + "-") && SemanticVersion.TryParse(part, out parsedVersion)) + { + version = parsedVersion.ToString(); + break; + } + } + } + downloads.Add( - new GenericModDownload(file.Name, file.Description, file.Version) + new GenericModDownload(file.Name, file.Description, version) ); } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/DisabledNexusExportApiClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/DisabledNexusExportApiClient.cs new file mode 100644 index 000000000..71f12c0cb --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/Nexus/DisabledNexusExportApiClient.cs @@ -0,0 +1,29 @@ +using System; +using System.Threading.Tasks; +using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport; +using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels; + +namespace StardewModdingAPI.Web.Framework.Clients.Nexus +{ + /// A client for the Nexus website which does nothing, used for local development. + internal class DisabledNexusExportApiClient : INexusExportApiClient + { + /********* + ** Public methods + *********/ + /// + public Task FetchExportAsync() + { + return Task.FromResult( + new NexusFullExport + { + Data = new(), + LastUpdated = DateTimeOffset.UtcNow + } + ); + } + + /// + public void Dispose() { } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs index 46c3092ce..7bfd50c07 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs @@ -7,7 +7,9 @@ using Pathoschild.FluentNexus.Models; using Pathoschild.Http.Client; using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels; using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.Caching.NexusExport; using StardewModdingAPI.Web.Framework.Clients.Nexus.ResponseModels; using FluentNexusClient = Pathoschild.FluentNexus.NexusClient; @@ -31,6 +33,9 @@ internal class NexusClient : INexusClient /// The underlying HTTP client for the Nexus API. private readonly FluentNexusClient ApiClient; + /// The cached mod data from the Nexus export API to use if available. + private readonly INexusExportCacheRepository NexusExportCache; + /********* ** Accessors @@ -49,12 +54,14 @@ internal class NexusClient : INexusClient /// The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID. /// The app version to show in API user agents. /// The Nexus API authentication key. - public NexusClient(string webUserAgent, string webBaseUrl, string webModUrlFormat, string webModScrapeUrlFormat, string apiAppVersion, string apiKey) + /// The cached mod data from the Nexus export API to use if available. + public NexusClient(string webUserAgent, string webBaseUrl, string webModUrlFormat, string webModScrapeUrlFormat, string apiAppVersion, string apiKey, INexusExportCacheRepository nexusExportCache) { this.WebModUrlFormat = webModUrlFormat; this.WebModScrapeUrlFormat = webModScrapeUrlFormat; this.WebClient = new FluentClient(webBaseUrl).SetUserAgent(webUserAgent); this.ApiClient = new FluentNexusClient(apiKey, "SMAPI", apiAppVersion); + this.NexusExportCache = nexusExportCache; } /// Get update check info about a mod. @@ -66,13 +73,34 @@ public NexusClient(string webUserAgent, string webBaseUrl, string webModUrlForma if (!uint.TryParse(id, out uint parsedId)) return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Nexus mod ID, must be an integer ID."); - // Fetch from the Nexus website when possible, since it has no rate limits. Mods with - // adult content are hidden for anonymous users, so fall back to the API in that case. - // Note that the API has very restrictive rate limits which means we can't just use it - // for all cases. - NexusMod? mod = await this.GetModFromWebsiteAsync(parsedId); - if (mod?.Status == NexusModStatus.AdultContentForbidden) - mod = await this.GetModFromApiAsync(parsedId); + // Nexus has strict rate limits meant for a user's mod manager, which are too low to + // provide an update-check server for all SMAPI players. To avoid rate limits, we fetch + // the mod from these sources in order of priority: + // + // 1. Nexus export API: + // This is a special endpoint provided by Nexus Mods specifically for SMAPI's update + // checks. It returns a cached view of every Stardew Valley mod, so we don't need to + // submit separate requests for each mod. + // + // 2. Nexus website: + // Though mostly superseded by the export API, this is the fallback if the export + // isn't available for some reason (e.g. because the server only has stale data). + // This has no rate limits and Nexus has special firewall rules in place to let + // SMAPI's web servers do this if needed. However, adult mods are hidden since + // we're not logged in. + // + // 3. Nexus API: + // For adult mods, fallback to the official Nexus API which has strict rate + // limits. + NexusMod? mod; + if (this.NexusExportCache.IsLoaded()) + mod = await this.GetModFromExportDataAsync(parsedId); + else + { + mod = await this.GetModFromWebsiteAsync(parsedId); + if (mod?.Status == NexusModStatus.AdultContentForbidden) + mod = await this.GetModFromApiAsync(parsedId); + } // page doesn't exist if (mod == null || mod.Status is NexusModStatus.Hidden or NexusModStatus.NotPublished) @@ -95,6 +123,47 @@ public void Dispose() /********* ** Private methods *********/ + /// Get metadata about a mod by searching the Nexus export API data. + /// The Nexus mod ID. + /// Returns the mod info if found, else null. + private Task GetModFromExportDataAsync(uint id) + { + static Task ModResult(NexusMod mod) => Task.FromResult(mod); + static Task StatusResult(NexusModStatus status) => Task.FromResult(new NexusMod(status, status.ToString())); + + // skip if no data available + if (!this.NexusExportCache.IsLoaded() || !this.NexusExportCache.TryGetMod(id, out NexusModExport? data)) + return Task.FromResult(null); + + // handle hidden mod + if (!data.Published) + return StatusResult(NexusModStatus.NotPublished); + if (!data.AllowView || data.Moderated) + return StatusResult(NexusModStatus.Hidden); + + // get downloads + var downloads = new List(); + foreach ((uint fileId, NexusFileExport file) in data.Files) + { + if ((FileCategory)file.CategoryId is FileCategory.Main or FileCategory.Optional) + { + downloads.Add( + new GenericModDownload(file.Name ?? fileId.ToString(), file.Description, file.Version) + ); + } + } + + // yield info + return ModResult( + new NexusMod( + name: data.Name ?? id.ToString(), + version: data.Version, + url: this.GetModUrl(id), + downloads: downloads.ToArray() + ) + ); + } + /// Get metadata about a mod by scraping the Nexus website. /// The Nexus mod ID. /// Returns the mod info if found, else null. @@ -108,7 +177,7 @@ public void Dispose() .GetAsync(string.Format(this.WebModScrapeUrlFormat, id)) .AsString(); } - catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) + catch (ApiException ex) when (ex.Status is HttpStatusCode.NotFound or HttpStatusCode.Forbidden) { return null; } diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/IUpdateManifestClient.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/IUpdateManifestClient.cs new file mode 100644 index 000000000..bf1edd3f5 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/IUpdateManifestClient.cs @@ -0,0 +1,7 @@ +using System; + +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest +{ + /// An API client for fetching update metadata from an arbitrary JSON URL. + internal interface IUpdateManifestClient : IModSiteClient, IDisposable { } +} diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestModModel.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestModModel.cs new file mode 100644 index 000000000..ead5c2299 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestModModel.cs @@ -0,0 +1,35 @@ +using System; + +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest.ResponseModels +{ + /// The data model for a mod in an update manifest file. + internal class UpdateManifestModModel + { + /********* + ** Accessors + *********/ + /// The mod's name. + public string? Name { get; } + + /// The mod page URL from which to download updates. + public string? ModPageUrl { get; } + + /// The available versions for this mod. + public UpdateManifestVersionModel[] Versions { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod's name. + /// The mod page URL from which to download updates. + /// The available versions for this mod. + public UpdateManifestModModel(string? name, string? modPageUrl, UpdateManifestVersionModel[]? versions) + { + this.Name = name; + this.ModPageUrl = modPageUrl; + this.Versions = versions ?? Array.Empty(); + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestModel.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestModel.cs new file mode 100644 index 000000000..5ccd31b0d --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestModel.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest.ResponseModels +{ + /// The data model for an update manifest file. + internal class UpdateManifestModel + { + /********* + ** Accessors + *********/ + /// The manifest format version. This is equivalent to the SMAPI version, and is used to parse older manifests correctly if later versions of SMAPI change the expected format. + public string Format { get; } + + /// The mod info in this update manifest. + public IDictionary Mods { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The manifest format version. + /// The mod info in this update manifest. + public UpdateManifestModel(string format, IDictionary? mods) + { + this.Format = format; + this.Mods = mods ?? new Dictionary(); + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestVersionModel.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestVersionModel.cs new file mode 100644 index 000000000..6678f5eba --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestVersionModel.cs @@ -0,0 +1,28 @@ +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest.ResponseModels +{ + /// Data model for a Version in an update manifest. + internal class UpdateManifestVersionModel + { + /********* + ** Accessors + *********/ + /// The mod's semantic version. + public string? Version { get; } + + /// The mod page URL from which to download updates, if different from . + public string? ModPageUrl { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod's semantic version. + /// The mod page URL from which to download updates, if different from . + public UpdateManifestVersionModel(string version, string? modPageUrl) + { + this.Version = version; + this.ModPageUrl = modPageUrl; + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestClient.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestClient.cs new file mode 100644 index 000000000..270728977 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestClient.cs @@ -0,0 +1,106 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Pathoschild.Http.Client; +using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.Clients.UpdateManifest.ResponseModels; + +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest +{ + /// An API client for fetching update metadata from an arbitrary JSON URL. + internal class UpdateManifestClient : IUpdateManifestClient + { + /********* + ** Fields + *********/ + /// The underlying HTTP client. + private readonly IClient Client; + + + /********* + ** Accessors + *********/ + /// The unique key for the mod site. + public ModSiteKey SiteKey => ModSiteKey.UpdateManifest; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The user agent for the API client. + public UpdateManifestClient(string userAgent) + { + this.Client = new FluentClient() + .SetUserAgent(userAgent); + + this.Client.Formatters.JsonFormatter.SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/plain")); + } + + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + public void Dispose() + { + this.Client.Dispose(); + } + + /// + [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract", Justification = "This is the method which ensures the annotations are correct.")] + public async Task GetModData(string id) + { + // get raw update manifest + UpdateManifestModel? manifest; + try + { + manifest = await this.Client.GetAsync(id).As(); + if (manifest is null) + return this.GetFormatError(id, "manifest can't be empty"); + } + catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) + { + return new GenericModPage(this.SiteKey, id).SetError(RemoteModStatus.DoesNotExist, $"No update manifest found at {id}"); + } + catch (Exception ex) + { + return this.GetFormatError(id, ex.Message); + } + + // validate + if (!SemanticVersion.TryParse(manifest.Format, out _)) + return this.GetFormatError(id, $"invalid format version '{manifest.Format}'"); + foreach (UpdateManifestModModel mod in manifest.Mods.Values) + { + if (mod is null) + return this.GetFormatError(id, "a mod record can't be null"); + if (string.IsNullOrWhiteSpace(mod.ModPageUrl)) + return this.GetFormatError(id, $"all mods must have a {nameof(mod.ModPageUrl)} value"); + foreach (UpdateManifestVersionModel? version in mod.Versions) + { + if (version is null) + return this.GetFormatError(id, "a version record can't be null"); + if (string.IsNullOrWhiteSpace(version.Version)) + return this.GetFormatError(id, $"all version records must have a {nameof(version.Version)} field"); + if (!SemanticVersion.TryParse(version.Version, out _)) + return this.GetFormatError(id, $"invalid mod version '{version.Version}'"); + } + } + + // build model + return new UpdateManifestModPage(id, manifest); + } + + + /********* + ** Private methods + *********/ + /// Get a mod page instance with an error indicating the update manifest is invalid. + /// The full URL to the update manifest. + /// A human-readable reason phrase indicating why it's invalid. + private IModPage GetFormatError(string url, string reason) + { + return new GenericModPage(this.SiteKey, url).SetError(RemoteModStatus.InvalidData, $"The update manifest at {url} is invalid ({reason})"); + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModDownload.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModDownload.cs new file mode 100644 index 000000000..f8cb760a4 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModDownload.cs @@ -0,0 +1,34 @@ +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest +{ + /// Metadata about a mod download in an update manifest file. + internal class UpdateManifestModDownload : GenericModDownload + { + /********* + ** Fields + *********/ + /// The update subkey for this mod download. + private readonly string Subkey; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The field name for this mod download in the manifest. + /// The mod name for this download. + /// The download's version. + /// The download's URL. + public UpdateManifestModDownload(string fieldName, string name, string? version, string? url) + : base(name, null, version, url) + { + this.Subkey = '@' + fieldName; + } + + /// Get whether the subkey matches this download. + /// The update subkey to check. + public override bool MatchesSubkey(string subkey) + { + return subkey == this.Subkey; + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModPage.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModPage.cs new file mode 100644 index 000000000..df7527136 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModPage.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.Clients.UpdateManifest.ResponseModels; + +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest +{ + /// Metadata about an update manifest "page". + internal class UpdateManifestModPage : GenericModPage + { + /********* + ** Fields + *********/ + /// The mods from the update manifest. + private readonly IDictionary Mods; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The URL of the update manifest file. + /// The parsed update manifest. + public UpdateManifestModPage(string url, UpdateManifestModel manifest) + : base(ModSiteKey.UpdateManifest, url) + { + this.RequireSubkey = true; + this.Mods = manifest.Mods; + this.SetInfo(name: url, url: url, version: null, downloads: this.ParseDownloads(manifest.Mods).ToArray()); + } + + /// Return the mod name for the given subkey, if it exists in this update manifest. + /// The subkey. + /// The mod name for the given subkey, or if this manifest does not contain the given subkey. + public override string? GetName(string? subkey) + { + return subkey is not null && this.Mods.TryGetValue(subkey.TrimStart('@'), out UpdateManifestModModel? mod) + ? mod.Name + : null; + } + + /// Return the mod URL for the given subkey, if it exists in this update manifest. + /// The subkey. + /// The mod URL for the given subkey, or if this manifest does not contain the given subkey. + public override string? GetUrl(string? subkey) + { + return subkey is not null && this.Mods.TryGetValue(subkey.TrimStart('@'), out UpdateManifestModModel? mod) + ? mod.ModPageUrl + : null; + } + + + /********* + ** Private methods + *********/ + /// Convert the raw download info from an update manifest to . + /// The mods from the update manifest. + private IEnumerable ParseDownloads(IDictionary? mods) + { + if (mods is null) + yield break; + + foreach ((string modKey, UpdateManifestModModel mod) in mods) + { + foreach (UpdateManifestVersionModel version in mod.Versions) + yield return new UpdateManifestModDownload(modKey, mod.Name ?? modKey, version.Version, version.ModPageUrl); + } + } + + } +} diff --git a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs index 21f9070b9..e96db5762 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs @@ -78,9 +78,12 @@ internal class ApiClientsConfig /**** ** Nexus Mods ****/ - /// The base URL for the Nexus Mods API. + /// The base URL for the Nexus Mods REST API. public string NexusBaseUrl { get; set; } = null!; + /// The base URL for the Nexus Mods export API. + public string NexusExportUrl { get; set; } = null!; + /// The URL for a Nexus mod page for the user, excluding the , where {0} is the mod ID. public string NexusModUrlFormat { get; set; } = null!; diff --git a/src/SMAPI.Web/Framework/IModDownload.cs b/src/SMAPI.Web/Framework/IModDownload.cs index fe1717858..8cb829893 100644 --- a/src/SMAPI.Web/Framework/IModDownload.cs +++ b/src/SMAPI.Web/Framework/IModDownload.cs @@ -14,5 +14,16 @@ internal interface IModDownload /// The download's file version. string? Version { get; } + + /// The mod URL page from which to download this update, if different from the URL of the mod page it was fetched from. + string? ModPageUrl { get; } + + + /********* + ** Methods + *********/ + /// Get whether the subkey matches this download. + /// The update subkey to check. + bool MatchesSubkey(string subkey); } } diff --git a/src/SMAPI.Web/Framework/IModPage.cs b/src/SMAPI.Web/Framework/IModPage.cs index 4d0a8d615..85be41e2b 100644 --- a/src/SMAPI.Web/Framework/IModPage.cs +++ b/src/SMAPI.Web/Framework/IModPage.cs @@ -39,10 +39,21 @@ internal interface IModPage [MemberNotNullWhen(false, nameof(IModPage.Error))] bool IsValid { get; } + /// Whether this mod page requires update subkeys and does not allow matching downloads without them. + bool RequireSubkey { get; } + /********* ** Methods *********/ + /// Get the mod name for an update subkey, if different from the mod page name. + /// The update subkey. + string? GetName(string? subkey); + + /// Get the mod page URL for an update subkey, if different from the mod page it was fetched from. + /// The update subkey. + string? GetUrl(string? subkey); + /// Set the fetched mod info. /// The mod name. /// The mod's semantic version number. diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs index 67ecea780..e581e8f79 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs @@ -22,6 +22,9 @@ public class LogParser /// A regex pattern matching SMAPI's mod folder path line. private readonly Regex ModPathPattern = new(@"^Mods go here: (?.+)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + /// A regex pattern matching SMAPI's mod folder path line. + private readonly Regex GamePathPattern = new(@"^\(Using custom --mods-path argument\. Game folder: (?.+)\.\)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + /// A regex pattern matching SMAPI's log timestamp line. private readonly Regex LogStartedAtPattern = new(@"^Log started at (?.+) UTC", RegexOptions.Compiled | RegexOptions.IgnoreCase); @@ -226,6 +229,13 @@ public ParsedLog Parse(string? logText) : log.ModPath; } + // game path line + else if (message.Level == LogLevel.Trace && this.GamePathPattern.IsMatch(message.Text)) + { + Match match = this.GamePathPattern.Match(message.Text); + log.GamePath = match.Groups["path"].Value; + } + // log UTC timestamp line else if (message.Level == LogLevel.Trace && this.LogStartedAtPattern.IsMatch(message.Text)) { diff --git a/src/SMAPI.Web/Framework/Metrics/ApiMetricsModel.cs b/src/SMAPI.Web/Framework/Metrics/ApiMetricsModel.cs new file mode 100644 index 000000000..e3d68c806 --- /dev/null +++ b/src/SMAPI.Web/Framework/Metrics/ApiMetricsModel.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using StardewModdingAPI.Toolkit.Framework.UpdateData; + +namespace StardewModdingAPI.Web.Framework.Metrics +{ + /// The metrics tracked for a specific block of time. + internal class ApiMetricsModel + { + /********* + ** Accessors + *********/ + /// The total number of update-check requests received by the API (each of which may include multiple update keys). + public int ApiRequests { get; private set; } + + /// The metrics by mod site. + public Dictionary Sites { get; } = new(); + + + /********* + ** Public methods + *********/ + /// Track an update-check request received by the API. + public void TrackRequest() + { + this.ApiRequests++; + } + + /// Track the update-check result for a specific update key. + /// The update key that was requested. + /// Whether the data was returned from the cache; else it was fetched from the remote modding site. + /// Whether the data was fetched successfully from the remote modding site. + public void TrackUpdateKey(UpdateKey updateKey, bool wasCached, bool wasSuccessful) + { + MetricsModel siteMetrics = this.GetSiteMetrics(updateKey.Site); + siteMetrics.TrackUpdateKey(updateKey, wasCached, wasSuccessful); + } + + /// Merge the values from another metrics model into this one. + /// The metrics to merge into this model. + public void AggregateFrom(ApiMetricsModel other) + { + this.ApiRequests += other.ApiRequests; + + foreach ((ModSiteKey site, var otherSiteMetrics) in other.Sites) + { + var siteMetrics = this.GetSiteMetrics(site); + siteMetrics.AggregateFrom(otherSiteMetrics); + } + } + + + /********* + ** Private methods + *********/ + /// Get the metrics for a site key, adding it if needed. + /// The mod site key. + private MetricsModel GetSiteMetrics(ModSiteKey site) + { + if (!this.Sites.TryGetValue(site, out MetricsModel? siteMetrics)) + this.Sites[site] = siteMetrics = new MetricsModel(); + + return siteMetrics; + } + } +} diff --git a/src/SMAPI.Web/Framework/Metrics/MetricsManager.cs b/src/SMAPI.Web/Framework/Metrics/MetricsManager.cs new file mode 100644 index 000000000..0189e12e1 --- /dev/null +++ b/src/SMAPI.Web/Framework/Metrics/MetricsManager.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.ConfigModels; + +namespace StardewModdingAPI.Web.Framework.Metrics +{ + /// Manages in-memory update check metrics since the server was last deployed or restarted. + internal static class MetricsManager + { + /********* + ** Fields + *********/ + /// The date/time format used to generate hourly metrics keys. + private const string HourlyKeyFormat = "yyyy-MM-dd HH:*"; + + /// The length of the date-only prefix in . + private const int DateOnlyKeyLength = 10; + + /// The tracked metrics. + private static readonly IDictionary Metrics = new Dictionary(); + + /// When the server began tracking metrics. + private static readonly DateTimeOffset MetricsTrackedSince = DateTimeOffset.UtcNow; + + + /********* + ** Public methods + *********/ + /// Get the metrics model for the current hour. + public static ApiMetricsModel GetMetricsForNow() + { + string key = $"{DateTimeOffset.UtcNow.ToString(HourlyKeyFormat)}"; + + if (!MetricsManager.Metrics.TryGetValue(key, out ApiMetricsModel? metrics)) + MetricsManager.Metrics[key] = metrics = new ApiMetricsModel(); + + return metrics; + } + + /// Get a summary of the metrics collected since the server started. + /// The update-check settings. + public static MetricsSummary GetSummary(ModUpdateCheckConfig config) + { + // get aggregate stats + int totalRequests = 0; + var totals = new MetricsModel(); + var bySite = new Dictionary(); + var byDate = new Dictionary(); + foreach ((string hourlyKey, ApiMetricsModel hourly) in MetricsManager.Metrics) + { + // totals + totalRequests += hourly.ApiRequests; + foreach (MetricsModel site in hourly.Sites.Values) + totals.AggregateFrom(site); + + // by site + foreach ((ModSiteKey site, MetricsModel fromMetrics) in hourly.Sites) + { + if (!bySite.TryGetValue(site, out MetricsModel? metrics)) + bySite[site] = metrics = new MetricsModel(); + + metrics.AggregateFrom(fromMetrics); + } + + // by date + string dailyKey = hourlyKey[..MetricsManager.DateOnlyKeyLength]; + if (!byDate.TryGetValue(dailyKey, out ApiMetricsModel? daily)) + byDate[dailyKey] = daily = new ApiMetricsModel(); + + daily.AggregateFrom(hourly); + } + + return new MetricsSummary( + Uptime: DateTimeOffset.UtcNow - MetricsTrackedSince, + SuccessCacheMinutes: config.SuccessCacheMinutes, + ErrorCacheMinutes: config.ErrorCacheMinutes, + TotalApiRequests: totalRequests, + UniqueModsChecked: totals.UniqueModsChecked, + TotalCacheHits: totals.CacheHits, + TotalSuccessCacheMisses: totals.SuccessCacheMisses, + TotalErrorCacheMisses: totals.ErrorCacheMisses, + BySite: bySite, + ByDate: byDate + ); + } + } +} diff --git a/src/SMAPI.Web/Framework/Metrics/MetricsModel.cs b/src/SMAPI.Web/Framework/Metrics/MetricsModel.cs new file mode 100644 index 000000000..4b94e1745 --- /dev/null +++ b/src/SMAPI.Web/Framework/Metrics/MetricsModel.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using StardewModdingAPI.Toolkit.Framework.UpdateData; + +namespace StardewModdingAPI.Web.Framework.Metrics +{ + /// The metrics for a specific site. + internal class MetricsModel + { + /********* + ** Accessors + *********/ + /// The number of times an update key returned data from the cache. + public int CacheHits { get; private set; } + + /// The number of times an update key successfully fetched data from the remote mod site. + public int SuccessCacheMisses { get; private set; } + + /// The number of times an update key could not fetch data from the remote mod site (e.g. mod page didn't exist or mod site returned an API error). + public int ErrorCacheMisses { get; private set; } + + /// The unique mod IDs requested from each site. + [JsonIgnore] + public HashSet UniqueKeys { get; } = new(); + + /// The number of unique mod IDs requested from each site. + public int UniqueModsChecked => this.UniqueKeys.Count; + + + /********* + ** Public methods + *********/ + /// Track the update-check result for a specific update key. + /// The update key that was requested. + /// Whether the data was returned from the cache; else it was fetched from the remote modding site. + /// Whether the data was fetched successfully from the remote modding site. + public void TrackUpdateKey(UpdateKey updateKey, bool wasCached, bool wasSuccessful) + { + // normalize site key + ModSiteKey site = updateKey.Site; + if (!Enum.IsDefined(site)) + site = ModSiteKey.Unknown; + + // update metrics + if (wasCached) + this.CacheHits++; + else if (wasSuccessful) + this.SuccessCacheMisses++; + else + this.ErrorCacheMisses++; + + this.UniqueKeys.Add(updateKey.ID?.Trim()); + } + + /// Merge the values from another metrics model into this one. + /// The metrics to merge into this model. + public void AggregateFrom(MetricsModel other) + { + this.CacheHits += other.CacheHits; + this.SuccessCacheMisses += other.SuccessCacheMisses; + this.ErrorCacheMisses += other.ErrorCacheMisses; + + foreach (string? id in other.UniqueKeys) + this.UniqueKeys.Add(id); + } + } +} diff --git a/src/SMAPI.Web/Framework/Metrics/MetricsSummary.cs b/src/SMAPI.Web/Framework/Metrics/MetricsSummary.cs new file mode 100644 index 000000000..847b88049 --- /dev/null +++ b/src/SMAPI.Web/Framework/Metrics/MetricsSummary.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using StardewModdingAPI.Toolkit.Framework.UpdateData; + +namespace StardewModdingAPI.Web.Framework.Metrics +{ + /// An aggregate summary of tracked metrics. + /// The total time since the server began tracking metrics. + /// The number of minutes for which a successful data fetch from a remote mod site is cached. + /// The number of minutes for which a failed data fetch from a remote mod site is cached. + /// The total number of update-check requests received by the API (each of which may include multiple update keys). + /// The number of unique mod IDs requested. + /// The number of times an update key returned data from the cache. + /// The number of times an update key successfully fetched data from a remote mod site. + /// The number of times an update key could not fetch data from a remote mod site (e.g. mod page didn't exist or mod site returned an API error). + /// The metrics grouped by site. + /// The metrics grouped by UTC date. + internal record MetricsSummary( + TimeSpan Uptime, + int SuccessCacheMinutes, + int ErrorCacheMinutes, + int TotalApiRequests, + int UniqueModsChecked, + int TotalCacheHits, + int TotalSuccessCacheMisses, + int TotalErrorCacheMisses, + IDictionary BySite, + IDictionary ByDate + ); +} diff --git a/src/SMAPI.Web/Framework/ModInfoModel.cs b/src/SMAPI.Web/Framework/ModInfoModel.cs index e70b60bfe..502c08270 100644 --- a/src/SMAPI.Web/Framework/ModInfoModel.cs +++ b/src/SMAPI.Web/Framework/ModInfoModel.cs @@ -27,6 +27,12 @@ internal class ModInfoModel /// The error message indicating why the mod is invalid (if applicable). public string? Error { get; private set; } + /// The mod page URL from which can be downloaded, if different from the . + public string? MainModPageUrl { get; private set; } + + /// The mod page URL from which can be downloaded, if different from the . + public string? PreviewModPageUrl { get; private set; } + /********* ** Public methods @@ -46,7 +52,8 @@ public ModInfoModel(string name, string url, ISemanticVersion? version, ISemanti { this .SetBasicInfo(name, url) - .SetVersions(version!, previewVersion) + .SetMainVersion(version!) + .SetPreviewVersion(previewVersion) .SetError(status, error!); } @@ -62,14 +69,25 @@ public ModInfoModel SetBasicInfo(string name, string url) return this; } - /// Set the mod version info. - /// The semantic version for the mod's latest release. - /// The semantic version for the mod's latest preview release, if available and different from . + /// Set the mod's main version info. + /// The semantic version for the mod's latest stable release. + /// The mod page URL from which can be downloaded, if different from the . [MemberNotNull(nameof(ModInfoModel.Version))] - public ModInfoModel SetVersions(ISemanticVersion version, ISemanticVersion? previewVersion = null) + public ModInfoModel SetMainVersion(ISemanticVersion version, string? modPageUrl = null) { this.Version = version; - this.PreviewVersion = previewVersion; + this.MainModPageUrl = modPageUrl; + + return this; + } + + /// Set the mod's preview version info. + /// The semantic version for the mod's latest preview release. + /// The mod page URL from which can be downloaded, if different from the . + public ModInfoModel SetPreviewVersion(ISemanticVersion? version, string? modPageUrl = null) + { + this.PreviewVersion = version; + this.PreviewModPageUrl = modPageUrl; return this; } diff --git a/src/SMAPI.Web/Framework/ModSiteManager.cs b/src/SMAPI.Web/Framework/ModSiteManager.cs index 674b9ffc9..4bb72f78e 100644 --- a/src/SMAPI.Web/Framework/ModSiteManager.cs +++ b/src/SMAPI.Web/Framework/ModSiteManager.cs @@ -59,30 +59,42 @@ public async Task GetModPageAsync(UpdateKey updateKey) /// Parse version info for the given mod page info. /// The mod page info. - /// The optional update subkey to match in available files. (If no file names or descriptions contain the subkey, it'll be ignored.) + /// The update key to match in available files. /// The changes to apply to remote versions for update checks. /// Whether to allow non-standard versions. - public ModInfoModel GetPageVersions(IModPage page, string? subkey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions) + public ModInfoModel GetPageVersions(IModPage page, UpdateKey updateKey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions) { - // get base model + // get ID to show in errors + string displayId = page.RequireSubkey + ? page.Id + updateKey.Subkey + : page.Id; + + // validate ModInfoModel model = new(); - if (page.IsValid) + if (!page.IsValid) + return model.SetError(page.Status, page.Error); + if (page.RequireSubkey && updateKey.Subkey is null) + return model.SetError(RemoteModStatus.RequiredSubkeyMissing, $"The {page.Site} mod with ID '{displayId}' requires an update subkey indicating which mod to fetch."); + + // add basic info (unless it's a manifest, in which case the 'mod page' is the JSON file) + if (updateKey.Site != ModSiteKey.UpdateManifest) model.SetBasicInfo(page.Name, page.Url); - else - { - model.SetError(page.Status, page.Error); - return model; - } // fetch versions - bool hasVersions = this.TryGetLatestVersions(page, subkey, allowNonStandardVersions, mapRemoteVersions, out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion); - if (!hasVersions && subkey != null) - hasVersions = this.TryGetLatestVersions(page, null, allowNonStandardVersions, mapRemoteVersions, out mainVersion, out previewVersion); + bool hasVersions = this.TryGetLatestVersions(page, updateKey.Subkey, allowNonStandardVersions, mapRemoteVersions, out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion, out string? mainModPageUrl, out string? previewModPageUrl); if (!hasVersions) - return model.SetError(RemoteModStatus.InvalidData, $"The {page.Site} mod with ID '{page.Id}' has no valid versions."); + return model.SetError(RemoteModStatus.InvalidData, $"The {page.Site} mod with ID '{displayId}' has no valid versions."); + + // apply mod page info + model.SetBasicInfo( + name: page.GetName(updateKey.Subkey) ?? page.Name, + url: page.GetUrl(updateKey.Subkey) ?? page.Url + ); // return info - return model.SetVersions(mainVersion!, previewVersion); + return model + .SetMainVersion(mainVersion!, mainModPageUrl) + .SetPreviewVersion(previewVersion, previewModPageUrl); } /// Get a semantic local version for update checks. @@ -113,34 +125,37 @@ public ModInfoModel GetPageVersions(IModPage page, string? subkey, bool allowNon /// The changes to apply to remote versions for update checks. /// The main mod version. /// The latest prerelease version, if newer than . - private bool TryGetLatestVersions(IModPage? mod, string? subkey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions, [NotNullWhen(true)] out ISemanticVersion? main, out ISemanticVersion? preview) + /// The mod page URL from which can be downloaded, if different from the 's URL. + /// The mod page URL from which can be downloaded, if different from the 's URL. + private bool TryGetLatestVersions(IModPage? mod, string? subkey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions, [NotNullWhen(true)] out ISemanticVersion? main, out ISemanticVersion? preview, out string? mainModPageUrl, out string? previewModPageUrl) { main = null; preview = null; + mainModPageUrl = null; + previewModPageUrl = null; + if (mod is null) + return false; // parse all versions from the mod page - IEnumerable<(string? name, string? description, ISemanticVersion? version)> GetAllVersions() + IEnumerable<(IModDownload? download, ISemanticVersion? version)> GetAllVersions() { - if (mod != null) + ISemanticVersion? ParseAndMapVersion(string? raw) { - ISemanticVersion? ParseAndMapVersion(string? raw) - { - raw = this.NormalizeVersion(raw); - return this.GetMappedVersion(raw, mapRemoteVersions, allowNonStandardVersions); - } + raw = this.NormalizeVersion(raw); + return this.GetMappedVersion(raw, mapRemoteVersions, allowNonStandardVersions); + } - // get mod version - ISemanticVersion? modVersion = ParseAndMapVersion(mod.Version); - if (modVersion != null) - yield return (name: null, description: null, version: ParseAndMapVersion(mod.Version)); + // get mod version + ISemanticVersion? modVersion = ParseAndMapVersion(mod.Version); + if (modVersion != null) + yield return (download: null, version: modVersion); - // get file versions - foreach (IModDownload download in mod.Downloads) - { - ISemanticVersion? cur = ParseAndMapVersion(download.Version); - if (cur != null) - yield return (download.Name, download.Description, cur); - } + // get file versions + foreach (IModDownload download in mod.Downloads) + { + ISemanticVersion? cur = ParseAndMapVersion(download.Version); + if (cur != null) + yield return (download, cur); } } var versions = GetAllVersions() @@ -148,40 +163,59 @@ private bool TryGetLatestVersions(IModPage? mod, string? subkey, bool allowNonSt .ToArray(); // get main + preview versions - void TryGetVersions([NotNullWhen(true)] out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion, Func<(string? name, string? description, ISemanticVersion? version), bool>? filter = null) + void TryGetVersions([NotNullWhen(true)] out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion, out string? mainUrl, out string? previewUrl, Func<(IModDownload? download, ISemanticVersion? version), bool>? filter = null) { mainVersion = null; previewVersion = null; + mainUrl = null; + previewUrl = null; // get latest main + preview version - foreach ((string? name, string? description, ISemanticVersion? version) entry in versions) + foreach ((IModDownload? download, ISemanticVersion? version) entry in versions) { if (entry.version is null || filter?.Invoke(entry) == false) continue; if (entry.version.IsPrerelease()) - previewVersion ??= entry.version; + { + if (previewVersion is null) + { + previewVersion = entry.version; + previewUrl = entry.download?.ModPageUrl; + } + } else - mainVersion ??= entry.version; - - if (mainVersion != null) + { + mainVersion = entry.version; + mainUrl = entry.download?.ModPageUrl; break; // any others will be older since entries are sorted by version + } } // normalize values if (previewVersion is not null) { - mainVersion ??= previewVersion; // if every version is prerelease, latest one is the main version + if (mainVersion is null) + { + // if every version is prerelease, latest one is the main version + mainVersion = previewVersion; + mainUrl = previewUrl; + } if (!previewVersion.IsNewerThan(mainVersion)) + { previewVersion = null; + previewUrl = null; + } } } + // get versions for subkey if (subkey is not null) - TryGetVersions(out main, out preview, entry => entry.name?.Contains(subkey, StringComparison.OrdinalIgnoreCase) == true || entry.description?.Contains(subkey, StringComparison.OrdinalIgnoreCase) == true); - if (main is null) - TryGetVersions(out main, out preview); + TryGetVersions(out main, out preview, out mainModPageUrl, out previewModPageUrl, filter: entry => entry.download?.MatchesSubkey(subkey) == true); + // fallback to non-subkey versions + if (main is null && !mod.RequireSubkey) + TryGetVersions(out main, out preview, out mainModPageUrl, out previewModPageUrl); return main != null; } diff --git a/src/SMAPI.Web/Framework/RemoteModStatus.cs b/src/SMAPI.Web/Framework/RemoteModStatus.cs index 139ecfd39..235bcec4e 100644 --- a/src/SMAPI.Web/Framework/RemoteModStatus.cs +++ b/src/SMAPI.Web/Framework/RemoteModStatus.cs @@ -12,6 +12,9 @@ internal enum RemoteModStatus /// The mod does not exist. DoesNotExist, + /// The mod page exists, but it requires a subkey and none was provided. + RequiredSubkeyMissing, + /// The mod was temporarily unavailable (e.g. the site could not be reached or an unknown error occurred). TemporaryError } diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index 54c259799..872aa02ed 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -10,9 +10,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Newtonsoft.Json; +using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport; using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Web.Framework; using StardewModdingAPI.Web.Framework.Caching.Mods; +using StardewModdingAPI.Web.Framework.Caching.NexusExport; using StardewModdingAPI.Web.Framework.Caching.Wiki; using StardewModdingAPI.Web.Framework.Clients.Chucklefish; using StardewModdingAPI.Web.Framework.Clients.CurseForge; @@ -20,6 +22,7 @@ using StardewModdingAPI.Web.Framework.Clients.ModDrop; using StardewModdingAPI.Web.Framework.Clients.Nexus; using StardewModdingAPI.Web.Framework.Clients.Pastebin; +using StardewModdingAPI.Web.Framework.Clients.UpdateManifest; using StardewModdingAPI.Web.Framework.Compression; using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.Framework.RedirectRules; @@ -77,6 +80,7 @@ public void ConfigureServices(IServiceCollection services) // init storage services.AddSingleton(new ModCacheMemoryRepository()); + services.AddSingleton(new NexusExportCacheMemoryRepository()); services.AddSingleton(new WikiCacheMemoryRepository()); // init Hangfire @@ -129,26 +133,40 @@ public void ConfigureServices(IServiceCollection services) modUrlFormat: api.ModDropModPageUrl )); - if (!string.IsNullOrWhiteSpace(api.NexusApiKey)) + if (!string.IsNullOrWhiteSpace(api.NexusExportUrl)) { - services.AddSingleton(new NexusClient( - webUserAgent: userAgent, - webBaseUrl: api.NexusBaseUrl, - webModUrlFormat: api.NexusModUrlFormat, - webModScrapeUrlFormat: api.NexusModScrapeUrlFormat, - apiAppVersion: version, - apiKey: api.NexusApiKey - )); + services.AddSingleton( + new NexusExportApiClient( + userAgent: userAgent, + baseUrl: api.NexusExportUrl + ) + ); } else + services.AddSingleton(new DisabledNexusExportApiClient()); + + if (!string.IsNullOrWhiteSpace(api.NexusApiKey)) { - services.AddSingleton(new DisabledNexusClient()); + services.AddSingleton( + provider => new NexusClient( + webUserAgent: userAgent, + webBaseUrl: api.NexusBaseUrl, + webModUrlFormat: api.NexusModUrlFormat, + webModScrapeUrlFormat: api.NexusModScrapeUrlFormat, + apiAppVersion: version, + apiKey: api.NexusApiKey, + provider.GetService() + )); } + else + services.AddSingleton(new DisabledNexusClient()); services.AddSingleton(new PastebinClient( baseUrl: api.PastebinBaseUrl, userAgent: userAgent )); + + services.AddSingleton(new UpdateManifestClient(userAgent: userAgent)); } // init helpers @@ -223,8 +241,11 @@ private RewriteOptions GetRedirectRules() [@"^/xnb\.?$"] = "https://stardewvalleywiki.com/Modding:Using_XNB_mods", // GitHub docs - [@"^/package(?:/?(.*))$"] = "https://github.com/Pathoschild/SMAPI/blob/develop/docs/technical/mod-package.md#$1", - [@"^/release(?:/?(.*))$"] = "https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#$1", + ["^/package(?:/?(.*))$"] = "https://github.com/Pathoschild/SMAPI/blob/develop/docs/technical/mod-package.md#$1", + ["^/release(?:/?(.*))$"] = "https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#$1", + + // Content Patcher docs + ["/cp-migrate(?:/?(.*))$"] = "https://github.com/Pathoschild/StardewMods/blob/develop/ContentPatcher/docs/author-migration-guide.md#$1", // legacy redirects [@"^/compat\.?$"] = "https://smapi.io/mods" diff --git a/src/SMAPI.Web/ViewModels/IndexVersionModel.cs b/src/SMAPI.Web/ViewModels/IndexVersionModel.cs index a76a5924b..248102b50 100644 --- a/src/SMAPI.Web/ViewModels/IndexVersionModel.cs +++ b/src/SMAPI.Web/ViewModels/IndexVersionModel.cs @@ -12,10 +12,13 @@ public class IndexVersionModel /// The Markdown description for the release. public string Description { get; } - /// The main download URL. + /// The URL to the download page. + public string WebUrl { get; } + + /// The direct download URL for the main version. public string DownloadUrl { get; } - /// The for-developers download URL (not applicable for prerelease versions). + /// The direct download URL for the for-developers version. Not applicable for prerelease versions. public string? DevDownloadUrl { get; } @@ -25,12 +28,14 @@ public class IndexVersionModel /// Construct an instance. /// The release number. /// The Markdown description for the release. - /// The main download URL. - /// The for-developers download URL (not applicable for prerelease versions). - internal IndexVersionModel(string version, string description, string downloadUrl, string? devDownloadUrl) + /// The URL to the download page. + /// The direct download URL for the main version. + /// The direct download URL for the for-developers version. Not applicable for prerelease versions. + internal IndexVersionModel(string version, string description, string webUrl, string downloadUrl, string? devDownloadUrl) { this.Version = version; this.Description = description; + this.WebUrl = webUrl; this.DownloadUrl = downloadUrl; this.DevDownloadUrl = devDownloadUrl; } diff --git a/src/SMAPI.Web/Views/Index/Index.cshtml b/src/SMAPI.Web/Views/Index/Index.cshtml index d6472fcb6..9809694be 100644 --- a/src/SMAPI.Web/Views/Index/Index.cshtml +++ b/src/SMAPI.Web/Views/Index/Index.cshtml @@ -26,8 +26,9 @@ @@ -65,17 +66,17 @@ diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index b3ea7db21..a05282860 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -15,10 +15,12 @@ // detect suggested fixes LogModInfo[] outdatedMods = log?.Mods.Where(mod => mod.HasUpdate).ToArray() ?? Array.Empty(); + bool isPyTkCompatibilityMode = log?.ApiVersionParsed?.IsBetween("3.15.0", "3.19.0") is true && log.Mods.Any(p => p.IsCodeMod && p.Name == "PyTK" && p.GetParsedVersion()?.IsOlderThan("1.24.0") is true); + LogModInfo? errorHandler = log?.Mods.FirstOrDefault(p => p.IsCodeMod && p.Name == "Error Handler"); - bool missingErrorHandler = errorHandler is null && log?.OperatingSystem?.Contains("Android Unix", StringComparison.OrdinalIgnoreCase) != true; - bool hasOlderErrorHandler = errorHandler?.GetParsedVersion() is not null && log?.ApiVersionParsed is not null && log.ApiVersionParsed.IsNewerThan(errorHandler.GetParsedVersion()); - bool isPyTkCompatibilityMode = log?.ApiVersionParsed?.IsOlderThan("3.15.0") is false && log.Mods.Any(p => p.IsCodeMod && p.Name == "PyTK" && p.GetParsedVersion()?.IsOlderThan("1.24.0") is true); + bool errorHandlerNeeded = log?.ApiVersionParsed?.IsOlderThan("4.0.0-alpha") == true && log.OperatingSystem?.Contains("Android Unix", StringComparison.OrdinalIgnoreCase) != true; + bool missingErrorHandler = errorHandlerNeeded && errorHandler is null; + bool hasOlderErrorHandler = errorHandlerNeeded && errorHandler?.GetParsedVersion() is not null && log?.ApiVersionParsed is not null && log.ApiVersionParsed.IsNewerThan(errorHandler.GetParsedVersion()); // get filters IDictionary defaultFilters = Enum diff --git a/src/SMAPI.Web/appsettings.Development.json b/src/SMAPI.Web/appsettings.Development.json index 3aa692859..de699707e 100644 --- a/src/SMAPI.Web/appsettings.Development.json +++ b/src/SMAPI.Web/appsettings.Development.json @@ -14,7 +14,8 @@ "GitHubUsername": null, "GitHubPassword": null, - "NexusApiKey": null + "NexusApiKey": null, + "NexusExportUrl": null }, "BackgroundServices": { diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json index 930cd33fd..7da468720 100644 --- a/src/SMAPI.Web/appsettings.json +++ b/src/SMAPI.Web/appsettings.json @@ -45,6 +45,7 @@ "NexusApiKey": null, "NexusBaseUrl": "https://www.nexusmods.com/stardewvalley/", + "NexusExportUrl": null, "NexusModUrlFormat": "mods/{0}", "NexusModScrapeUrlFormat": "mods/{0}?tab=files", diff --git a/src/SMAPI.Web/wwwroot/Content/css/index.css b/src/SMAPI.Web/wwwroot/Content/css/index.css index 150ccc0a2..cac83b0fb 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/index.css +++ b/src/SMAPI.Web/wwwroot/Content/css/index.css @@ -29,18 +29,18 @@ h1 { margin-bottom: 1em; padding: 6px 24px; text-decoration: none; - text-shadow: #aade7c 0 1px 0; + text-shadow: #98D760 0 1px 0; } #call-to-action a.main-cta { background: linear-gradient(#77d42a 5%, #5cb811 75%) #77d42a; font-size: 1.5em; - color: #306108; + color: #214205; } #call-to-action a.secondary-cta { - background: #768d87; - border: 1px solid #566963; + background: #5E706B; + border: 1px solid #3C4945; color: #eee; text-shadow: #2b665e 0 1px 0; } diff --git a/src/SMAPI.Web/wwwroot/Content/images/curseforge-icon.svg b/src/SMAPI.Web/wwwroot/Content/images/curseforge-icon.svg new file mode 100644 index 000000000..11b2077b1 --- /dev/null +++ b/src/SMAPI.Web/wwwroot/Content/images/curseforge-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/SMAPI.Web/wwwroot/Content/images/direct-download-icon.png b/src/SMAPI.Web/wwwroot/Content/images/direct-download-icon.png deleted file mode 100644 index 6c30ca366..000000000 Binary files a/src/SMAPI.Web/wwwroot/Content/images/direct-download-icon.png and /dev/null differ diff --git a/src/SMAPI.Web/wwwroot/Content/images/github-logo.svg b/src/SMAPI.Web/wwwroot/Content/images/github-logo.svg new file mode 100644 index 000000000..9c84407cd --- /dev/null +++ b/src/SMAPI.Web/wwwroot/Content/images/github-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/SMAPI.Web/wwwroot/Content/images/ko-fi.png b/src/SMAPI.Web/wwwroot/Content/images/ko-fi.png deleted file mode 100644 index a483f4527..000000000 Binary files a/src/SMAPI.Web/wwwroot/Content/images/ko-fi.png and /dev/null differ diff --git a/src/SMAPI.Web/wwwroot/Content/images/ko-fi.svg b/src/SMAPI.Web/wwwroot/Content/images/ko-fi.svg new file mode 100644 index 000000000..1445bb28f --- /dev/null +++ b/src/SMAPI.Web/wwwroot/Content/images/ko-fi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/SMAPI.Web/wwwroot/Content/images/nexus-icon.png b/src/SMAPI.Web/wwwroot/Content/images/nexus-icon.png deleted file mode 100644 index 10c667121..000000000 Binary files a/src/SMAPI.Web/wwwroot/Content/images/nexus-icon.png and /dev/null differ diff --git a/src/SMAPI.Web/wwwroot/Content/images/nexus-icon.svg b/src/SMAPI.Web/wwwroot/Content/images/nexus-icon.svg new file mode 100644 index 000000000..efb909602 --- /dev/null +++ b/src/SMAPI.Web/wwwroot/Content/images/nexus-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/SMAPI.Web/wwwroot/Content/images/patreon.png b/src/SMAPI.Web/wwwroot/Content/images/patreon.png deleted file mode 100644 index d589fedc4..000000000 Binary files a/src/SMAPI.Web/wwwroot/Content/images/patreon.png and /dev/null differ diff --git a/src/SMAPI.Web/wwwroot/Content/images/patreon.svg b/src/SMAPI.Web/wwwroot/Content/images/patreon.svg new file mode 100644 index 000000000..9c01dcd79 --- /dev/null +++ b/src/SMAPI.Web/wwwroot/Content/images/patreon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/SMAPI.Web/wwwroot/Content/images/paypal.png b/src/SMAPI.Web/wwwroot/Content/images/paypal.png deleted file mode 100644 index 225c9d7ba..000000000 Binary files a/src/SMAPI.Web/wwwroot/Content/images/paypal.png and /dev/null differ diff --git a/src/SMAPI.Web/wwwroot/Content/images/paypal.svg b/src/SMAPI.Web/wwwroot/Content/images/paypal.svg new file mode 100644 index 000000000..0576ce349 --- /dev/null +++ b/src/SMAPI.Web/wwwroot/Content/images/paypal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/SMAPI.Web/wwwroot/SMAPI.metadata.json b/src/SMAPI.Web/wwwroot/SMAPI.metadata.json index d654b1819..ad42d4deb 100644 --- a/src/SMAPI.Web/wwwroot/SMAPI.metadata.json +++ b/src/SMAPI.Web/wwwroot/SMAPI.metadata.json @@ -51,14 +51,6 @@ * was overridden. If not provided, it defaults to the StatusReasonPhrase or 'no reason given'. */ "ModData": { - /********* - ** Mods bundles with SMAPI - *********/ - "Error Handler": { - "ID": "SMAPI.ErrorHandler", - "SuppressWarnings": "PatchesGame" - }, - /********* ** Common dependencies for friendly errors *********/ @@ -66,57 +58,46 @@ "ID": "Entoarox.AdvancedLocationLoader", "Default | UpdateKey": "Nexus:2270" }, - "Content Patcher": { "ID": "Pathoschild.ContentPatcher", "Default | UpdateKey": "Nexus:1915" }, - "Custom Farming Redux": { "ID": "Platonymous.CustomFarming", "Default | UpdateKey": "Nexus:991" }, - "Custom Shirts": { "ID": "Platonymous.CustomShirts", "Default | UpdateKey": "Nexus:2416" }, - "Entoarox Framework": { "ID": "Entoarox.EntoaroxFramework", "Default | UpdateKey": "Nexus:2269" }, - "JSON Assets": { "ID": "spacechase0.JsonAssets", "Default | UpdateKey": "Nexus:1720" }, - "Mail Framework": { "ID": "DIGUS.MailFrameworkMod", "Default | UpdateKey": "Nexus:1536" }, - "MTN": { "ID": "SgtPickles.MTN", "Default | UpdateKey": "Nexus:2256" }, - "PyTK": { "ID": "Platonymous.Toolkit", "Default | UpdateKey": "Nexus:1726" }, - "SpaceCore": { "ID": "spacechase0.SpaceCore", "Default | UpdateKey": "Nexus:1348" }, - "Stardust Core": { "ID": "Omegasis.StardustCore", "Default | UpdateKey": "Nexus:2341" }, - "TMXL Map Toolkit": { "ID": "Platonymous.TMXLoader", "Default | UpdateKey": "Nexus:1820" @@ -128,37 +109,437 @@ "Animal Mood Fix": { "ID": "GPeters-AnimalMoodFix", "~ | Status": "Obsolete", - "~ | StatusReasonPhrase": "the animal mood bugs were fixed in Stardew Valley 1.2." + "~ | StatusReasonPhrase": "the animal mood bugs were fixed in Stardew Valley 1.2. You can delete this mod." }, - "Bee House Flower Range Fix": { "ID": "kirbylink.beehousefix", "~ | Status": "Obsolete", - "~ | StatusReasonPhrase": "the bee house flower range was fixed in Stardew Valley 1.4." + "~ | StatusReasonPhrase": "the bee house flower range was fixed in Stardew Valley 1.4. You can delete this mod." }, - "Colored Chests": { "ID": "4befde5c-731c-4853-8e4b-c5cdf946805f", "~ | Status": "Obsolete", - "~ | StatusReasonPhrase": "colored chests were added in Stardew Valley 1.1." + "~ | StatusReasonPhrase": "colored chests were added in Stardew Valley 1.1. You can delete this mod." + }, + "Error Handler": { + "ID": "SMAPI.ErrorHandler", + "~ | Status": "Obsolete", + "~ | StatusReasonPhrase": "its error handling was integrated into Stardew Valley 1.6. You can delete this mod." + }, + "Extra Map Layers": { + "ID": "aedenthorn.ExtraMapLayers", + "~0.3.10 | Status": "Obsolete", + "~0.3.10 | StatusReasonPhrase": "extra map layer support was added in Stardew Valley 1.6. You can delete this mod.", + "IgnoreDependencies": true }, - "Modder Serialization Utility": { "ID": "SerializerUtils-0-1", "~ | Status": "Obsolete", - "~ | StatusReasonPhrase": "it's no longer maintained or used." + "~ | StatusReasonPhrase": "it's no longer maintained or used. You can delete this mod." }, - "No Debug Mode": { "ID": "NoDebugMode", "~ | Status": "Obsolete", - "~ | StatusReasonPhrase": "debug mode was removed in SMAPI 1.0." + "~ | StatusReasonPhrase": "debug mode was removed in SMAPI 1.0. You can delete this mod." }, - "Split Screen": { "ID": "Ilyaki.SplitScreen", "~ | Status": "Obsolete", - "~ | StatusReasonPhrase": "split-screen mode was added in Stardew Valley 1.5" + "~ | StatusReasonPhrase": "split-screen mode was added in Stardew Valley 1.5. You can delete this mod." + }, + + /********* + ** Broke in SDV 1.6 + *********/ + "24-Hour Clock Harmony": { + "ID": "pepoluan.24h", + "~0.3.1 | Status": "AssumeBroken", + "~0.3.1 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Aimon's Fancy Farmhouse": { + "ID": "Aimon111.RedesignedFarmHouseLayoutAlt", + "~2.0.0 | Status": "AssumeBroken", + "~2.0.0 | StatusReasonDetails": "breaks farmhouse layout and causes runtime crashes" + }, + "All Chests Menu": { + "ID": "aedenthorn.AllChestsMenu", + "~0.3.1 | Status": "AssumeBroken", + "~0.3.1 | StatusReasonDetails": "fails at runtime" + }, + "Animal Dialogue Framework": { + "ID": "aedenthorn.AnimalDialogueFramework", + "~0.1.1 | Status": "AssumeBroken", + "~0.1.1 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "A Quality Mod": { + "ID": "spacechase0.AQualityMod", + "~1.0.1 | Status": "AssumeBroken", + "~1.0.1 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Backstory Questions Framework": { + "ID": "spacechase0.BackstoryQuestionsFramework", + "~1.0.0 | Status": "AssumeBroken", + "~1.0.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Better Elevator": { + "ID": "aedenthorn.BetterElevator", + "~0.2.4 | Status": "AssumeBroken", + "~0.2.4 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Better Crab Pots": { + "ID": "EpicBellyFlop45.BetterCrabPots", + "~2.1.0 | Status": "AssumeBroken", + "~2.1.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Better Horse Flute": { + "ID": "AnthonyMaciel.BetterHorseFlute", + "~1.0.0 | Status": "AssumeBroken", + "~1.0.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Better Quality More Seeds": { + "ID": "SpaceBaby.BetterQualityMoreSeeds", + "~2.0.0-6 | Status": "AssumeBroken", + "~2.0.0-6 | StatusReasonDetails": "asset edits fail at runtime" + }, + "Betwitched": { + "ID": "b_wandert.Betwitched", + "~0.9.0 | Status": "AssumeBroken", + "~0.9.0 | StatusReasonDetails": "breaks loading the Forest location" + }, + "Bulk Animal Purchase": { + "ID": "aedenthorn.BulkAnimalPurchase", + "~0.2.1 | Status": "AssumeBroken", + "~0.2.1 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Build On Any Tile": { + "ID": "Esca.BuildOnAnyTile", + "~1.1.2 | Status": "AssumeBroken", + "~1.1.2 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Carry Chest": { + "ID": "spacechase0.CarryChest", + "~1.3.6 | Status": "AssumeBroken", + "~1.3.6 | StatusReasonDetails": "loses items when chest is placed" + }, + "Categories in Recipes": { + "ID": "Traktori.CategoriesInRecipes", + "~1.0.0 | Status": "AssumeBroken", + "~1.0.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Change Horse Sounds": { + "ID": "CF.ChangeHorseSounds", + "~1.3.1 | Status": "AssumeBroken", + "~1.3.1 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Configure Machine Speed": { + "ID": "BayesianBandit.ConfigureMachineSpeed", + "~2.0.0-beta.2 | Status": "AssumeBroken", + "~2.0.0-beta.2 | StatusReasonDetails": "causes runtime errors" + }, + "Crop Walker": { + "ID": "MindMeltMax.CropWalker", + "~1.0.0 | Status": "AssumeBroken", + "~1.0.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Custom Dungeon Floors": { + "ID": "Aedenthorn.CustomMonsterFloors", + "~0.7.0 | Status": "AssumeBroken", + "~0.7.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Custom Starter Package": { + "ID": "aedenthorn.CustomStarterPackage", + "~0.2.0 | Status": "AssumeBroken", + "~0.2.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Custom Tool Effect": { + "ID": "ZaneYork.CustomToolEffect", + "~1.1.0 | Status": "AssumeBroken", + "~1.1.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Death Tweaks": { + "ID": "aedenthorn.DeathTweaks", + "~0.1.1 | Status": "AssumeBroken", + "~0.1.1 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Enemy Health Bars": { + "ID": "Speeder.HealthBars", + "~1.9.1-unofficial.2-libraryaddict | Status": "AssumeBroken", + "~1.9.1-unofficial.2-libraryaddict | StatusReasonDetails": "causes runtime errors" + }, + "Extreme Weather": { + "ID": "BlaDe.ExtremeWeather", + "~1.0.0 | Status": "AssumeBroken", + "~1.0.0 | StatusReasonDetails": "causes runtime crash" + }, + "Farmageddon": { + "ID": "maxvollmer.farmageddon", + "~3.0.0 | Status": "AssumeBroken", + "~3.0.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Fast Loads": { + "ID": "spajus.fastloads", + "~1.0.3 | Status": "AssumeBroken", + "~1.0.3 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Fish Exclusions": { + "ID": "GZhynko.FishExclusions", + "~1.1.5 | Status": "AssumeBroken", + "~1.1.5 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Fishing Progression": { + "ID": "chadlymasterson.fishingprogression", + "~1.0.1 | Status": "AssumeBroken", + "~1.0.1 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Fixed Weapons Damage": { + "ID": "BlueSight.FixedWeaponsDamage", + "~1.0.0 | Status": "AssumeBroken", + "~1.0.0 | StatusReasonDetails": "Harmony patches and asset edits fail at runtime" + }, + "Flower Rain": { + "ID": "spacechase0.FlowerRain", + "~1.1.4 | Status": "AssumeBroken", + "~1.1.4 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Friendly Divorce": { + "ID": "aedenthorn.FriendlyDivorce", + "~0.3.0 | Status": "AssumeBroken", + "~0.3.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Furniture Recolor": { + "ID": "aedenthorn.FurnitureRecolor", + "~0.1.0 | Status": "AssumeBroken", + "~0.1.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Hibernation": { + "ID": "Shockah.Hibernation", + "~1.0.0 | Status": "AssumeBroken", + "~1.0.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Hugs and Kisses": { + "ID": "aedenthorn.HugsAndKisses", + "~0.4.0 | Status": "AssumeBroken", + "~0.4.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Mayo Hats": { + "ID": "spacechase0.MayoHats", + "~1.0.0 | Status": "AssumeBroken", + "~1.0.0 | StatusReasonDetails": "affected by breaking changes in the Json Assets mod API" + }, + "Mayo Mart": { + "ID": "aedenthorn.MayoMart", + "~0.1.0 | Status": "AssumeBroken", + "~0.1.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Misophonia Accessibility": { + "ID": "TheFluffyRobot.MisophoniaAccessibility", + "~3.0.1 | Status": "AssumeBroken", + "~3.0.1 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Mod Updater": { + "ID": "Platonymous.ModUpdater", + "~1.0.6 | Status": "AssumeBroken", + "~1.0.6 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "More Giant Crops": { + "ID": "spacechase0.MoreGiantCrops", + "~1.2.0 | Status": "AssumeBroken", + "~1.2.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "More Grass": { + "ID": "EpicBellyFlop45.MoreGrass", + "~1.2.1 | Status": "AssumeBroken", + "~1.2.1 | StatusReasonDetails": "causes runtime crashes" + }, + "More Multiplayer Info": { + "ID": "cheesysteak.moremultiplayerinfo", + "~3.0.1 | Status": "AssumeBroken", + "~3.0.1 | StatusReasonDetails": "causes runtime errors" + }, + "Move Between Buildings": { + "ID": "Vilaboa.MoveBetweenBuildings", + "~1.0.0 | Status": "AssumeBroken", + "~1.0.0 | StatusReasonDetails": "causes runtime errors" + }, + "Movie Theater Tweaks": { + "ID": "aedenthorn.MovieTheatreTweaks", + "~0.2.0 | Status": "AssumeBroken", + "~0.2.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "MultiSave": { + "ID": "aedenthorn.MultiSave", + "~0.1.8 | Status": "AssumeBroken", + "~0.1.8 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Musical Paths": { + "ID": "aedenthorn.MusicalPaths", + "~0.1.1 | Status": "AssumeBroken", + "~0.1.1 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "NPC Clothing": { + "ID": "aedenthorn.NPCClothing", + "~0.1.0 | Status": "AssumeBroken", + "~0.1.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "One Click Shed Reloader": { + "ID": "BitwiseJonMods.OneClickShedReloader", + "~1.1.1 | Status": "AssumeBroken", + "~1.1.1 | StatusReasonDetails": "causes runtime errors" + }, + "One Sprinkler One Scarecrow": { + "ID": "mizzion.onesprinkleronescarecrow", + "~2.1.0 | Status": "AssumeBroken", + "~2.1.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Ore Increaser": { + "ID": "crazywig.oreincrease", + "~1.0.0 | Status": "AssumeBroken", + "~1.0.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Ore Increase V3": { + "ID": "OreIncreaseV3", + "~3.0.0 | Status": "AssumeBroken", + "~3.0.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Pacifist Valley": { + "ID": "Aedenthorn.PacifistValley", + "~1.0.0 | Status": "AssumeBroken", + "~1.0.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Passable Crops": { + "ID": "NCarigon.PassableCrops", + "~1.0.9 | Status": "AssumeBroken", + "~1.0.9 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Pelican TTS": { + "ID": "Platonymous.PelicanTTS", + "~1.13.2 | Status": "AssumeBroken", + "~1.13.2 | StatusReasonDetails": "asset edits fail at runtime" + }, + "Persistent Mines": { + "ID": "spacechase0.PersistentMines", + "~1.0.1 | Status": "AssumeBroken", + "~1.0.1 | StatusReasonDetails": "affected by breaking changes in the SpaceCore mod API" + }, + "Placeable Mine Shafts": { + "ID": "Aedenthorn.PlaceShaft", + "~2.0.0 | Status": "AssumeBroken", + "~2.0.0 | StatusReasonDetails": "affected by breaking changes in the Json Assets mod API" + }, + "Platonic Partners and Friendships": { + "ID": "Amaranthacyan.PPAFSMAPI", + "~2.2.3 | Status": "AssumeBroken", + "~2.2.3 | StatusReasonDetails": "asset edits fail at runtime" + }, + "Qi Chest": { + "ID": "spacechase0.QiChest", + "~1.0.0 | Status": "AssumeBroken", + "~1.0.0 | StatusReasonDetails": "causes crash during game launch due to Harmony" + }, + "Quest Time Limits": { + "ID": "aedenthorn.QuestTimeLimits", + "~0.1.3 | Status": "AssumeBroken", + "~0.1.3 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Riverland Farm But You Need to Build Bridges": { + "ID": "idermailer.riverfarmButBridge", + "~1.0.0 | Status": "AssumeBroken", + "~1.0.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Screenshot Everywhere": { + "ID": "Gaiadin.Stardew-Screenshot-Everywhere", + "~1.4.1 | Status": "AssumeBroken", + "~1.4.1 | StatusReasonDetails": "causes runtime errors" + }, + "SeaCliff Farm": { + "ID": "freethejunimos.seaclifffarm", + "~1.0.2 | Status": "AssumeBroken", + "~1.0.2 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Seed Maker - Better Quality More Seeds": { + "ID": "vabrell.sm_bqms", + "~1.3.3 | Status": "AssumeBroken", + "~1.3.3 | StatusReasonDetails": "asset edits fail at runtime" + }, + "Seed Maker Tweaks": { + "ID": "aedenthorn.SeedMakerTweaks", + "~0.2.0 | Status": "AssumeBroken", + "~0.2.0 | StatusReasonDetails": "affected by breaking changes in the Json Assets mod API" + }, + "Shut Up": { + "ID": "gekox.shutUp", + "~1.1.0 | Status": "AssumeBroken", + "~1.1.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Sixty-Nine Shirt": { + "ID": "aedenthorn.SixtyNine", + "~0.2.69 | Status": "AssumeBroken", + "~0.2.69 | StatusReasonDetails": "causes runtime errors" + }, + "Skip Intro": { + "ID": "Pathoschild.SkipIntro", + "~1.9.9-alpha.20220227 | Status": "AssumeBroken", + "~1.9.9-alpha.20220227 | StatusReasonDetails": "causes crash during game launch" + }, + "Skull Cavern Drill": { + "ID": "S1mmyy.SkullCavernDrill", + "~1.0.1 | Status": "AssumeBroken", + "~1.0.1 | StatusReasonDetails": "affected by breaking changes in the Json Assets mod API" + }, + "Skull Cavern Drill Redux": { + "ID": "NetworkOverflow.SkullCavernDrillRedux", + "~1.0.1 | Status": "AssumeBroken", + "~1.0.1 | StatusReasonDetails": "affected by breaking changes in the Json Assets mod API" + }, + "Socializing Skill": { + "ID": "drbirbdev.SocializingSkill", + "~2.0.5 | Status": "AssumeBroken", + "~2.0.5 | StatusReasonDetails": "Harmony patches fail at runtime and cause hard game crash" + }, + "Split Screen Manager": { + "ID": "RomenH.SplitScreenManager", + "~1.0.0 | Status": "AssumeBroken", + "~1.0.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Stardew Hack": { + "ID": "bcmpinc.StardewHack", + "~6.0.0 | Status": "AssumeBroken", + "~6.0.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Statue of Generosity": { + "ID": "spacechase0.StatueOfGenerosity", + "~1.1.3 | Status": "AssumeBroken", + "~1.1.3 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Statue Shorts": { + "ID": "aedenthorn.StatueShorts", + "~0.1.0 | Status": "AssumeBroken", + "~0.1.0 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "Swizzy Meads": { + "ID": "SwizzyStudios.SwizzyMeads", + "~1.0.1-alpha | Status": "AssumeBroken", + "~1.0.1-alpha | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "The Adventurer's Life Expanded": { + "ID": "HamioDracny.TALE.SMAPI", + "~1.2.1 | Status": "AssumeBroken", + "~1.2.1 | StatusReasonDetails": "Harmony patches fail at runtime" + }, + "This Mod Is Organic": { + "ID": "SweetPanda.Organic", + "~1.0.0 | Status": "AssumeBroken", + "~1.0.0 | StatusReasonDetails": "affected by breaking changes in the Json Assets mod API" + }, + "TimeSpeed": { + "ID": "cantorsdust.TimeSpeed", + "~2.7.7-alpha.20240220 | Status": "AssumeBroken", + "~2.7.7-alpha.20240220 | StatusReasonDetails": "causes runtime time issues before 2.7.7" + }, + "Wealth is Health": { + "ID": "QiTheMysterious.WealthIsHealth", + "~0.1.2 | Status": "AssumeBroken", + "~0.1.2 | StatusReasonDetails": "causes runtime errors" }, /********* @@ -198,16 +579,6 @@ "~2.0.1 | Status": "AssumeBroken", "~2.0.1 | StatusReasonDetails": "requires 'Microsoft.Xna.Framework.Audio.AudioCategory' which doesn't exist in MonoGame" }, - "Skip Intro": { - "ID": "Pathoschild.SkipIntro", - "~1.9.1 | Status": "AssumeBroken", - "~1.9.1 | StatusReasonDetails": "causes freeze during game launch" - }, - "Stardew Hack": { - "ID": "bcmpinc.StardewHack", - "~5.1.0 | Status": "AssumeBroken", - "~5.1.0 | StatusReasonDetails": "runtime error when initializing due to an API change between .NET Framework and .NET 5" - }, "Stardew Valley Expanded": { "ID": "FlashShifter.SVECode", "~1.13.11 | Status": "AssumeBroken", @@ -277,11 +648,6 @@ "~4.1.0 | Status": "AssumeBroken", "~4.1.0 | StatusReasonDetails": "causes Harmony patching errors for other mods" // requested by the mod author }, - "Tree Spread": { - "ID": "bcmpinc.TreeSpread", - "~4.2.0 | Status": "AssumeBroken", - "~4.2.0 | StatusReasonDetails": "causes Harmony patching errors for other mods" // requested by the mod author - }, "Wear More Rings": { "ID": "bcmpinc.WearMoreRings", "~4.1.0 | Status": "AssumeBroken", @@ -305,38 +671,27 @@ "~1.3.4 | Status": "AssumeBroken", "~1.3.4 | StatusReasonDetails": "has no effect due to changes in Stardew Valley 1.5, causes crashes in other mods like Chests Anywhere" }, - "Custom Furniture": { "ID": "Platonymous.CustomFurniture", "~0.11.2 | Status": "AssumeBroken", "~0.11.2 | StatusReasonDetails": "causes errors and custom furniture no longer work in Stardew Valley 1.5" }, - "Custom Localization": { "ID": "ZaneYork.CustomLocalization", "FormerIDs": "SMAPI.CustomLocalization", // changed in 1.0.1 "~1.1 | Status": "AssumeBroken", "~1.1 | StatusReasonDetails": "reflection error due to renamed _localizedAssets field" }, - "Geode Info Menu": { "ID": "cat.geodeinfomenu", "~1.5.2 | Status": "AssumeBroken", "~1.5.2 | StatusReasonDetails": "shows no info, freezes game if you try to search" }, - "Mod Settings Tab": { "ID": "GilarF.ModSettingsTab", "~0.2.1 | Status": "AssumeBroken", "~0.2.1 | StatusReasonDetails": "fails extending title menu" }, - - "More Grass": { - "ID": "EpicBellyFlop45.MoreGrass", - "~1.0.8 | Status": "AssumeBroken", - "~1.0.8 | StatusReasonDetails": "crashes on save load" - }, - "TreeTransplant": { "ID": "TreeTransplant", "~1.0.9 | Status": "AssumeBroken", diff --git a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json index bd9e74272..f56841db3 100644 --- a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json +++ b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json @@ -14,9 +14,9 @@ "title": "Format version", "description": "The format version. You should always use the latest version to enable the latest features, avoid obsolete behavior, and reduce load times.", "type": "string", - "pattern": "^1\\.29\\.[0-9]+$", + "pattern": "^2\\.0\\.[0-9]+$", "@errorMessages": { - "pattern": "Incorrect value '@value'. You should always use the latest format version (currently 1.29.0) to enable the latest features, avoid obsolete behavior, and reduce load times." + "pattern": "Incorrect value '@value'. You should always use the latest format version (currently 2.0.0) to enable the latest features, avoid obsolete behavior, and reduce load times." } }, "ConfigSchema": { @@ -112,7 +112,7 @@ "allOf": [ { "not": { - "pattern": "\b\\.\\.[/\\]" + "pattern": "\\b\\.\\.[/\\]" } }, { @@ -216,7 +216,7 @@ "allOf": [ { "not": { - "pattern": "\b\\.\\.[/\\]" + "pattern": "\\b\\.\\.[/\\]" } }, { diff --git a/src/SMAPI.Web/wwwroot/schemas/manifest.json b/src/SMAPI.Web/wwwroot/schemas/manifest.json index 727e5cbd3..7ea977d13 100644 --- a/src/SMAPI.Web/wwwroot/schemas/manifest.json +++ b/src/SMAPI.Web/wwwroot/schemas/manifest.json @@ -71,6 +71,11 @@ "title": "Minimum API version", "description": "The minimum SMAPI version needed to use this mod. If a player tries to use the mod with an older SMAPI version, they'll see a friendly message saying they need to update SMAPI. This also serves as a proxy for the minimum game version, since SMAPI itself enforces a minimum game version.", "$ref": "#/definitions/SemanticVersion" + }, + "MinimumGameVersion": { + "title": "Minimum Game version", + "description": "The minimum Stardew Valley version needed to use this mod. If a player tries to use the mod with an older Stardew Valley version, they'll see a friendly message saying they need to update Stardew Valley.", + "$ref": "#/definitions/SemanticVersion" }, "Dependencies": { "title": "Mod dependencies", @@ -103,7 +108,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^(?i)(Chucklefish:\\d+|Nexus:\\d+|GitHub:[A-Za-z0-9_\\-]+/[A-Za-z0-9_\\-]+|ModDrop:\\d+)(?: *@ *[a-zA-Z0-9_]+ *)?$", + "pattern": "^(?i)(Chucklefish:\\d+|Nexus:\\d+|GitHub:[A-Za-z0-9_\\-\\.]+/[A-Za-z0-9_\\-]+|ModDrop:\\d+)(?: *@ *[a-zA-Z0-9_]+ *)?$", "@errorMessages": { "pattern": "Invalid update key; see https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest#Update_checks for more info." } diff --git a/src/SMAPI.sln b/src/SMAPI.sln index 99b9dc834..6a48e34e7 100644 --- a/src/SMAPI.sln +++ b/src/SMAPI.sln @@ -51,8 +51,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Mods", "Mods", "{AE9A4D46-E EndProject Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "SMAPI.Internal", "SMAPI.Internal\SMAPI.Internal.shproj", "{85208F8D-6FD1-4531-BE05-7142490F59FE}" EndProject -Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "SMAPI.Internal.Patching", "SMAPI.Internal.Patching\SMAPI.Internal.Patching.shproj", "{6C16E948-3E5C-47A7-BF4B-07A7469A87A5}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.ModBuildConfig.Analyzer.Tests", "SMAPI.ModBuildConfig.Analyzer.Tests\SMAPI.ModBuildConfig.Analyzer.Tests.csproj", "{680B2641-81EA-467C-86A5-0E81CDC57ED0}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Tests", "SMAPI.Tests\SMAPI.Tests.csproj", "{AA95884B-7097-476E-92C8-D0500DE9D6D1}" @@ -62,7 +60,6 @@ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Installer", "SMAPI.Installer\SMAPI.Installer.csproj", "{0A9BB24F-15FF-4C26-B1A2-81F7AE316518}" ProjectSection(ProjectDependencies) = postProject {0634EA4C-3B8F-42DB-AEA6-CA9E4EF6E92F} = {0634EA4C-3B8F-42DB-AEA6-CA9E4EF6E92F} - {491E775B-EAD0-44D4-B6CA-F1FC3E316D33} = {491E775B-EAD0-44D4-B6CA-F1FC3E316D33} {CD53AD6F-97F4-4872-A212-50C2A0FD3601} = {CD53AD6F-97F4-4872-A212-50C2A0FD3601} {E6DA2198-7686-4F1D-B312-4A4DC70884C0} = {E6DA2198-7686-4F1D-B312-4A4DC70884C0} EndProjectSection @@ -73,8 +70,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.ModBuildConfig.Analyz EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Mods.ConsoleCommands", "SMAPI.Mods.ConsoleCommands\SMAPI.Mods.ConsoleCommands.csproj", "{0634EA4C-3B8F-42DB-AEA6-CA9E4EF6E92F}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Mods.ErrorHandler", "SMAPI.Mods.ErrorHandler\SMAPI.Mods.ErrorHandler.csproj", "{491E775B-EAD0-44D4-B6CA-F1FC3E316D33}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Mods.SaveBackup", "SMAPI.Mods.SaveBackup\SMAPI.Mods.SaveBackup.csproj", "{CD53AD6F-97F4-4872-A212-50C2A0FD3601}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Toolkit", "SMAPI.Toolkit\SMAPI.Toolkit.csproj", "{08184F74-60AD-4EEE-A78C-F4A35ADE6246}" @@ -107,13 +102,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Tests.ModApiConsumer" EndProject Global GlobalSection(SharedMSBuildProjectFiles) = preSolution - SMAPI.Internal\SMAPI.Internal.projitems*{0634ea4c-3b8f-42db-aea6-ca9e4ef6e92f}*SharedItemsImports = 5 SMAPI.Internal\SMAPI.Internal.projitems*{0a9bb24f-15ff-4c26-b1a2-81f7ae316518}*SharedItemsImports = 5 - SMAPI.Internal.Patching\SMAPI.Internal.Patching.projitems*{6c16e948-3e5c-47a7-bf4b-07a7469a87a5}*SharedItemsImports = 13 SMAPI.Internal\SMAPI.Internal.projitems*{80efd92f-728f-41e0-8a5b-9f6f49a91899}*SharedItemsImports = 5 SMAPI.Internal\SMAPI.Internal.projitems*{85208f8d-6fd1-4531-be05-7142490f59fe}*SharedItemsImports = 13 SMAPI.Internal\SMAPI.Internal.projitems*{cd53ad6f-97f4-4872-a212-50c2a0fd3601}*SharedItemsImports = 5 - SMAPI.Internal.Patching\SMAPI.Internal.Patching.projitems*{e6da2198-7686-4f1d-b312-4a4dc70884c0}*SharedItemsImports = 5 SMAPI.Internal\SMAPI.Internal.projitems*{e6da2198-7686-4f1d-b312-4a4dc70884c0}*SharedItemsImports = 5 EndGlobalSection GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -149,10 +141,6 @@ Global {0634EA4C-3B8F-42DB-AEA6-CA9E4EF6E92F}.Debug|Any CPU.Build.0 = Debug|Any CPU {0634EA4C-3B8F-42DB-AEA6-CA9E4EF6E92F}.Release|Any CPU.ActiveCfg = Release|Any CPU {0634EA4C-3B8F-42DB-AEA6-CA9E4EF6E92F}.Release|Any CPU.Build.0 = Release|Any CPU - {491E775B-EAD0-44D4-B6CA-F1FC3E316D33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {491E775B-EAD0-44D4-B6CA-F1FC3E316D33}.Debug|Any CPU.Build.0 = Debug|Any CPU - {491E775B-EAD0-44D4-B6CA-F1FC3E316D33}.Release|Any CPU.ActiveCfg = Release|Any CPU - {491E775B-EAD0-44D4-B6CA-F1FC3E316D33}.Release|Any CPU.Build.0 = Release|Any CPU {CD53AD6F-97F4-4872-A212-50C2A0FD3601}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CD53AD6F-97F4-4872-A212-50C2A0FD3601}.Debug|Any CPU.Build.0 = Debug|Any CPU {CD53AD6F-97F4-4872-A212-50C2A0FD3601}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -188,11 +176,9 @@ Global {EB35A917-67B9-4EFA-8DFC-4FB49B3949BB} = {86C452BE-D2D8-45B4-B63F-E329EB06CEDA} {5947303D-3512-413A-9009-7AC43F5D3513} = {EB35A917-67B9-4EFA-8DFC-4FB49B3949BB} {85208F8D-6FD1-4531-BE05-7142490F59FE} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11} - {6C16E948-3E5C-47A7-BF4B-07A7469A87A5} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11} {680B2641-81EA-467C-86A5-0E81CDC57ED0} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11} {AA95884B-7097-476E-92C8-D0500DE9D6D1} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11} {0634EA4C-3B8F-42DB-AEA6-CA9E4EF6E92F} = {AE9A4D46-E910-4293-8BC4-673F85FFF6A5} - {491E775B-EAD0-44D4-B6CA-F1FC3E316D33} = {AE9A4D46-E910-4293-8BC4-673F85FFF6A5} {CD53AD6F-97F4-4872-A212-50C2A0FD3601} = {AE9A4D46-E910-4293-8BC4-673F85FFF6A5} {4D661178-38FB-43E4-AA5F-9B0406919344} = {09CF91E5-5BAB-4650-A200-E5EA9A633046} {CAA1488E-842B-433D-994D-1D3D0B5DD125} = {09CF91E5-5BAB-4650-A200-E5EA9A633046} diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index eab900f92..1234ff25b 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -6,9 +6,6 @@ using Mono.Cecil; using StardewModdingAPI.Enums; using StardewModdingAPI.Framework; -#if SMAPI_DEPRECATED -using StardewModdingAPI.Framework.Deprecations; -#endif using StardewModdingAPI.Framework.ModLoading; using StardewModdingAPI.Toolkit.Framework; using StardewModdingAPI.Toolkit.Utilities; @@ -52,7 +49,7 @@ internal static class EarlyConstants internal static int? LogScreenId { get; set; } /// SMAPI's current raw semantic version. - internal static string RawApiVersion = "3.18.6"; + internal static string RawApiVersion = "4.0.8"; } /// Contains SMAPI's constants and assumptions. @@ -68,10 +65,10 @@ public static class Constants public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion(EarlyConstants.RawApiVersion); /// The minimum supported version of Stardew Valley. - public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.5.6"); + public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.6.4"); /// The maximum supported version of Stardew Valley, if any. - public static ISemanticVersion? MaximumGameVersion { get; } = new GameVersion("1.5.6"); + public static ISemanticVersion? MaximumGameVersion { get; } = null; /// The target game platform. public static GamePlatform TargetPlatform { get; } = EarlyConstants.Platform; @@ -79,25 +76,6 @@ public static class Constants /// The game framework running the game. public static GameFramework GameFramework { get; } = EarlyConstants.GameFramework; -#if SMAPI_DEPRECATED - /// The path to the game folder. - [Obsolete($"Use {nameof(Constants)}.{nameof(GamePath)} instead. This property will be removed in SMAPI 4.0.0.")] - public static string ExecutionPath - { - get - { - SCore.DeprecationManager.Warn( - source: null, - nounPhrase: $"{nameof(Constants)}.{nameof(Constants.ExecutionPath)}", - version: "3.14.0", - severity: DeprecationLevel.PendingRemoval - ); - - return Constants.GamePath; - } - } -#endif - /// The path to the game folder. public static string GamePath { get; } = EarlyConstants.GamePath; @@ -139,9 +117,12 @@ public static string ExecutionPath /// The file path for the SMAPI configuration file. internal static string ApiConfigPath => Path.Combine(Constants.InternalFilesPath, "config.json"); - /// The file path for the overrides file for , which is applied over it. + /// The file path for the per-user override file, which is applied over it. internal static string ApiUserConfigPath => Path.Combine(Constants.InternalFilesPath, "config.user.json"); + /// The file path for the per-mods-folder override file, which is applied over it. + internal static string ApiModGroupConfigPath => Path.Combine(ModsPath, "SMAPI-config.json"); + /// The file path for the SMAPI metadata file. internal static string ApiMetadataPath => Path.Combine(Constants.InternalFilesPath, "metadata.json"); diff --git a/src/SMAPI/Events/AssetRequestedEventArgs.cs b/src/SMAPI/Events/AssetRequestedEventArgs.cs index d6561028b..5ad483e96 100644 --- a/src/SMAPI/Events/AssetRequestedEventArgs.cs +++ b/src/SMAPI/Events/AssetRequestedEventArgs.cs @@ -71,6 +71,7 @@ internal void SetMod(IModMetadata mod) /// /// The asset doesn't need to exist in the game's Content folder. If any mod loads the asset, the game will see it as an existing asset as if it was in that folder. /// Each asset can logically only have one initial instance. If multiple loads apply at the same time, SMAPI will use the parameter to decide what happens. If you're making changes to the existing asset instead of replacing it, you should use instead to avoid those limitations and improve mod compatibility. + /// Do not return a cached instance. SMAPI takes ownership of the returned asset and may edit, resize, or dispose it as needed. Returning a cached instance may cause object-disposed errors or cache poisoning (where reloading the asset doesn't undo previously applied edits). If you need a reference to the final edited asset, use the event. /// /// public void LoadFrom(Func load, AssetLoadPriority priority, string? onBehalfOf = null) @@ -90,13 +91,7 @@ public void LoadFrom(Func load, AssetLoadPriority priority, string? onBe /// The expected data type. The main supported types are , , dictionaries, and lists; other types may be supported by the game's content pipeline. /// The relative path to the file in your mod folder. /// If there are multiple loads that apply to the same asset, the priority with which this one should be applied. - /// - /// Usage notes: - /// - /// The asset doesn't need to exist in the game's Content folder. If any mod loads the asset, the game will see it as an existing asset as if it was in that folder. - /// Each asset can logically only have one initial instance. If multiple loads apply at the same time, SMAPI will raise an error and ignore all of them. If you're making changes to the existing asset instead of replacing it, you should use instead to avoid those limitations and improve mod compatibility. - /// - /// + /// public void LoadFromModFile(string relativePath, AssetLoadPriority priority) where TAsset : notnull { diff --git a/src/SMAPI/Events/IDisplayEvents.cs b/src/SMAPI/Events/IDisplayEvents.cs index dbf8d90f4..750094e0f 100644 --- a/src/SMAPI/Events/IDisplayEvents.cs +++ b/src/SMAPI/Events/IDisplayEvents.cs @@ -9,6 +9,12 @@ public interface IDisplayEvents /// Raised after a game menu is opened, closed, or replaced. event EventHandler MenuChanged; + /// Raised before the game draws a specific step in the rendering cycle. + event EventHandler RenderingStep; + + /// Raised after the game draws a specific step in the rendering cycle. + event EventHandler RenderedStep; + /// Raised before the game draws anything to the screen in a draw tick, as soon as the sprite batch is opened. The sprite batch may be closed and reopened multiple times after this event is called, but it's only raised once per draw tick. This event isn't useful for drawing to the screen, since the game will draw over it. event EventHandler Rendering; diff --git a/src/SMAPI/Events/RenderedStepEventArgs.cs b/src/SMAPI/Events/RenderedStepEventArgs.cs new file mode 100644 index 000000000..2c90b4dee --- /dev/null +++ b/src/SMAPI/Events/RenderedStepEventArgs.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; +using StardewValley.Mods; + +namespace StardewModdingAPI.Events +{ + /// Event arguments for an event. + public class RenderedStepEventArgs : EventArgs + { + /********* + ** Fields + *********/ + /// The cached instance for each render step. + private static readonly Dictionary Instances = new(); + + + /********* + ** Accessors + *********/ + /// The current step in the render cycle. + public RenderSteps Step { get; } + + /// The sprite batch being drawn. Add anything you want to appear on-screen to this sprite batch. + public SpriteBatch SpriteBatch => Game1.spriteBatch; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The current step in the render cycle. + public RenderedStepEventArgs(RenderSteps step) + { + this.Step = step; + } + + /// Get an instance for a render step. + /// The current step in the render cycle. + internal static RenderedStepEventArgs Instance(RenderSteps step) + { + if (!RenderedStepEventArgs.Instances.TryGetValue(step, out RenderedStepEventArgs instance)) + RenderedStepEventArgs.Instances[step] = instance = new(step); + + return instance; + } + } +} diff --git a/src/SMAPI/Events/RenderingStepEventArgs.cs b/src/SMAPI/Events/RenderingStepEventArgs.cs new file mode 100644 index 000000000..bedad203b --- /dev/null +++ b/src/SMAPI/Events/RenderingStepEventArgs.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; +using StardewValley.Mods; + +namespace StardewModdingAPI.Events +{ + /// Event arguments for an event. + public class RenderingStepEventArgs : EventArgs + { + /********* + ** Fields + *********/ + /// The cached instance for each render step. + private static readonly Dictionary Instances = new(); + + + /********* + ** Accessors + *********/ + /// The current step in the render cycle. + public RenderSteps Step { get; } + + /// The sprite batch being drawn. Add anything you want to appear on-screen to this sprite batch. + public SpriteBatch SpriteBatch => Game1.spriteBatch; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The current step in the render cycle. + public RenderingStepEventArgs(RenderSteps step) + { + this.Step = step; + } + + /// Get an instance for a render step. + /// The current step in the render cycle. + internal static RenderingStepEventArgs Instance(RenderSteps step) + { + if (!RenderingStepEventArgs.Instances.TryGetValue(step, out RenderingStepEventArgs instance)) + RenderingStepEventArgs.Instances[step] = instance = new(step); + + return instance; + } + } +} diff --git a/src/SMAPI/Framework/CommandManager.cs b/src/SMAPI/Framework/CommandManager.cs index d3b9c8ee8..b20e5ceb8 100644 --- a/src/SMAPI/Framework/CommandManager.cs +++ b/src/SMAPI/Framework/CommandManager.cs @@ -2,8 +2,8 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Text; using StardewModdingAPI.Framework.Commands; +using StardewValley; namespace StardewModdingAPI.Framework { @@ -109,7 +109,7 @@ public bool TryParse(string? input, [NotNullWhen(true)] out string? name, [NotNu } // parse input - args = this.ParseArgs(input); + args = ArgUtility.SplitBySpaceQuoteAware(input); name = this.GetNormalizedName(args[0])!; args = args.Skip(1).ToArray(); @@ -138,56 +138,10 @@ public bool TryParse(string? input, [NotNullWhen(true)] out string? name, [NotNu return this.Commands.TryGetValue(name, out command); } - /// Trigger a command. - /// The command name. - /// The command arguments. - /// Returns whether a matching command was triggered. - public bool Trigger(string? name, string[] arguments) - { - // get normalized name - name = this.GetNormalizedName(name)!; - if (string.IsNullOrWhiteSpace(name)) - return false; - - // get command - if (this.Commands.TryGetValue(name, out Command? command)) - { - command.Callback.Invoke(name, arguments); - return true; - } - - return false; - } - /********* ** Private methods *********/ - /// Parse a string into command arguments. - /// The string to parse. - private string[] ParseArgs(string input) - { - bool inQuotes = false; - IList args = new List(); - StringBuilder currentArg = new(); - foreach (char ch in input) - { - if (ch == '"') - inQuotes = !inQuotes; - else if (!inQuotes && char.IsWhiteSpace(ch)) - { - args.Add(currentArg.ToString()); - currentArg.Clear(); - } - else - currentArg.Append(ch); - } - - args.Add(currentArg.ToString()); - - return args.Where(item => !string.IsNullOrWhiteSpace(item)).ToArray(); - } - /// Try to parse a 'screen=X' command argument, which specifies the screen that should receive the command. /// The raw argument to parse. /// The parsed screen ID, if any. diff --git a/src/SMAPI/Framework/Content/AssetInfo.cs b/src/SMAPI/Framework/Content/AssetInfo.cs index 52ef02e64..30dc325a5 100644 --- a/src/SMAPI/Framework/Content/AssetInfo.cs +++ b/src/SMAPI/Framework/Content/AssetInfo.cs @@ -1,9 +1,6 @@ using System; using System.Collections.Generic; using Microsoft.Xna.Framework.Graphics; -#if SMAPI_DEPRECATED -using StardewModdingAPI.Framework.Deprecations; -#endif namespace StardewModdingAPI.Framework.Content { @@ -34,30 +31,6 @@ internal class AssetInfo : IAssetInfo /// public Type DataType { get; } -#if SMAPI_DEPRECATED - /// - [Obsolete($"Use {nameof(AssetInfo.Name)} or {nameof(AssetInfo.NameWithoutLocale)} instead. This property will be removed in SMAPI 4.0.0.")] - public string AssetName - { - get - { - SCore.DeprecationManager.Warn( - source: null, - nounPhrase: $"{nameof(IAssetInfo)}.{nameof(IAssetInfo.AssetName)}", - version: "3.14.0", - severity: DeprecationLevel.PendingRemoval, - unlessStackIncludes: new[] - { - $"{typeof(AssetInterceptorChange).FullName}.{nameof(AssetInterceptorChange.CanIntercept)}", - $"{typeof(ContentCoordinator).FullName}.{nameof(ContentCoordinator.GetAssetOperations)}" - } - ); - - return this.NameWithoutLocale.Name; - } - } -#endif - /********* ** Public methods @@ -75,28 +48,6 @@ public AssetInfo(string? locale, IAssetName assetName, Type type, Func - [Obsolete($"Use {nameof(Name)}.{nameof(IAssetName.IsEquivalentTo)} or {nameof(AssetInfo.NameWithoutLocale)}.{nameof(IAssetName.IsEquivalentTo)} instead. This method will be removed in SMAPI 4.0.0.")] - public bool AssetNameEquals(string path) - { - SCore.DeprecationManager.Warn( - source: null, - nounPhrase: $"{nameof(IAssetInfo)}.{nameof(IAssetInfo.AssetNameEquals)}", - version: "3.14.0", - severity: DeprecationLevel.PendingRemoval, - unlessStackIncludes: new[] - { - $"{typeof(AssetInterceptorChange).FullName}.{nameof(AssetInterceptorChange.CanIntercept)}", - $"{typeof(ContentCoordinator).FullName}.{nameof(ContentCoordinator.GetAssetOperations)}" - } - ); - - - return this.NameWithoutLocale.IsEquivalentTo(path); - } -#endif - /********* ** Protected methods diff --git a/src/SMAPI/Framework/Content/AssetInterceptorChange.cs b/src/SMAPI/Framework/Content/AssetInterceptorChange.cs deleted file mode 100644 index 3b5068dca..000000000 --- a/src/SMAPI/Framework/Content/AssetInterceptorChange.cs +++ /dev/null @@ -1,106 +0,0 @@ -#if SMAPI_DEPRECATED -using System; -using System.Reflection; -using StardewModdingAPI.Internal; - -namespace StardewModdingAPI.Framework.Content -{ - /// A wrapper for and for internal cache invalidation. - internal class AssetInterceptorChange - { - /********* - ** Accessors - *********/ - /// The mod which registered the interceptor. - public IModMetadata Mod { get; } - - /// The interceptor instance. - public object Instance { get; } - - /// Whether the asset interceptor was added since the last tick. Mutually exclusive with . - public bool WasAdded { get; } - - /// Whether the asset interceptor was removed since the last tick. Mutually exclusive with . - public bool WasRemoved => this.WasAdded; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The mod registering the interceptor. - /// The interceptor. This must be an or instance. - /// Whether the asset interceptor was added since the last tick; else removed. - public AssetInterceptorChange(IModMetadata mod, object instance, bool wasAdded) - { - this.Mod = mod ?? throw new ArgumentNullException(nameof(mod)); - this.Instance = instance ?? throw new ArgumentNullException(nameof(instance)); - this.WasAdded = wasAdded; - - if (instance is not (IAssetEditor or IAssetLoader)) - throw new InvalidCastException($"The provided {nameof(instance)} value must be an {nameof(IAssetEditor)} or {nameof(IAssetLoader)} instance."); - } - - /// Get whether this instance can intercept the given asset. - /// Basic metadata about the asset being loaded. - public bool CanIntercept(IAssetInfo asset) - { - MethodInfo? canIntercept = this.GetType().GetMethod(nameof(this.CanInterceptImpl), BindingFlags.Instance | BindingFlags.NonPublic); - if (canIntercept == null) - throw new InvalidOperationException($"SMAPI couldn't access the {nameof(AssetInterceptorChange)}.{nameof(this.CanInterceptImpl)} implementation."); - - return (bool)canIntercept.MakeGenericMethod(asset.DataType).Invoke(this, new object[] { asset })!; - } - - - /********* - ** Private methods - *********/ - /// Get whether this instance can intercept the given asset. - /// The asset type. - /// Basic metadata about the asset being loaded. - private bool CanInterceptImpl(IAssetInfo asset) - { - // check edit - if (this.Instance is IAssetEditor editor) - { - Context.HeuristicModsRunningCode.Push(this.Mod); - try - { - if (editor.CanEdit(asset)) - return true; - } - catch (Exception ex) - { - this.Mod.LogAsMod($"Mod failed when checking whether it could edit asset '{asset.Name}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); - } - finally - { - Context.HeuristicModsRunningCode.TryPop(out _); - } - } - - // check load - if (this.Instance is IAssetLoader loader) - { - Context.HeuristicModsRunningCode.Push(this.Mod); - try - { - if (loader.CanLoad(asset)) - return true; - } - catch (Exception ex) - { - this.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{asset.Name}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); - } - finally - { - Context.HeuristicModsRunningCode.TryPop(out _); - } - } - - return false; - } - } -} -#endif diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs index 86415a5f0..4757b2fb4 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -7,14 +7,12 @@ using System.Text; using System.Threading; using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Events; using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Utilities; -#if SMAPI_DEPRECATED -using StardewModdingAPI.Internal; -#endif using StardewModdingAPI.Metadata; using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities.PathLookups; @@ -83,16 +81,6 @@ internal class ContentCoordinator : IDisposable /// The cached asset load/edit operations to apply, indexed by asset name. private readonly TickCacheDictionary AssetOperationsByKey = new(); -#if SMAPI_DEPRECATED - /// A cache of asset operation groups created for legacy implementations. - [Obsolete("This only exists to support legacy code and will be removed in SMAPI 4.0.0.")] - private readonly Dictionary> LegacyLoaderCache = new(ReferenceEqualityComparer.Instance); - - /// A cache of asset operation groups created for legacy implementations. - [Obsolete("This only exists to support legacy code and will be removed in SMAPI 4.0.0.")] - private readonly Dictionary> LegacyEditorCache = new(ReferenceEqualityComparer.Instance); -#endif - /********* ** Accessors @@ -103,16 +91,6 @@ internal class ContentCoordinator : IDisposable /// The current language as a constant. public LocalizedContentManager.LanguageCode Language => this.MainContentManager.Language; -#if SMAPI_DEPRECATED - /// Interceptors which provide the initial versions of matching assets. - [Obsolete("This only exists to support legacy code and will be removed in SMAPI 4.0.0.")] - public IList> Loaders { get; } = new List>(); - - /// Interceptors which edit matching assets after they're loaded. - [Obsolete("This only exists to support legacy code and will be removed in SMAPI 4.0.0.")] - public IList> Editors { get; } = new List>(); -#endif - /// The absolute path to the . public string FullRootDirectory { get; } @@ -243,7 +221,7 @@ public string GetLocale() public void OnAdditionalLanguagesInitialized() { // update locale cache for custom languages, and load it now (since languages added later won't work) - var customLanguages = this.MainContentManager.Load>("Data/AdditionalLanguages"); + var customLanguages = DataLoader.AdditionalLanguages(this.MainContentManager); this.LocaleCodes = new Lazy>(() => this.GetLocaleCodes(customLanguages)); _ = this.LocaleCodes.Value; } @@ -267,9 +245,9 @@ public void OnReturningToTitleScreen() { // The game clears LocalizedContentManager.localizedAssetNames after returning to the title screen. That // causes an inconsistency in the SMAPI asset cache, which leads to an edge case where assets already - // provided by mods via IAssetLoader when playing in non-English are ignored. + // provided by mods via a load operation when playing in non-English are ignored. // - // For example, let's say a mod provides the 'Data\mail' asset through IAssetLoader when playing in + // For example, let's say a mod provides the 'Data\mail' asset via a load operation when playing in // Portuguese. Here's the normal load process after it's loaded: // 1. The game requests Data\mail. // 2. SMAPI sees that it's already cached, and calls LoadRaw to bypass asset interception. @@ -418,7 +396,9 @@ public IEnumerable InvalidateCache(Func InvalidateCache(Func p.Key, p => p.Value), ignoreWorld: Context.IsWorldFullyUnloaded, out IDictionary propagated, @@ -499,25 +480,13 @@ out bool updatedWarpRoutes return invalidatedAssets.Keys; } -#if SMAPI_DEPRECATED - /// Get the asset load and edit operations to apply to a given asset if it's (re)loaded now. - /// The asset type. - /// The asset info to load or edit. - public AssetOperationGroup? GetAssetOperations(IAssetInfo info) - where T : notnull -#else /// Get the asset load and edit operations to apply to a given asset if it's (re)loaded now. /// The asset info to load or edit. public AssetOperationGroup? GetAssetOperations(IAssetInfo info) -#endif { return this.AssetOperationsByKey.GetOrSet( info.Name, -#if SMAPI_DEPRECATED - () => this.GetAssetOperationsWithoutCache(info) -#else () => this.RequestAssetOperations(info) -#endif ); } @@ -562,7 +531,7 @@ public TilesheetReference[] GetVanillaTilesheetIds(string assetName) if (language == LocalizedContentManager.LanguageCode.mod && LocalizedContentManager.CurrentModLanguage == null) return null; - return this.MainContentManager.LanguageCodeString(language); + return this.MainContentManager.GetLocale(language); } /// Dispose held resources. @@ -606,14 +575,19 @@ private bool TryLoadVanillaAsset(string assetName, [NotNullWhen(true)] out T? { try { - asset = this.VanillaContentManager.Load(assetName); - return true; + if (this.VanillaContentManager.DoesAssetExist(assetName)) + { + asset = this.VanillaContentManager.Load(assetName); + return true; + } } catch { - asset = default; - return false; + // handled below } + + asset = default; + return false; } /// Get the language enums (like ) indexed by locale code (like ja-JP). @@ -639,195 +613,5 @@ private bool TryLoadVanillaAsset(string assetName, [NotNullWhen(true)] out T? return map; } - -#if SMAPI_DEPRECATED - /// Get the asset load and edit operations to apply to a given asset if it's (re)loaded now, ignoring the cache. - /// The asset type. - /// The asset info to load or edit. - private AssetOperationGroup? GetAssetOperationsWithoutCache(IAssetInfo info) - where T : notnull - { - // new content API - AssetOperationGroup? group = this.RequestAssetOperations(info); - - // legacy load operations - if (this.Editors.Count > 0 || this.Loaders.Count > 0) - { - IAssetInfo legacyInfo = this.GetLegacyAssetInfo(info); - - foreach (ModLinked loader in this.Loaders) - { - // check if loader applies - Context.HeuristicModsRunningCode.Push(loader.Mod); - try - { - if (!loader.Data.CanLoad(legacyInfo)) - continue; - } - catch (Exception ex) - { - loader.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{legacyInfo.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); - continue; - } - finally - { - Context.HeuristicModsRunningCode.TryPop(out _); - } - - // add operation - group ??= new AssetOperationGroup(new List(), new List()); - group.LoadOperations.Add( - this.GetOrCreateLegacyOperation( - cache: this.LegacyLoaderCache, - editor: loader.Data, - dataType: info.DataType, - create: () => new AssetLoadOperation( - Mod: loader.Mod, - OnBehalfOf: null, - Priority: AssetLoadPriority.Exclusive, - GetData: assetInfo => loader.Data.Load(this.GetLegacyAssetInfo(assetInfo)) - ) - ) - ); - } - - // legacy edit operations - foreach (var editor in this.Editors) - { - // check if editor applies - Context.HeuristicModsRunningCode.Push(editor.Mod); - try - { - if (!editor.Data.CanEdit(legacyInfo)) - continue; - } - catch (Exception ex) - { - editor.Mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{legacyInfo.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); - continue; - } - finally - { - Context.HeuristicModsRunningCode.TryPop(out _); - } - - // HACK - // - // If two editors have the same priority, they're applied in registration order (so - // whichever was registered first is applied first). Mods often depend on this - // behavior, like Json Assets registering its interceptors before Content Patcher. - // - // Unfortunately the old & new content APIs have separate lists, so new-API - // interceptors always ran before old-API interceptors with the same priority, - // regardless of the registration order *between* APIs. Since the new API works in - // a fundamentally different way (i.e. loads/edits are defined on asset request - // instead of by registering a global 'editor' or 'loader' class), there's no way - // to track registration order between them. - // - // Until we drop the old content API in SMAPI 4.0.0, this sets the priority for - // specific legacy editors to maintain compatibility. - AssetEditPriority priority = editor.Data.GetType().FullName switch - { - "JsonAssets.Framework.ContentInjector1" => AssetEditPriority.Default - 1, // must be applied before Content Patcher - _ => AssetEditPriority.Default - }; - - // add operation - group ??= new AssetOperationGroup(new List(), new List()); - group.EditOperations.Add( - this.GetOrCreateLegacyOperation( - cache: this.LegacyEditorCache, - editor: editor.Data, - dataType: info.DataType, - create: () => new AssetEditOperation( - Mod: editor.Mod, - OnBehalfOf: null, - Priority: priority, - ApplyEdit: assetData => editor.Data.Edit(this.GetLegacyAssetData(assetData)) - ) - ) - ); - } - } - - return group; - } - - /// Get a cached asset operation group for a legacy or instance, creating it if needed. - /// The editor type (one of or ). - /// The operation model type. - /// The cached operation groups for the interceptor type. - /// The legacy asset interceptor. - /// The asset data type. - /// Create the asset operation group if it's not cached yet. - private TOperation GetOrCreateLegacyOperation(Dictionary> cache, TInterceptor editor, Type dataType, Func create) - where TInterceptor : class - { - if (!cache.TryGetValue(editor, out Dictionary? cacheByType)) - cache[editor] = cacheByType = new Dictionary(); - - if (!cacheByType.TryGetValue(dataType, out TOperation? operation)) - cacheByType[dataType] = operation = create(); - - return operation; - } - - /// Get an asset info compatible with legacy and instances, which always expect the base name. - /// The asset info. - private IAssetInfo GetLegacyAssetInfo(IAssetInfo asset) - { - return new AssetInfo( - locale: this.GetLegacyLocale(asset), - assetName: this.GetLegacyAssetName(asset.Name), - type: asset.DataType, - getNormalizedPath: this.MainContentManager.AssertAndNormalizeAssetName - ); - } - - /// Get an asset data compatible with legacy and instances, which always expect the base name. - /// The asset data. - private IAssetData GetLegacyAssetData(IAssetData asset) - { - return new AssetDataForObject( - locale: this.GetLegacyLocale(asset), - assetName: this.GetLegacyAssetName(asset.Name), - data: asset.Data, - getNormalizedPath: this.MainContentManager.AssertAndNormalizeAssetName, - reflection: this.Reflection, - onDataReplaced: asset.ReplaceWith - ); - } - - /// Get the value compatible with legacy and instances, which expect the locale to default to the current game locale or an empty string. - /// The non-legacy asset info to map. - private string GetLegacyLocale(IAssetInfo asset) - { - return asset.Locale ?? this.GetLocale(); - } - - /// Get an asset name compatible with legacy and instances, which always expect the base name. - /// The asset name to map. - /// Returns the legacy asset name if needed, or the if no change is needed. - private IAssetName GetLegacyAssetName(IAssetName asset) - { - // strip _international suffix - const string internationalSuffix = "_international"; - if (asset.Name.EndsWith(internationalSuffix)) - { - return new AssetName( - baseName: asset.Name[..^internationalSuffix.Length], - localeCode: null, - languageCode: null - ); - } - - // else strip locale - if (asset.LocaleCode != null) - return new AssetName(asset.BaseName, null, null); - - // else no change needed - return asset; - } -#endif } } diff --git a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs index 1e9f4ffe3..5ffe7945c 100644 --- a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.IO; using System.Linq; +using System.Reflection; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.Content; @@ -48,6 +49,12 @@ internal abstract class BaseContentManager : LocalizedContentManager, IContentMa /// This should be kept empty to avoid keeping disposable assets referenced forever, which prevents garbage collection when they're unused. Disposable assets are tracked by instead, which avoids a hard reference. private readonly List BaseDisposableReferences; + /// A cache of proxy wrappers for the method. + private readonly Dictionary BaseLoadProxyCache = new(); + + /// Whether to check the game folder in the base implementation. + protected bool CheckGameFolderForAssetExists; + /********* ** Accessors @@ -97,36 +104,24 @@ protected BaseContentManager(string name, IServiceProvider serviceProvider, stri } /// - public virtual bool DoesAssetExist(IAssetName assetName) - where T : notnull + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Inherited from base method.")] + public sealed override bool DoesAssetExist(string? localized_asset_name) { - return this.Cache.ContainsKey(assetName.Name); - } + if (string.IsNullOrWhiteSpace(localized_asset_name)) + return false; - /// - [Obsolete("This method is implemented for the base game and should not be used directly. To load an asset from the underlying content manager directly, use " + nameof(BaseContentManager.RawLoad) + " instead.")] - public sealed override T LoadBase(string assetName) - { - return this.Load(assetName, LanguageCode.en); + IAssetName assetName = this.Coordinator.ParseAssetName(this.PrenormalizeRawAssetName(localized_asset_name), allowLocales: this.TryLocalizeKeys); + return this.DoesAssetExist(assetName); } /// - public sealed override string LoadBaseString(string path) + public virtual bool DoesAssetExist(IAssetName assetName) + where T : notnull { - try - { - // copied as-is from LocalizedContentManager.LoadBaseString - // This is only changed to call this.Load instead of base.Load, to support mod assets - this.ParseStringPath(path, out string assetName, out string key); - Dictionary? strings = this.Load?>(assetName, LanguageCode.en); - return strings != null && strings.ContainsKey(key) - ? this.GetString(strings, key) - : path; - } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed loading string path '{path}' from '{this.Name}'.", ex); - } + if (this.CheckGameFolderForAssetExists && base.DoesAssetExist(assetName.Name)) + return true; + + return this.Cache.ContainsKey(assetName.Name); } /// @@ -138,9 +133,16 @@ public sealed override T Load(string assetName) /// public sealed override T Load(string assetName, LanguageCode language) { - assetName = this.PrenormalizeRawAssetName(assetName); - IAssetName parsedName = this.Coordinator.ParseAssetName(assetName, allowLocales: this.TryLocalizeKeys); - return this.LoadLocalized(parsedName, language, useCache: true); + IAssetName parsedAssetName = this.Coordinator.ParseAssetName(this.PrenormalizeRawAssetName(assetName), allowLocales: this.TryLocalizeKeys); + return this.LoadLocalized(parsedAssetName, this.Language, true); + } + + /// + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Inherited from base method.")] + public sealed override T LoadImpl(string base_asset_name, string localized_asset_name, LanguageCode language_code) + { + IAssetName assetName = this.Coordinator.ParseAssetName(this.PrenormalizeRawAssetName(localized_asset_name), allowLocales: this.TryLocalizeKeys); + return this.LoadExact(assetName, useCache: true); } /// @@ -156,7 +158,7 @@ public T LoadLocalized(IAssetName assetName, LanguageCode language, bool useC Dictionary localizedAssetNames = this.Coordinator.LocalizedAssetNames.Value; if (!localizedAssetNames.TryGetValue(assetName.Name, out _)) { - string localeCode = this.LanguageCodeString(language); + string localeCode = this.GetLocale(language); IAssetName localizedName = new AssetName(baseName: assetName.BaseName, localeCode: localeCode, languageCode: language); try @@ -212,13 +214,15 @@ public string AssertAndNormalizeAssetName(string? assetName) /// public string GetLocale() { - return this.GetLocale(this.GetCurrentLanguage()); + return LocalizedContentManager.CurrentLanguageString; } /// public string GetLocale(LanguageCode language) { - return this.LanguageCodeString(language); + return language == LocalizedContentManager.CurrentLanguageCode + ? LocalizedContentManager.CurrentLanguageString + : LocalizedContentManager.LanguageCodeString(language); } /// @@ -321,9 +325,22 @@ public override void Unload() /// Whether to read/write the loaded asset to the asset cache. protected virtual T RawLoad(IAssetName assetName, bool useCache) { - return useCache - ? base.LoadBase(assetName.Name) - : this.ReadAsset(assetName.Name, disposable => this.Disposables.Add(new WeakReference(disposable))); + if (useCache) + { + if (!this.BaseLoadProxyCache.TryGetValue(typeof(T), out object? cacheEntry)) + { + MethodInfo method = typeof(ContentManager).GetMethod(nameof(ContentManager.Load)) ?? throw new InvalidOperationException($"Can't get required method '{nameof(ContentManager)}.{nameof(ContentManager.Load)}'."); + method = method.MakeGenericMethod(typeof(T)); + IntPtr pointer = method.MethodHandle.GetFunctionPointer(); + this.BaseLoadProxyCache[typeof(T)] = cacheEntry = Activator.CreateInstance(typeof(Func), this, pointer) ?? throw new InvalidOperationException($"Can't proxy required method '{nameof(ContentManager)}.{nameof(ContentManager.Load)}'."); + } + + Func baseLoad = (Func)cacheEntry; + + return baseLoad(assetName.Name); + } + + return this.ReadAsset(assetName.Name, disposable => this.Disposables.Add(new WeakReference(disposable))); } /// Add tracking data to an asset and add it to the cache. @@ -349,34 +366,5 @@ protected virtual void TrackAsset(IAssetName assetName, T value, bool useCach // avoid hard disposable references; see remarks on the field this.BaseDisposableReferences.Clear(); } - - /**** - ** Private methods copied from the game code - ****/ -#pragma warning disable CS1574 // can't be resolved: the reference is valid but private - /// Parse a string path like assetName:key. - /// The string path. - /// The extracted asset name. - /// The extracted entry key. - /// The string path is not in a valid format. - /// This is copied as-is from . - private void ParseStringPath(string path, out string assetName, out string key) - { - int length = path.IndexOf(':'); - assetName = length != -1 ? path.Substring(0, length) : throw new ContentLoadException("Unable to parse string path: " + path); - key = path.Substring(length + 1, path.Length - length - 1); - } - - /// Get a string value from a dictionary asset. - /// The asset to read. - /// The string key to find. - /// This is copied as-is from . - private string GetString(Dictionary strings, string key) - { - return strings.TryGetValue(key + ".desktop", out string? str) - ? str - : strings[key]; - } -#pragma warning restore CS1574 } } diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs index df7bdc59e..8043c74ae 100644 --- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs @@ -2,13 +2,11 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; -using System.IO; using System.Linq; using System.Reflection; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Events; using StardewModdingAPI.Framework.Content; -using StardewModdingAPI.Framework.Deprecations; using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Utilities; @@ -25,7 +23,7 @@ internal class GameContentManager : BaseContentManager /********* ** Fields *********/ - /// The assets currently being intercepted by instances. This is used to prevent infinite loops when a loader loads a new asset. + /// The assets currently being intercepted by instances. This is used to prevent infinite loops when a loader loads a new asset. private readonly ContextHash AssetsBeingLoaded = new(); /// Whether the next load is the first for any game content manager. @@ -57,6 +55,8 @@ public GameContentManager(string name, IServiceProvider serviceProvider, string { this.OnLoadingFirstAsset = onLoadingFirstAsset; this.OnAssetLoaded = onAssetLoaded; + + this.CheckGameFolderForAssetExists = true; } /// @@ -65,10 +65,6 @@ public override bool DoesAssetExist(IAssetName assetName) if (base.DoesAssetExist(assetName)) return true; - // vanilla asset - if (File.Exists(Path.Combine(this.RootDirectory, $"{assetName.Name}.xnb"))) - return true; - // managed asset if (this.Coordinator.TryParseManagedAssetKey(assetName.Name, out string? contentManagerID, out IAssetName? relativePath)) return this.Coordinator.DoesManagedAssetExist(contentManagerID, relativePath); @@ -76,11 +72,7 @@ public override bool DoesAssetExist(IAssetName assetName) // custom asset from a loader string locale = this.GetLocale(); IAssetInfo info = new AssetInfo(locale, assetName, typeof(T), this.AssertAndNormalizeAssetName); - AssetOperationGroup? operations = this.Coordinator.GetAssetOperations -#if SMAPI_DEPRECATED - -#endif - (info); + AssetOperationGroup? operations = this.Coordinator.GetAssetOperations(info); if (operations?.LoadOperations.Count > 0) { if (!this.AssertMaxOneRequiredLoader(info, operations.LoadOperations, out string? error)) @@ -133,11 +125,7 @@ public override T LoadExact(IAssetName assetName, bool useCache) data = this.AssetsBeingLoaded.Track(assetName.Name, () => { IAssetInfo info = new AssetInfo(assetName.LocaleCode, assetName, typeof(T), this.AssertAndNormalizeAssetName); - AssetOperationGroup? operations = this.Coordinator.GetAssetOperations -#if SMAPI_DEPRECATED - -#endif - (info); + AssetOperationGroup? operations = this.Coordinator.GetAssetOperations(info); IAssetData asset = this.ApplyLoader(info, operations?.LoadOperations) ?? new AssetDataForObject(info, this.RawLoad(assetName, useCache), this.AssertAndNormalizeAssetName, this.Reflection); @@ -302,11 +290,7 @@ private bool AssertMaxOneRequiredLoader(IAssetInfo info, List(IAssetInfo info, [NotNullWhen(true) // handle mismatch if (loadedMap.TileSheets.Count <= vanillaSheet.Index || loadedMap.TileSheets[vanillaSheet.Index].Id != vanillaSheet.Id) { -#if SMAPI_DEPRECATED - // only show warning if not farm map - // This is temporary: mods shouldn't do this for any vanilla map, but these are the ones we know will crash. Showing a warning for others instead gives modders time to update their mods, while still simplifying troubleshooting. - bool isFarmMap = info.Name.IsEquivalentTo("Maps/Farm") || info.Name.IsEquivalentTo("Maps/Farm_Combat") || info.Name.IsEquivalentTo("Maps/Farm_Fishing") || info.Name.IsEquivalentTo("Maps/Farm_Foraging") || info.Name.IsEquivalentTo("Maps/Farm_FourCorners") || info.Name.IsEquivalentTo("Maps/Farm_Island") || info.Name.IsEquivalentTo("Maps/Farm_Mining"); - - string reason = $"{this.GetOnBehalfOfLabel(loader.OnBehalfOf, parenthetical: false) ?? "mod"} reordered the original tilesheets, which {(isFarmMap ? "would cause a crash" : "often causes crashes")}.\nTechnical details for mod author: Expected order: {string.Join(", ", vanillaTilesheetRefs.Select(p => p.Id))}. See https://stardewvalleywiki.com/Modding:Maps#Tilesheet_order for help."; - - SCore.DeprecationManager.PlaceholderWarn("3.8.2", DeprecationLevel.PendingRemoval); - if (isFarmMap) - { - mod.LogAsMod($"SMAPI blocked a '{info.Name}' map load: {reason}", LogLevel.Error); - return false; - } - - mod.LogAsMod($"SMAPI found an issue with a '{info.Name}' map load: {reason}", LogLevel.Warn); -#else - mod.LogAsMod($"SMAPI found an issue with a '{info.Name}' map load: {this.GetOnBehalfOfLabel(loader.OnBehalfOf, parenthetical: false) ?? "mod"} reordered the original tilesheets, which often causes crashes.\nTechnical details for mod author: Expected order: {string.Join(", ", vanillaTilesheetRefs.Select(p => p.Id))}. See https://stardewvalleywiki.com/Modding:Maps#Tilesheet_order for help.", LogLevel.Error); + mod.LogAsMod($"SMAPI blocked a '{info.Name}' map load: {this.GetOnBehalfOfLabel(loader.OnBehalfOf, parenthetical: false) ?? "mod"} reordered the original tilesheets, which often causes crashes.\nTechnical details for mod author: Expected order: {string.Join(", ", vanillaTilesheetRefs.Select(p => p.Id))}. See https://stardewvalleywiki.com/Modding:Maps#Tilesheet_order for help.", LogLevel.Error); return false; -#endif } } } diff --git a/src/SMAPI/Framework/ContentManagers/IContentManager.cs b/src/SMAPI/Framework/ContentManagers/IContentManager.cs index f2e3b9f05..8b146ddac 100644 --- a/src/SMAPI/Framework/ContentManagers/IContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/IContentManager.cs @@ -57,7 +57,7 @@ T LoadExact(IAssetName assetName, bool useCache) /// Get the current content locale. string GetLocale(); - /// The locale for a language. + /// Get the locale for a language. /// The language. string GetLocale(LocalizedContentManager.LanguageCode language); diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index 8cdb65eb9..d19ce4329 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -46,15 +46,6 @@ internal sealed class ModContentManager : BaseContentManager private static readonly string[] LocalTilesheetExtensions = { ".png", ".xnb" }; - /********* - ** Accessors - *********/ -#if SMAPI_DEPRECATED - /// Whether to enable legacy compatibility mode for PyTK scale-up textures. - internal static bool EnablePyTkLegacyMode; -#endif - - /********* ** Public methods *********/ @@ -95,13 +86,13 @@ public override bool DoesAssetExist(IAssetName assetName) /// public override T LoadExact(IAssetName assetName, bool useCache) { - // disable caching + // + // Note: caching is ignored for mod content. // This is necessary to avoid assets being shared between content managers, which can // cause changes to an asset through one content manager affecting the same asset in // others (or even fresh content managers). See https://www.patreon.com/posts/27247161 // for more background info. - if (useCache) - throw new InvalidOperationException("Mod content managers don't support asset caching."); + // // resolve managed asset key { @@ -201,16 +192,6 @@ private T LoadImageFile(IAssetName assetName, FileInfo file) this.AssertValidType(assetName, file, typeof(Texture2D), typeof(IRawTextureData)); bool returnRawData = typeof(T).IsAssignableTo(typeof(IRawTextureData)); -#if SMAPI_DEPRECATED - if (!returnRawData && this.ShouldDisableIntermediateRawDataLoad(assetName, file)) - { - using FileStream stream = File.OpenRead(file.FullName); - Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream).SetName(assetName); - this.PremultiplyTransparency(texture); - return (T)(object)texture; - } -#endif - IRawTextureData raw = this.LoadRawImageData(file, returnRawData); if (returnRawData) @@ -223,28 +204,6 @@ private T LoadImageFile(IAssetName assetName, FileInfo file) } } -#if SMAPI_DEPRECATED - /// Get whether to disable loading an image as before building a instance. This isn't called if the mod requested directly. - /// The type of asset being loaded. - /// The asset name relative to the loader root directory. - /// The file being loaded. - private bool ShouldDisableIntermediateRawDataLoad(IAssetName assetName, FileInfo file) - { - // disable raw data if PyTK will rescale the image (until it supports raw data) - if (ModContentManager.EnablePyTkLegacyMode) - { - // PyTK intercepts Texture2D file loads to rescale them (e.g. for HD portraits), - // but doesn't support IRawTextureData loads yet. We can't just check if the - // current file has a '.pytk.json' rescale file though, since PyTK may still - // rescale it if the original asset or another edit gets rescaled. - this.Monitor.LogOnce("Enabled compatibility mode for PyTK 1.23.* or earlier. This won't cause any issues, but may impact performance. This will no longer be supported in the upcoming SMAPI 4.0.0.", LogLevel.Warn); - return true; - } - - return false; - } -#endif - /// Load the raw image data from a file on disk. /// The file whose data to load. /// Whether the data is being loaded for an (true) or (false) instance. @@ -523,7 +482,7 @@ private bool TryGetTilesheetAssetName(string modRelativeMapFolder, string relati // ignore file-not-found errors // TODO: while it's useful to suppress an asset-not-found error here to avoid // confusion, this is a pretty naive approach. Even if the file doesn't exist, - // the file may have been loaded through an IAssetLoader which failed. So even + // the file may have been loaded through a load operation which failed. So even // if the content file doesn't exist, that doesn't mean the error here is a // content-not-found error. Unfortunately XNA doesn't provide a good way to // detect the error type. diff --git a/src/SMAPI/Framework/ContentPack.cs b/src/SMAPI/Framework/ContentPack.cs index a1d977e4a..b6d0c07eb 100644 --- a/src/SMAPI/Framework/ContentPack.cs +++ b/src/SMAPI/Framework/ContentPack.cs @@ -96,23 +96,6 @@ public void WriteJsonFile(string path, TModel data) where TModel : class } } -#if SMAPI_DEPRECATED - /// - [Obsolete($"Use {nameof(IContentPack.ModContent)}.{nameof(IModContentHelper.Load)} instead. This method will be removed in SMAPI 4.0.0.")] - public T LoadAsset(string key) - where T : notnull - { - return this.ModContent.Load(key); - } - - /// - [Obsolete($"Use {nameof(IContentPack.ModContent)}.{nameof(IModContentHelper.GetInternalAssetName)} instead. This method will be removed in SMAPI 4.0.0.")] - public string GetActualAssetKey(string key) - { - return this.ModContent.GetInternalAssetName(key).Name; - } -#endif - /********* ** Private methods diff --git a/src/SMAPI/Framework/Events/EventManager.cs b/src/SMAPI/Framework/Events/EventManager.cs index b21d5c7df..41b914185 100644 --- a/src/SMAPI/Framework/Events/EventManager.cs +++ b/src/SMAPI/Framework/Events/EventManager.cs @@ -36,6 +36,12 @@ internal class EventManager /// public readonly ManagedEvent Rendered; + /// + public readonly ManagedEvent RenderingStep; + + /// + public readonly ManagedEvent RenderedStep; + /// public readonly ManagedEvent RenderingWorld; @@ -212,6 +218,8 @@ ManagedEvent ManageEventOf(string typeName, string event this.MenuChanged = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.MenuChanged)); this.Rendering = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendering)); this.Rendered = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendered)); + this.RenderingStep = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingStep)); + this.RenderedStep = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedStep)); this.RenderingWorld = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingWorld)); this.RenderedWorld = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedWorld)); this.RenderingActiveMenu = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingActiveMenu)); diff --git a/src/SMAPI/Framework/Events/ModDisplayEvents.cs b/src/SMAPI/Framework/Events/ModDisplayEvents.cs index 48f553242..145e23d1a 100644 --- a/src/SMAPI/Framework/Events/ModDisplayEvents.cs +++ b/src/SMAPI/Framework/Events/ModDisplayEvents.cs @@ -16,6 +16,20 @@ public event EventHandler MenuChanged remove => this.EventManager.MenuChanged.Remove(value); } + /// + public event EventHandler RenderingStep + { + add => this.EventManager.RenderingStep.Add(value, this.Mod); + remove => this.EventManager.RenderingStep.Remove(value); + } + + /// + public event EventHandler RenderedStep + { + add => this.EventManager.RenderedStep.Add(value, this.Mod); + remove => this.EventManager.RenderedStep.Remove(value); + } + /// public event EventHandler Rendering { diff --git a/src/SMAPI/Framework/Input/SInputState.cs b/src/SMAPI/Framework/Input/SInputState.cs index fef83af7f..0844616d8 100644 --- a/src/SMAPI/Framework/Input/SInputState.cs +++ b/src/SMAPI/Framework/Input/SInputState.cs @@ -73,7 +73,7 @@ public void TrueUpdate() var keyboard = new KeyboardStateBuilder(base.GetKeyboardState()); var mouse = new MouseStateBuilder(base.GetMouseState()); Vector2 cursorAbsolutePos = new((mouse.X * zoomMultiplier) + Game1.viewport.X, (mouse.Y * zoomMultiplier) + Game1.viewport.Y); - Vector2? playerTilePos = Context.IsPlayerFree ? Game1.player.getTileLocation() : null; + Vector2? playerTilePos = Context.IsPlayerFree ? Game1.player.Tile : null; HashSet reallyDown = new HashSet(this.GetPressedButtons(keyboard, mouse, controller)); // apply overrides diff --git a/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs b/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs deleted file mode 100644 index 9ecc16269..000000000 --- a/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System; -using System.IO; -using System.Text; - -namespace StardewModdingAPI.Framework.Logging -{ - /// A text writer which allows intercepting output. - internal class InterceptingTextWriter : TextWriter - { - /********* - ** Fields - *********/ - /// The event raised when a message is written to the console directly. - private readonly Action OnMessageIntercepted; - - - /********* - ** Accessors - *********/ - /// Prefixing a message with this character indicates that the console interceptor should write the string without intercepting it. (The character itself is not written.) - public const char IgnoreChar = '\u200B'; - - /// The underlying console output. - public TextWriter Out { get; } - - /// - public override Encoding Encoding => this.Out.Encoding; - - /// Whether the text writer should ignore the next input if it's a newline. - /// This is used when log output is suppressed from the console, since Console.WriteLine writes the trailing newline as a separate call. - public bool IgnoreNextIfNewline { get; set; } - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The underlying output writer. - /// The event raised when a message is written to the console directly. - public InterceptingTextWriter(TextWriter output, Action onMessageIntercepted) - { - this.Out = output; - this.OnMessageIntercepted = onMessageIntercepted; - } - - /// - public override void Write(char[] buffer, int index, int count) - { - // track newline skip - bool ignoreIfNewline = this.IgnoreNextIfNewline; - this.IgnoreNextIfNewline = false; - - // get first character if valid - if (count == 0 || index < 0 || index >= buffer.Length) - { - this.Out.Write(buffer, index, count); - return; - } - char firstChar = buffer[index]; - - // handle output - if (firstChar == InterceptingTextWriter.IgnoreChar) - this.Out.Write(buffer, index + 1, count - 1); - else if (char.IsControl(firstChar) && firstChar is not ('\r' or '\n')) - this.Out.Write(buffer, index, count); - else if (this.IsEmptyOrNewline(buffer)) - { - if (!ignoreIfNewline) - this.Out.Write(buffer, index, count); - } - else - this.OnMessageIntercepted(new string(buffer, index, count)); - } - - /// - public override void Write(char ch) - { - this.Out.Write(ch); - } - - - /********* - ** Private methods - *********/ - /// Get whether a buffer represents a line break. - /// The buffer to check. - 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 index ffffc9c71..af59068dc 100644 --- a/src/SMAPI/Framework/Logging/LogManager.cs +++ b/src/SMAPI/Framework/Logging/LogManager.cs @@ -4,7 +4,6 @@ using System.IO; using System.Linq; using System.Text; -using System.Text.RegularExpressions; using System.Threading; using StardewModdingAPI.Framework.Commands; using StardewModdingAPI.Framework.Models; @@ -13,6 +12,7 @@ using StardewModdingAPI.Internal.ConsoleWriting; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Utilities; +using StardewValley; namespace StardewModdingAPI.Framework.Logging { @@ -25,48 +25,9 @@ internal class LogManager : IDisposable /// The log file to which to write messages. private readonly LogFileManager LogFile; - /// The text writer which intercepts console output. - private readonly InterceptingTextWriter ConsoleInterceptor; - - /// 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.) - private const char IgnoreChar = InterceptingTextWriter.IgnoreChar; - /// Create a monitor instance given the ID and name. private readonly Func GetMonitorImpl; - /// Regex patterns which match console non-error messages to suppress from the console and log. - private readonly Regex[] SuppressConsolePatterns = - { - new(@"^TextBox\.Selected is now '(?:True|False)'\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new(@"^loadPreferences\(\); begin", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new(@"^savePreferences\(\); async=", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new(@"^DebugOutput:\s+(?:added cricket|dismount tile|Ping|playerPos)", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new(@"^Ignoring keys: ", RegexOptions.Compiled | RegexOptions.CultureInvariant) - }; - - /// Regex patterns which match console messages to show a more friendly error for. - private readonly ReplaceLogPattern[] ReplaceConsolePatterns = - { - // Steam not loaded - new( - 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. See 'Configure your game client' 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( - 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 @@ -97,7 +58,7 @@ public LogManager(string logPath, ColorSchemeConfig colorConfig, bool writeToCon this.LogFile = new LogFileManager(logPath); // init monitor - this.GetMonitorImpl = (id, name) => new Monitor(name, LogManager.IgnoreChar, this.LogFile, colorConfig, verboseLogging.Contains("*") || verboseLogging.Contains(id), getScreenIdForLog) + this.GetMonitorImpl = (id, name) => new Monitor(name, this.LogFile, colorConfig, verboseLogging.Contains("*") || verboseLogging.Contains(id), getScreenIdForLog) { WriteToConsole = writeToConsole, ShowTraceInConsole = isDeveloperMode, @@ -106,15 +67,6 @@ public LogManager(string logPath, ColorSchemeConfig colorConfig, bool writeToCon this.Monitor = this.GetMonitor("SMAPI", "SMAPI"); this.MonitorForGame = this.GetMonitor("game", "game"); - // redirect direct console output - this.ConsoleInterceptor = new InterceptingTextWriter( - output: Console.Out, - onMessageIntercepted: writeToConsole - ? message => this.HandleConsoleMessage(this.MonitorForGame, message) - : _ => { } - ); - Console.SetOut(this.ConsoleInterceptor); - // enable Unicode handling on Windows // (the terminal defaults to UTF-8 on Linux/macOS) #if SMAPI_FOR_WINDOWS @@ -269,25 +221,17 @@ public void LogFatalLaunchError(Exception exception) public void LogIntro(string modsPath, IDictionary customSettings) { // log platform - this.Monitor.Log($"SMAPI {Constants.ApiVersion} " -#if !SMAPI_DEPRECATED - + "(strict mode) " -#endif - + $"with Stardew Valley {Constants.GameVersion} (build {Constants.GetBuildVersionLabel()}) on {EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform)}", LogLevel.Info); + this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Game1.GetVersionString()} on {EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform)}", LogLevel.Info); // log basic 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($"(Using custom --mods-path argument. Game folder: {Constants.GamePath}.)"); 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}"))}"); - -#if !SMAPI_DEPRECATED - this.Monitor.Log("SMAPI is running in 'strict mode', which removes all deprecated APIs. This can significantly improve performance, but some mods may not work. You can reinstall SMAPI to disable it if you run into problems.", LogLevel.Info); -#endif } /// Log details for settings that don't match the default. @@ -316,7 +260,9 @@ public void LogSettingsHeader(SConfig settings) /// The loaded mods. /// The mods which could not be loaded. /// Whether to log issues for mods which directly use potentially sensitive .NET APIs like file or shell access. - public void LogModInfo(IModMetadata[] loaded, IModMetadata[] loadedContentPacks, IModMetadata[] loadedMods, IModMetadata[] skippedMods, bool logParanoidWarnings) + /// Whether to include more technical details about broken mods in the TRACE logs. This is mainly useful for creating compatibility rewriters. + /// Whether Harmony was fixed to work with Stardew Valley. + public void LogModInfo(IModMetadata[] loaded, IModMetadata[] loadedContentPacks, IModMetadata[] loadedMods, IModMetadata[] skippedMods, bool logParanoidWarnings, bool logTechnicalDetailsForBrokenMods, bool hasHarmonyFix) { // log loaded mods this.Monitor.Log($"Loaded {loadedMods.Length} mods" + (loadedMods.Length > 0 ? ":" : "."), LogLevel.Info); @@ -355,7 +301,7 @@ public void LogModInfo(IModMetadata[] loaded, IModMetadata[] loadedContentPacks, } // log mod warnings - this.LogModWarnings(loaded, skippedMods, logParanoidWarnings); + this.LogModWarnings(loaded, skippedMods, logParanoidWarnings, logTechnicalDetailsForBrokenMods, hasHarmonyFix); } /// @@ -368,51 +314,25 @@ public void Dispose() /********* ** Protected methods *********/ - /// Redirect messages logged directly to the console to the given monitor. - /// The monitor with which to log messages as the game. - /// The message to log. - 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))) - { - this.ConsoleInterceptor.IgnoreNextIfNewline = true; - 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; - } - } - - // simplify exception messages - if (level == LogLevel.Error) - message = ExceptionHelper.SimplifyExtensionMessage(message); - - // forward to monitor - gameMonitor.Log(message, level); - this.ConsoleInterceptor.IgnoreNextIfNewline = true; - } - /// Write a summary of mod warnings to the console and log. /// The loaded mods. /// The mods which could not be loaded. /// Whether to log issues for mods which directly use potentially sensitive .NET APIs like file or shell access. + /// Whether to include more technical details about broken mods in the TRACE logs. This is mainly useful for creating compatibility rewriters. + /// Whether Harmony was fixed to work with Stardew Valley. [SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract", Justification = "Manifests aren't guaranteed non-null at this point in the loading process.")] - private void LogModWarnings(IEnumerable mods, IModMetadata[] skippedMods, bool logParanoidWarnings) + private void LogModWarnings(IEnumerable mods, IModMetadata[] skippedMods, bool logParanoidWarnings, bool logTechnicalDetailsForBrokenMods, bool hasHarmonyFix) { // get mods with warnings - IModMetadata[] modsWithWarnings = mods.Where(p => p.Warnings != ModWarning.None).ToArray(); + IModMetadata[] modsWithWarnings = mods + .Where(p => + ( + logParanoidWarnings + ? p.Warnings + : p.Warnings & ~ModWarning.AccessesFilesystem & ~ModWarning.AccessesShell + ) != ModWarning.None + ) + .ToArray(); if (!modsWithWarnings.Any() && !skippedMods.Any()) return; @@ -437,7 +357,11 @@ private void LogModWarnings(IEnumerable mods, IModMetadata[] skipp { foreach (IModMetadata mod in list.OrderBy(p => p.DisplayName)) { - string message = $" - {mod.DisplayName}{(" " + mod.Manifest?.Version?.ToString()).TrimEnd()} because {mod.Error}"; + string technicalInfo = logTechnicalDetailsForBrokenMods + ? $" (ID: {mod.Manifest?.UniqueID ?? "???"}, path: {mod.RelativeDirectoryPath})" + : ""; + + string message = $" - {mod.DisplayName}{(" " + mod.Manifest?.Version?.ToString()).TrimEnd()}{technicalInfo} because {mod.Error}"; // duplicate mod: log first one only, don't show redundant version if (mod.FailReason == ModFailReason.Duplicate && mod.HasManifest()) @@ -468,6 +392,15 @@ private void LogModWarnings(IEnumerable mods, IModMetadata[] skipp "errors, or crashes in-game." ); + // missing Harmony fix + if (!hasHarmonyFix) + { + this.LogModWarningGroup(modsWithWarnings, ModWarning.PatchesGame, LogLevel.Warn, "Patched game code without Harmony fix", + $"These mods directly change the game code using Harmony, but you disabled the {nameof(SConfig.FixHarmony)} option.", + "The game will probably crash soon." + ); + } + // 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", @@ -475,10 +408,13 @@ private void LogModWarnings(IEnumerable mods, IModMetadata[] skipp ); // 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." - ); + if (hasHarmonyFix) + { + 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", @@ -486,12 +422,18 @@ private void LogModWarnings(IEnumerable mods, IModMetadata[] skipp "corruption. If your game has issues, try removing these first." ); + // direct console access + this.LogModWarningGroup(modsWithWarnings, ModWarning.UsesUnvalidatedUpdateTick, LogLevel.Trace, "Direct console access", + "These mods access the SMAPI console window directly. This is more fragile, and their output may not", + "be logged by SMAPI." + ); + // paranoid warnings if (logParanoidWarnings) { this.LogModWarningGroup( modsWithWarnings, - match: mod => mod.HasWarnings(ModWarning.AccessesConsole, ModWarning.AccessesFilesystem, ModWarning.AccessesShell), + match: mod => mod.HasWarnings(ModWarning.AccessesFilesystem, ModWarning.AccessesShell), level: LogLevel.Debug, heading: "Direct system access", blurb: new[] @@ -503,8 +445,6 @@ private void LogModWarnings(IEnumerable mods, IModMetadata[] skipp modLabel: mod => { List labels = new List(); - if (mod.HasWarnings(ModWarning.AccessesConsole)) - labels.Add("console"); if (mod.HasWarnings(ModWarning.AccessesFilesystem)) labels.Add("files"); if (mod.HasWarnings(ModWarning.AccessesShell)) @@ -651,40 +591,5 @@ private void LogModWarningGroup(IModMetadata[] mods, ModWarning warning, LogLeve { this.LogModWarningGroup(mods, mod => mod.HasWarnings(warning), level, heading, blurb); } - - - /********* - ** Protected types - *********/ - /// A console log pattern to replace with a different message. - private class ReplaceLogPattern - { - /********* - ** Accessors - *********/ - /// The regex pattern matching the portion of the message to replace. - public Regex Search { get; } - - /// The replacement string. - public string Replacement { get; } - - /// The log level for the new message. - public LogLevel LogLevel { get; } - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The regex pattern matching the portion of the message to replace. - /// The replacement string. - /// The log level for the new message. - public ReplaceLogPattern(Regex search, string replacement, LogLevel logLevel) - { - this.Search = search; - this.Replacement = replacement; - this.LogLevel = logLevel; - } - } } } diff --git a/src/SMAPI/Framework/Logging/VerboseLogStringHandler.cs b/src/SMAPI/Framework/Logging/VerboseLogStringHandler.cs new file mode 100644 index 000000000..b69df6307 --- /dev/null +++ b/src/SMAPI/Framework/Logging/VerboseLogStringHandler.cs @@ -0,0 +1,50 @@ +using System.Runtime.CompilerServices; + +namespace StardewModdingAPI.Framework.Logging +{ + /// An interpolated string handler to handle verbose logging. + [InterpolatedStringHandler] + public ref struct VerboseLogStringHandler + { + /********* + ** Fields + *********/ + /// The underlying interpolated string handler. + private DefaultInterpolatedStringHandler Handler; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The total length of literals used in interpolation. + /// The number of interpolation holes to fill. + /// The monitor instance. + /// Whether the handler can receive and output data. + public VerboseLogStringHandler(int literalLength, int formattedCount, IMonitor monitor, out bool isValid) + { + isValid = monitor.IsVerbose; + + if (isValid) + this.Handler = new(literalLength, formattedCount); + } + + /// + public void AppendLiteral(string literal) + { + this.Handler.AppendLiteral(literal); + } + + /// + public void AppendFormatted(T value) + { + this.Handler.AppendFormatted(value); + } + + /// + public override string ToString() + { + return this.Handler.ToStringAndClear(); + } + } +} diff --git a/src/SMAPI/Framework/ModHelpers/CommandHelper.cs b/src/SMAPI/Framework/ModHelpers/CommandHelper.cs index 90edc137c..d3c5a1f93 100644 --- a/src/SMAPI/Framework/ModHelpers/CommandHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/CommandHelper.cs @@ -1,7 +1,4 @@ using System; -#if SMAPI_DEPRECATED -using StardewModdingAPI.Framework.Deprecations; -#endif namespace StardewModdingAPI.Framework.ModHelpers { @@ -33,21 +30,5 @@ public ICommandHelper Add(string name, string documentation, Action - [Obsolete("Use mod-provided APIs to integrate with mods instead. This method will be removed in SMAPI 4.0.0.")] - public bool Trigger(string name, string[] arguments) - { - SCore.DeprecationManager.Warn( - source: this.Mod, - nounPhrase: $"{nameof(IModHelper)}.{nameof(IModHelper.ConsoleCommands)}.{nameof(ICommandHelper.Trigger)}", - version: "3.8.1", - severity: DeprecationLevel.PendingRemoval - ); - - return this.CommandManager.Trigger(name, arguments); - } -#endif } } diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs deleted file mode 100644 index 152b264c4..000000000 --- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs +++ /dev/null @@ -1,253 +0,0 @@ -#if SMAPI_DEPRECATED -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Diagnostics.CodeAnalysis; -using System.Diagnostics.Contracts; -using System.IO; -using System.Linq; -using StardewModdingAPI.Framework.Content; -using StardewModdingAPI.Framework.ContentManagers; -using StardewModdingAPI.Framework.Deprecations; -using StardewModdingAPI.Framework.Exceptions; -using StardewModdingAPI.Framework.Reflection; -using StardewValley; - -namespace StardewModdingAPI.Framework.ModHelpers -{ - /// Provides an API for loading content assets. - [Obsolete($"Use {nameof(IMod.Helper)}.{nameof(IModHelper.GameContent)} or {nameof(IMod.Helper)}.{nameof(IModHelper.ModContent)} instead. This interface will be removed in SMAPI 4.0.0.")] - internal class ContentHelper : BaseHelper, IContentHelper - { - /********* - ** Fields - *********/ - /// SMAPI's core content logic. - private readonly ContentCoordinator ContentCore; - - /// A content manager for this mod which manages files from the game's Content folder. - private readonly IContentManager GameContentManager; - - /// A content manager for this mod which manages files from the mod's folder. - private readonly ModContentManager ModContentManager; - - /// Encapsulates monitoring and logging. - private readonly IMonitor Monitor; - - /// Simplifies access to private code. - private readonly Reflector Reflection; - - - /********* - ** Accessors - *********/ - /// - public string CurrentLocale => this.GameContentManager.GetLocale(); - - /// - public LocalizedContentManager.LanguageCode CurrentLocaleConstant => this.GameContentManager.Language; - - /// The observable implementation of . - internal ObservableCollection ObservableAssetEditors { get; } = new(); - - /// The observable implementation of . - internal ObservableCollection ObservableAssetLoaders { get; } = new(); - - /// - public IList AssetLoaders - { - get - { - SCore.DeprecationManager.Warn( - source: this.Mod, - nounPhrase: $"{nameof(IContentHelper)}.{nameof(IContentHelper.AssetLoaders)}", - version: "3.14.0", - severity: DeprecationLevel.PendingRemoval - ); - - return this.ObservableAssetLoaders; - } - } - - /// - public IList AssetEditors - { - get - { - SCore.DeprecationManager.Warn( - source: this.Mod, - nounPhrase: $"{nameof(IContentHelper)}.{nameof(IContentHelper.AssetEditors)}", - version: "3.14.0", - severity: DeprecationLevel.PendingRemoval - ); - - return this.ObservableAssetEditors; - } - } - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// SMAPI's core content logic. - /// The absolute path to the mod folder. - /// The mod using this instance. - /// Encapsulates monitoring and logging. - /// Simplifies access to private code. - public ContentHelper(ContentCoordinator contentCore, string modFolderPath, IModMetadata mod, IMonitor monitor, Reflector reflection) - : base(mod) - { - string managedAssetPrefix = contentCore.GetManagedAssetPrefix(mod.Manifest.UniqueID); - - this.ContentCore = contentCore; - this.GameContentManager = contentCore.CreateGameContentManager(managedAssetPrefix + ".content"); - this.ModContentManager = contentCore.CreateModContentManager(managedAssetPrefix, this.Mod.DisplayName, modFolderPath, this.GameContentManager); - this.Monitor = monitor; - this.Reflection = reflection; - } - - /// - public T Load(string key, ContentSource source = ContentSource.ModFolder) - where T : notnull - { - IAssetName assetName = this.ContentCore.ParseAssetName(key, allowLocales: source == ContentSource.GameContent); - - try - { - this.AssertAndNormalizeAssetName(key); - switch (source) - { - case ContentSource.GameContent: - if (assetName.Name.EndsWith(".xnb", StringComparison.OrdinalIgnoreCase)) - { - assetName = this.ContentCore.ParseAssetName(assetName.Name[..^4], allowLocales: true); - SCore.DeprecationManager.Warn( - this.Mod, - "loading assets from the Content folder with a .xnb file extension", - "3.14.0", - DeprecationLevel.Info - ); - } - - return this.GameContentManager.LoadLocalized(assetName, this.CurrentLocaleConstant, useCache: false); - - case ContentSource.ModFolder: - try - { - return this.ModContentManager.LoadExact(assetName, useCache: false); - } - catch (SContentLoadException ex) when (ex.ErrorType == ContentLoadErrorType.AssetDoesNotExist) - { - // legacy behavior: you can load a .xnb file without the file extension - try - { - IAssetName newName = this.ContentCore.ParseAssetName(assetName.Name + ".xnb", allowLocales: false); - if (this.ModContentManager.DoesAssetExist(newName)) - { - T data = this.ModContentManager.LoadExact(newName, useCache: false); - SCore.DeprecationManager.Warn( - this.Mod, - "loading XNB files from the mod folder without the .xnb file extension", - "3.14.0", - DeprecationLevel.Info - ); - return data; - } - } - catch { /* legacy behavior failed, rethrow original error */ } - - throw; - } - - default: - throw new SContentLoadException(ContentLoadErrorType.Other, $"{this.Mod.DisplayName} failed loading content asset '{key}' from {source}: unknown content source '{source}'."); - } - } - catch (Exception ex) when (ex is not SContentLoadException) - { - throw new SContentLoadException(ContentLoadErrorType.Other, $"{this.Mod.DisplayName} failed loading content asset '{key}' from {source}.", ex); - } - } - - /// - [Pure] - public string NormalizeAssetName(string? assetName) - { - return this.ModContentManager.AssertAndNormalizeAssetName(assetName); - } - - /// - public string GetActualAssetKey(string key, ContentSource source = ContentSource.ModFolder) - { - switch (source) - { - case ContentSource.GameContent: - return this.GameContentManager.AssertAndNormalizeAssetName(key); - - case ContentSource.ModFolder: - return this.ModContentManager.GetInternalAssetKey(key).Name; - - default: - throw new NotSupportedException($"Unknown content source '{source}'."); - } - } - - /// - public bool InvalidateCache(string key) - { - string actualKey = this.GetActualAssetKey(key, ContentSource.GameContent); - this.Monitor.Log($"Requested cache invalidation for '{actualKey}'."); - return this.ContentCore.InvalidateCache(asset => asset.Name.IsEquivalentTo(actualKey)).Any(); - } - - /// - public bool InvalidateCache() - where T : notnull - { - this.Monitor.Log($"Requested cache invalidation for all assets of type {typeof(T)}. This is an expensive operation and should be avoided if possible."); - return this.ContentCore.InvalidateCache((_, _, type) => typeof(T).IsAssignableFrom(type)).Any(); - } - - /// - public bool InvalidateCache(Func predicate) - { - this.Monitor.Log("Requested cache invalidation for all assets matching a predicate."); - return this.ContentCore.InvalidateCache(predicate).Any(); - } - - /// - public IAssetData GetPatchHelper(T data, string? assetName = null) - where T : notnull - { - if (data == null) - throw new ArgumentNullException(nameof(data), "Can't get a patch helper for a null value."); - - assetName ??= $"temp/{Guid.NewGuid():N}"; - - return new AssetDataForObject( - locale: this.CurrentLocale, - assetName: this.ContentCore.ParseAssetName(assetName, allowLocales: true/* no way to know if it's a game or mod asset here*/), - data: data, - getNormalizedPath: this.NormalizeAssetName, - reflection: this.Reflection - ); - } - - - /********* - ** Private methods - *********/ - /// Assert that the given key has a valid format. - /// The asset key to check. - /// The asset key is empty or contains invalid characters. - [SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")] - private void AssertAndNormalizeAssetName(string key) - { - this.ModContentManager.AssertAndNormalizeAssetName(key); - if (Path.IsPathRooted(key)) - throw new ArgumentException("The asset key must not be an absolute path."); - } - } -} -#endif diff --git a/src/SMAPI/Framework/ModHelpers/ModHelper.cs b/src/SMAPI/Framework/ModHelpers/ModHelper.cs index 531289d00..d1cf357e9 100644 --- a/src/SMAPI/Framework/ModHelpers/ModHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModHelper.cs @@ -1,9 +1,6 @@ using System; using System.IO; using StardewModdingAPI.Events; -#if SMAPI_DEPRECATED -using StardewModdingAPI.Framework.Deprecations; -#endif using StardewModdingAPI.Framework.Input; namespace StardewModdingAPI.Framework.ModHelpers @@ -11,16 +8,6 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Provides simplified APIs for writing mods. internal class ModHelper : BaseHelper, IModHelper, IDisposable { -#if SMAPI_DEPRECATED - /********* - ** Fields - *********/ - /// The backing field for . - [Obsolete("This only exists to support legacy code and will be removed in SMAPI 4.0.0.")] - private readonly ContentHelper ContentImpl; -#endif - - /********* ** Accessors *********/ @@ -30,25 +17,6 @@ internal class ModHelper : BaseHelper, IModHelper, IDisposable /// public IModEvents Events { get; } -#if SMAPI_DEPRECATED - /// - [Obsolete($"Use {nameof(IGameContentHelper)} or {nameof(IModContentHelper)} instead.")] - public IContentHelper Content - { - get - { - SCore.DeprecationManager.Warn( - source: this.Mod, - nounPhrase: $"{nameof(IModHelper)}.{nameof(IModHelper.Content)}", - version: "3.14.0", - severity: DeprecationLevel.PendingRemoval - ); - - return this.ContentImpl; - } - } -#endif - /// public IGameContentHelper GameContent { get; } @@ -88,7 +56,6 @@ public IContentHelper Content /// The full path to the mod's folder. /// Manages the game's input state for the current player instance. That may not be the main player in split-screen mode. /// Manages access to events raised by SMAPI. - /// An API for loading content assets. /// An API for loading content assets from the game's Content folder or via . /// An API for loading content assets from your mod's files. /// An API for managing content packs. @@ -100,13 +67,7 @@ public IContentHelper Content /// An API for reading translations stored in the mod's i18n folder. /// An argument is null or empty. /// The path does not exist on disk. - public ModHelper( - IModMetadata mod, string modDirectory, Func currentInputState, IModEvents events, -#if SMAPI_DEPRECATED - ContentHelper contentHelper, -#endif - IGameContentHelper gameContentHelper, IModContentHelper modContentHelper, IContentPackHelper contentPackHelper, ICommandHelper commandHelper, IDataHelper dataHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, IMultiplayerHelper multiplayer, ITranslationHelper translationHelper - ) + public ModHelper(IModMetadata mod, string modDirectory, Func currentInputState, IModEvents events, IGameContentHelper gameContentHelper, IModContentHelper modContentHelper, IContentPackHelper contentPackHelper, ICommandHelper commandHelper, IDataHelper dataHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, IMultiplayerHelper multiplayer, ITranslationHelper translationHelper) : base(mod) { // validate directory @@ -117,9 +78,6 @@ public ModHelper( // initialize this.DirectoryPath = modDirectory; -#if SMAPI_DEPRECATED - this.ContentImpl = contentHelper ?? throw new ArgumentNullException(nameof(contentHelper)); -#endif this.GameContent = gameContentHelper ?? throw new ArgumentNullException(nameof(gameContentHelper)); this.ModContent = modContentHelper ?? throw new ArgumentNullException(nameof(modContentHelper)); this.ContentPacks = contentPackHelper ?? throw new ArgumentNullException(nameof(contentPackHelper)); @@ -133,15 +91,6 @@ public ModHelper( this.Events = events; } -#if SMAPI_DEPRECATED - /// Get the underlying instance for . - [Obsolete("This only exists to support legacy code and will be removed in SMAPI 4.0.0.")] - public ContentHelper GetLegacyContentHelper() - { - return this.ContentImpl; - } -#endif - /**** ** Mod config file ****/ diff --git a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs index a4354e57d..2f03e7ded 100644 --- a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs +++ b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; @@ -11,6 +12,7 @@ using StardewModdingAPI.Metadata; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Utilities; +using StardewValley; namespace StardewModdingAPI.Framework.ModLoading { @@ -23,9 +25,6 @@ internal class AssemblyLoader : IDisposable /// Encapsulates monitoring and logging. private readonly IMonitor Monitor; - /// Whether to detect paranoid mode issues. - private readonly bool ParanoidMode; - /// Metadata for mapping assemblies to the current platform. private readonly PlatformAssemblyMap AssemblyMap; @@ -50,6 +49,9 @@ internal class AssemblyLoader : IDisposable /// Whether to rewrite mods for compatibility. private readonly bool RewriteMods; + /// Whether to include more technical details about broken mods in the TRACE logs. This is mainly useful for creating compatibility rewriters. + private readonly bool LogTechnicalDetailsForBrokenMods; + /********* ** Public methods @@ -59,11 +61,12 @@ internal class AssemblyLoader : IDisposable /// Encapsulates monitoring and logging. /// Whether to detect paranoid mode issues. /// Whether to rewrite mods for compatibility. - public AssemblyLoader(Platform targetPlatform, IMonitor monitor, bool paranoidMode, bool rewriteMods) + /// Whether to include more technical details about broken mods in the TRACE logs. This is mainly useful for creating compatibility rewriters. + public AssemblyLoader(Platform targetPlatform, IMonitor monitor, bool paranoidMode, bool rewriteMods, bool logTechnicalDetailsForBrokenMods) { this.Monitor = monitor; - this.ParanoidMode = paranoidMode; this.RewriteMods = rewriteMods; + this.LogTechnicalDetailsForBrokenMods = logTechnicalDetailsForBrokenMods; this.AssemblyMap = this.TrackForDisposal(Constants.GetAssemblyMap(targetPlatform)); // init resolver @@ -86,8 +89,18 @@ public AssemblyLoader(Platform targetPlatform, IMonitor monitor, bool paranoidMo } // init rewriters - this.InstructionHandlers = new InstructionMetadata().GetHandlers(this.ParanoidMode, this.RewriteMods).ToArray(); - + Stopwatch? timer = null; + if (logTechnicalDetailsForBrokenMods) + { + timer = new(); + timer.Start(); + } + this.InstructionHandlers = new InstructionMetadata().GetHandlers(paranoidMode, this.RewriteMods, logTechnicalDetailsForBrokenMods).ToArray(); + if (logTechnicalDetailsForBrokenMods) + { + timer!.Stop(); + monitor.Log($"[SMAPI] Initialized rewriters in {timer.ElapsedMilliseconds}ms"); + } } /// Preprocess and load an assembly. @@ -170,31 +183,6 @@ select name this.AssemblyDefinitionResolver.Add(assembly.Definition); } -#if SMAPI_DEPRECATED - // special case: clear legacy-DLL warnings if the mod bundles a copy - if (mod.Warnings.HasFlag(ModWarning.DetectedLegacyCachingDll)) - { - if (File.Exists(Path.Combine(mod.DirectoryPath, "System.Runtime.Caching.dll"))) - mod.RemoveWarning(ModWarning.DetectedLegacyCachingDll); - else - { - // remove duplicate warnings (System.Runtime.Caching.dll references these) - mod.RemoveWarning(ModWarning.DetectedLegacyConfigurationDll); - mod.RemoveWarning(ModWarning.DetectedLegacyPermissionsDll); - } - } - if (mod.Warnings.HasFlag(ModWarning.DetectedLegacyConfigurationDll)) - { - if (File.Exists(Path.Combine(mod.DirectoryPath, "System.Configuration.ConfigurationManager.dll"))) - mod.RemoveWarning(ModWarning.DetectedLegacyConfigurationDll); - } - if (mod.Warnings.HasFlag(ModWarning.DetectedLegacyPermissionsDll)) - { - if (File.Exists(Path.Combine(mod.DirectoryPath, "System.Security.Permissions.dll"))) - mod.RemoveWarning(ModWarning.DetectedLegacyPermissionsDll); - } -#endif - // throw if incompatibilities detected if (!assumeCompatible && mod.Warnings.HasFlag(ModWarning.BrokenCodeLoaded)) throw new IncompatibleInstructionException(); @@ -478,23 +466,6 @@ private void ProcessInstructionHandleResult(IModMetadata mod, IInstructionHandle mod.SetWarning(ModWarning.AccessesShell); break; -#if SMAPI_DEPRECATED - case InstructionHandleResult.DetectedLegacyCachingDll: - template = $"{logPrefix}Detected reference to System.Runtime.Caching.dll, which will be removed in SMAPI 4.0.0."; - mod.SetWarning(ModWarning.DetectedLegacyCachingDll); - break; - - case InstructionHandleResult.DetectedLegacyConfigurationDll: - template = $"{logPrefix}Detected reference to System.Configuration.ConfigurationManager.dll, which will be removed in SMAPI 4.0.0."; - mod.SetWarning(ModWarning.DetectedLegacyConfigurationDll); - break; - - case InstructionHandleResult.DetectedLegacyPermissionsDll: - template = $"{logPrefix}Detected reference to System.Security.Permissions.dll, which will be removed in SMAPI 4.0.0."; - mod.SetWarning(ModWarning.DetectedLegacyPermissionsDll); - break; -#endif - case InstructionHandleResult.None: break; @@ -505,9 +476,14 @@ private void ProcessInstructionHandleResult(IModMetadata mod, IInstructionHandle return; // format messages - string phrase = handler.Phrases.Any() - ? string.Join(", ", handler.Phrases) - : handler.DefaultPhrase; + string phrase; + if (!handler.Phrases.Any()) + phrase = handler.DefaultPhrase; + else if (this.LogTechnicalDetailsForBrokenMods && result == InstructionHandleResult.NotCompatible) + phrase = "\n - " + string.Join(";\n - ", handler.Phrases.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)); + else + phrase = string.Join(", ", handler.Phrases.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)); + this.Monitor.LogOnce(loggedMessages, template.Replace("$phrase", phrase)); } diff --git a/src/SMAPI/Framework/ModLoading/Finders/LegacyAssemblyFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/LegacyAssemblyFinder.cs deleted file mode 100644 index 773809075..000000000 --- a/src/SMAPI/Framework/ModLoading/Finders/LegacyAssemblyFinder.cs +++ /dev/null @@ -1,51 +0,0 @@ -#if SMAPI_DEPRECATED -using Mono.Cecil; -using StardewModdingAPI.Framework.ModLoading.Framework; - -namespace StardewModdingAPI.Framework.ModLoading.Finders -{ - /// Detects assembly references which will break in SMAPI 4.0.0. - internal class LegacyAssemblyFinder : BaseInstructionHandler - { - /********* - ** Public methods - *********/ - /// Construct an instance. - public LegacyAssemblyFinder() - : base(defaultPhrase: "legacy assembly references") { } - - - /// - public override bool Handle(ModuleDefinition module) - { - foreach (AssemblyNameReference assembly in module.AssemblyReferences) - { - InstructionHandleResult flag = this.GetFlag(assembly); - if (flag is InstructionHandleResult.None) - continue; - - this.MarkFlag(flag); - } - - return false; - } - - - /********* - ** Private methods - *********/ - /// Get the instruction handle flag for the given assembly reference, if any. - /// The assembly reference. - private InstructionHandleResult GetFlag(AssemblyNameReference assemblyRef) - { - return assemblyRef.Name switch - { - "System.Configuration.ConfigurationManager" => InstructionHandleResult.DetectedLegacyConfigurationDll, - "System.Runtime.Caching" => InstructionHandleResult.DetectedLegacyCachingDll, - "System.Security.Permission" => InstructionHandleResult.DetectedLegacyPermissionsDll, - _ => InstructionHandleResult.None - }; - } - } -} -#endif diff --git a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToInvalidMemberFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToInvalidMemberFinder.cs new file mode 100644 index 000000000..42e5cb82c --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToInvalidMemberFinder.cs @@ -0,0 +1,154 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Mono.Cecil; +using Mono.Cecil.Cil; +using StardewModdingAPI.Framework.ModLoading.Framework; + +namespace StardewModdingAPI.Framework.ModLoading.Finders +{ + /// Finds references to a field, property, or method which either doesn't exist or returns a different type than the code expects. + /// This implementation is purely heuristic. It should never return a false positive, but won't detect all cases. + internal class ReferenceToInvalidMemberFinder : BaseInstructionHandler + { + /********* + ** Fields + *********/ + /// The assembly names to which to heuristically detect broken references. + private readonly ISet ValidateReferencesToAssemblies; + + /// Whether to include more technical details about broken mods in the TRACE logs. This is mainly useful for creating compatibility rewriters. + private readonly bool LogTechnicalDetailsForBrokenMods; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The assembly names to which to heuristically detect broken references. + /// Whether to include more technical details about broken mods in the TRACE logs. This is mainly useful for creating compatibility rewriters. + public ReferenceToInvalidMemberFinder(ISet validateReferencesToAssemblies, bool logTechnicalDetailsForBrokenMods) + : base(defaultPhrase: "") + { + this.ValidateReferencesToAssemblies = validateReferencesToAssemblies; + this.LogTechnicalDetailsForBrokenMods = logTechnicalDetailsForBrokenMods; + } + + /// + 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? targetField = fieldRef.DeclaringType.Resolve()?.Fields.FirstOrDefault(p => p.Name == fieldRef.Name); + + // wrong return type + if (targetField != null && !RewriteHelper.LooksLikeSameType(fieldRef.FieldType, targetField.FieldType)) + this.MarkFlag(InstructionHandleResult.NotCompatible, $"reference to {this.GetMemberDisplayName(fieldRef)} (field returns {this.GetFriendlyTypeName(targetField.FieldType)}, not {this.GetFriendlyTypeName(fieldRef.FieldType)})"); + + // missing + else if (targetField == null || targetField.HasConstant || !RewriteHelper.HasSameNamespaceAndName(fieldRef.DeclaringType, targetField.DeclaringType)) + this.MarkFlag(InstructionHandleResult.NotCompatible, $"reference to {this.GetMemberDisplayName(fieldRef)} (no such field)"); + + return false; + } + + // method reference + MethodReference? methodRef = RewriteHelper.AsMethodReference(instruction); + if (methodRef != null && !this.IsUnsupported(methodRef)) + { + MethodDefinition? methodDef = methodRef.Resolve(); + + // wrong return type + if (methodDef != null && this.ShouldValidate(methodRef.DeclaringType)) + { + MethodDefinition[]? candidateMethods = methodRef.DeclaringType.Resolve()?.Methods.Where(found => found.Name == methodRef.Name).ToArray(); + if (candidateMethods?.Any() is true && candidateMethods.All(method => !RewriteHelper.LooksLikeSameType(method.ReturnType, methodDef.ReturnType))) + this.MarkFlag(InstructionHandleResult.NotCompatible, $"reference to {this.GetMemberDisplayName(methodDef)} (no such method returns {this.GetFriendlyTypeName(methodDef.ReturnType)})"); + } + + // missing + else if (methodDef is null) + { + string typeName; + if (this.IsProperty(methodRef)) + typeName = "property"; + else if (methodRef.Name == ".ctor") + typeName = "constructor"; + else + typeName = "method"; + + this.MarkFlag(InstructionHandleResult.NotCompatible, $"reference to {this.GetMemberDisplayName(methodRef)} (no such {typeName})"); + } + } + + return false; + } + + + /********* + ** Private methods + *********/ + /// Whether references to the given type should be validated. + /// The type reference. + private bool ShouldValidate([NotNullWhen(true)] TypeReference? type) + { + return type != null && this.ValidateReferencesToAssemblies.Contains(type.Scope.Name); + } + + /// Get whether a method reference is a special case that's not currently supported (e.g. array methods). + /// The method reference. + private bool IsUnsupported(MethodReference method) + { + return + method.DeclaringType.Name.Contains("["); // array methods + } + + /// Get the member name to show in logged messages. + /// The member reference. + private string GetMemberDisplayName(MemberReference memberRef) + { + if (this.LogTechnicalDetailsForBrokenMods) + return memberRef.FullName; + + string name = memberRef.Name; + if (memberRef is PropertyReference) + name = name[4..]; // remove `get_` or `set_` prefix + + return $"{memberRef.DeclaringType.FullName}.{name}"; + } + + /// Get a shorter type name for display. + /// The type reference. + private string GetFriendlyTypeName(TypeReference type) + { + // most common built-in types + switch (type.FullName) + { + case "System.Boolean": + return "bool"; + case "System.Int32": + return "int"; + case "System.String": + return "string"; + } + + // most common unambiguous namespaces + foreach (string @namespace in new[] { "Microsoft.Xna.Framework", "Netcode", "System", "System.Collections.Generic" }) + { + if (type.Namespace == @namespace) + return type.Name; + } + + return type.FullName; + } + + /// Get whether a method reference is a property getter or setter. + /// The method reference. + private bool IsProperty(MethodReference method) + { + return method.Name.StartsWith("get_") || method.Name.StartsWith("set_"); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs deleted file mode 100644 index f34542c30..000000000 --- a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using Mono.Cecil; -using Mono.Cecil.Cil; -using StardewModdingAPI.Framework.ModLoading.Framework; - -namespace StardewModdingAPI.Framework.ModLoading.Finders -{ - /// Finds references to a field, property, or method which returns a different type than the code expects. - /// This implementation is purely heuristic. It should never return a false positive, but won't detect all cases. - internal class ReferenceToMemberWithUnexpectedTypeFinder : BaseInstructionHandler - { - /********* - ** Fields - *********/ - /// The assembly names to which to heuristically detect broken references. - private readonly ISet ValidateReferencesToAssemblies; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The assembly names to which to heuristically detect broken references. - public ReferenceToMemberWithUnexpectedTypeFinder(ISet validateReferencesToAssemblies) - : base(defaultPhrase: "") - { - this.ValidateReferencesToAssemblies = validateReferencesToAssemblies; - } - - /// - public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction) - { - // field reference - FieldReference? fieldRef = RewriteHelper.AsFieldReference(instruction); - if (fieldRef != null && this.ShouldValidate(fieldRef.DeclaringType)) - { - // get target field - FieldDefinition? targetField = fieldRef.DeclaringType.Resolve()?.Fields.FirstOrDefault(p => p.Name == fieldRef.Name); - if (targetField == null) - return false; - - // validate return type - if (!RewriteHelper.LooksLikeSameType(fieldRef.FieldType, targetField.FieldType)) - { - this.MarkFlag(InstructionHandleResult.NotCompatible, $"reference to {fieldRef.DeclaringType.FullName}.{fieldRef.Name} (field returns {this.GetFriendlyTypeName(targetField.FieldType)}, not {this.GetFriendlyTypeName(fieldRef.FieldType)})"); - return false; - } - } - - // method reference - MethodReference? methodReference = RewriteHelper.AsMethodReference(instruction); - if (methodReference != null && !this.IsUnsupported(methodReference) && this.ShouldValidate(methodReference.DeclaringType)) - { - // get potential targets - MethodDefinition[]? candidateMethods = methodReference.DeclaringType.Resolve()?.Methods.Where(found => found.Name == methodReference.Name).ToArray(); - if (candidateMethods == null || !candidateMethods.Any()) - return false; - - // compare return types - MethodDefinition? methodDef = methodReference.Resolve(); - if (methodDef == null) - return false; // validated by ReferenceToMissingMemberFinder - - if (candidateMethods.All(method => !RewriteHelper.LooksLikeSameType(method.ReturnType, methodDef.ReturnType))) - { - this.MarkFlag(InstructionHandleResult.NotCompatible, $"reference to {methodDef.DeclaringType.FullName}.{methodDef.Name} (no such method returns {this.GetFriendlyTypeName(methodDef.ReturnType)})"); - return false; - } - } - - return false; - } - - - /********* - ** Private methods - *********/ - /// Whether references to the given type should be validated. - /// The type reference. - private bool ShouldValidate([NotNullWhen(true)] TypeReference? type) - { - return type != null && this.ValidateReferencesToAssemblies.Contains(type.Scope.Name); - } - - /// Get whether a method reference is a special case that's not currently supported (e.g. array methods). - /// The method reference. - private bool IsUnsupported(MethodReference method) - { - return - method.DeclaringType.Name.Contains("["); // array methods - } - - /// Get a shorter type name for display. - /// The type reference. - private string GetFriendlyTypeName(TypeReference type) - { - // most common built-in types - switch (type.FullName) - { - case "System.Boolean": - return "bool"; - case "System.Int32": - return "int"; - case "System.String": - return "string"; - } - - // most common unambiguous namespaces - foreach (string @namespace in new[] { "Microsoft.Xna.Framework", "Netcode", "System", "System.Collections.Generic" }) - { - if (type.Namespace == @namespace) - return type.Name; - } - - return type.FullName; - } - } -} diff --git a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs deleted file mode 100644 index fae7fb125..000000000 --- a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Mono.Cecil; -using Mono.Cecil.Cil; -using StardewModdingAPI.Framework.ModLoading.Framework; - -namespace StardewModdingAPI.Framework.ModLoading.Finders -{ - /// Finds references to a field, property, or method which no longer exists. - /// This implementation is purely heuristic. It should never return a false positive, but won't detect all cases. - internal class ReferenceToMissingMemberFinder : BaseInstructionHandler - { - /********* - ** Fields - *********/ - /// The assembly names to which to heuristically detect broken references. - private readonly ISet ValidateReferencesToAssemblies; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The assembly names to which to heuristically detect broken references. - public ReferenceToMissingMemberFinder(ISet validateReferencesToAssemblies) - : base(defaultPhrase: "") - { - this.ValidateReferencesToAssemblies = validateReferencesToAssemblies; - } - - /// - 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.Resolve(); - if (target == null || target.HasConstant) - { - this.MarkFlag(InstructionHandleResult.NotCompatible, $"reference to {fieldRef.DeclaringType.FullName}.{fieldRef.Name} (no such field)"); - return false; - } - } - - // method reference - MethodReference? methodRef = RewriteHelper.AsMethodReference(instruction); - if (methodRef != null && this.ShouldValidate(methodRef.DeclaringType) && !this.IsUnsupported(methodRef)) - { - MethodDefinition? target = methodRef.Resolve(); - if (target == 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") - phrase = $"reference to {methodRef.DeclaringType.FullName}.{methodRef.Name} (no matching constructor)"; - else - phrase = $"reference to {methodRef.DeclaringType.FullName}.{methodRef.Name} (no such method)"; - - this.MarkFlag(InstructionHandleResult.NotCompatible, phrase); - return false; - } - } - - return false; - } - - - /********* - ** Private methods - *********/ - /// Whether references to the given type should be validated. - /// The type reference. - private bool ShouldValidate([NotNullWhen(true)] TypeReference? type) - { - return type != null && this.ValidateReferencesToAssemblies.Contains(type.Scope.Name); - } - - /// Get whether a method reference is a special case that's not currently supported (e.g. array methods). - /// The method reference. - private bool IsUnsupported(MethodReference method) - { - return - method.DeclaringType.Name.Contains("["); // array methods - } - - /// Get whether a method reference is a property getter or setter. - /// The method reference. - private bool IsProperty(MethodReference method) - { - return method.Name.StartsWith("get_") || method.Name.StartsWith("set_"); - } - } -} diff --git a/src/SMAPI/Framework/ModLoading/Framework/RewriteHelper.cs b/src/SMAPI/Framework/ModLoading/Framework/RewriteHelper.cs index 5f93510a4..257f95224 100644 --- a/src/SMAPI/Framework/ModLoading/Framework/RewriteHelper.cs +++ b/src/SMAPI/Framework/ModLoading/Framework/RewriteHelper.cs @@ -98,6 +98,26 @@ public static string GetFullCecilName(Type type) return $"{type.Namespace}.{type.Name}"; } + /// Get the resolved type for a Cecil type reference. + /// The type reference. + public static Type? GetCSharpType(TypeReference type) + { + string typeName = RewriteHelper.GetReflectionName(type); + return Type.GetType(typeName, false); + } + + /// Get the .NET reflection full name for a Cecil type reference. + /// The type reference. + public static string GetReflectionName(TypeReference type) + { + if (!type.IsGenericInstance) + return $"{type.FullName},{type.Scope.Name}"; + + var genericInstance = (GenericInstanceType) type; + var genericArgs = genericInstance.GenericArguments.Select(row => "[" + RewriteHelper.GetReflectionName(row) + "]"); + return $"{genericInstance.Namespace}.{type.Name}[{string.Join(",", genericArgs)}],{type.Scope.Name}"; + } + /// Get whether a type matches a type reference. /// The defined type. /// The type reference. @@ -173,6 +193,17 @@ public static bool LooksLikeSameType(TypeReference? typeA, TypeReference? typeB) return RewriteHelper.TypeDefinitionComparer.Equals(typeA, typeB); } + /// Get whether a type reference and definition have the same namespace and name. This does not guarantee they point to the same type due to generics. + /// The type reference. + /// The type definition. + /// This avoids an issue where we can't compare to because of the different ways they handle generics (e.g. List`1<System.String> vs List`1). + public static bool HasSameNamespaceAndName(TypeReference? typeReference, TypeDefinition? typeDefinition) + { + return + typeReference?.Namespace == typeDefinition?.Namespace + && typeReference?.Name == typeDefinition?.Name; + } + /// Get whether a method definition matches the signature expected by a method reference. /// The method definition. /// The method reference. diff --git a/src/SMAPI/Framework/ModLoading/Framework/SuppressReasons.cs b/src/SMAPI/Framework/ModLoading/Framework/SuppressReasons.cs index f2e723769..c960b5196 100644 --- a/src/SMAPI/Framework/ModLoading/Framework/SuppressReasons.cs +++ b/src/SMAPI/Framework/ModLoading/Framework/SuppressReasons.cs @@ -8,5 +8,8 @@ internal static class SuppressReasons /// A message indicating the code is used via assembly rewriting. public const string UsedViaRewriting = "This code is used via assembly rewriting."; + + /// A message indicating the code is used via assembly rewriting. + public const string BaseForClarity = "This code deliberately uses 'base' to ensure we're calling the real method instead of a rewritten one."; } } diff --git a/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs b/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs index 189ca64ef..e3f108cb8 100644 --- a/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs +++ b/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs @@ -30,17 +30,6 @@ internal enum InstructionHandleResult DetectedFilesystemAccess, /// The instruction accesses the OS shell or processes directly. - DetectedShellAccess, - -#if SMAPI_DEPRECATED - /// The module references the legacy System.Configuration.ConfigurationManager assembly and doesn't include a copy in the mod folder, so it'll break in SMAPI 4.0.0. - DetectedLegacyConfigurationDll, - - /// The module references the legacy System.Runtime.Caching assembly and doesn't include a copy in the mod folder, so it'll break in SMAPI 4.0.0. - DetectedLegacyCachingDll, - - /// The module references the legacy System.Security.Permissions assembly and doesn't include a copy in the mod folder, so it'll break in SMAPI 4.0.0. - DetectedLegacyPermissionsDll -#endif + DetectedShellAccess } } diff --git a/src/SMAPI/Framework/ModLoading/ModResolver.cs b/src/SMAPI/Framework/ModLoading/ModResolver.cs index 607bb70dc..923c3c1f4 100644 --- a/src/SMAPI/Framework/ModLoading/ModResolver.cs +++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs @@ -66,12 +66,13 @@ public IEnumerable ReadManifests(ModToolkit toolkit, string rootPa /// Validate manifest metadata. /// The mod manifests to validate. /// The current SMAPI version. + /// The current Stardew Valley version. /// Get an update URL for an update key (if valid). /// Get a file lookup for the given directory. /// Whether to validate that files referenced in the manifest (like ) exist on disk. This can be disabled to only validate the manifest itself. [SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract", Justification = "Manifest values may be null before they're validated.")] [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract", Justification = "Manifest values may be null before they're validated.")] - public void ValidateManifests(IEnumerable mods, ISemanticVersion apiVersion, Func getUpdateUrl, Func getFileLookup, bool validateFilesExist = true) + public void ValidateManifests(IEnumerable mods, ISemanticVersion apiVersion, ISemanticVersion gameVersion, Func getUpdateUrl, Func getFileLookup, bool validateFilesExist = true) { mods = mods.ToArray(); @@ -126,6 +127,13 @@ public void ValidateManifests(IEnumerable mods, ISemanticVersion a continue; } + // validate game version + if (mod.Manifest.MinimumGameVersion?.IsNewerThan(gameVersion) == true) + { + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.Incompatible, $"it needs Stardew Valley {mod.Manifest.MinimumGameVersion} or later. Please update your game to the latest version to use this mod."); + continue; + } + // validate manifest format if (!ManifestValidator.TryValidateFields(mod.Manifest, out string manifestError)) { @@ -335,6 +343,7 @@ from entry in dependencies // sorted successfully case ModDependencyStatus.Sorted: case ModDependencyStatus.Failed when !dependency.IsRequired: // ignore failed optional dependency + case ModDependencyStatus.Failed when modDatabase.Get(dependency.ID)?.IgnoreDependencies is true: // ignore failed dependency based on SMAPI metadata break; // failed, which means this mod can't be loaded either diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicFieldRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicFieldRewriter.cs index 9c6a39804..26d942df4 100644 --- a/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicFieldRewriter.cs +++ b/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicFieldRewriter.cs @@ -38,7 +38,7 @@ public override bool Handle(ModuleDefinition module, ILProcessor cil, Instructio // skip if not broken FieldDefinition? fieldDefinition = fieldRef.Resolve(); - if (fieldDefinition?.HasConstant == false) + if (fieldDefinition?.HasConstant == false && RewriteHelper.HasSameNamespaceAndName(fieldRef.DeclaringType, fieldDefinition.DeclaringType)) return false; // rewrite if possible @@ -46,7 +46,8 @@ public override bool Handle(ModuleDefinition module, ILProcessor cil, Instructio bool isRead = instruction.OpCode == OpCodes.Ldsfld || instruction.OpCode == OpCodes.Ldfld; return this.TryRewriteToProperty(module, instruction, fieldRef, declaringType, isRead) - || this.TryRewriteToConstField(instruction, fieldDefinition); + || this.TryRewriteToConstField(instruction, fieldDefinition) + || this.TryRewriteToInheritedField(module, instruction, fieldRef, fieldDefinition); } @@ -103,5 +104,32 @@ private bool TryRewriteToConstField(Instruction instruction, FieldDefinition? fi this.Phrases.Add($"{field.DeclaringType.Name}.{field.Name} (field => const)"); return this.MarkRewritten(); } + + /// Try rewriting the field into a matching inherited field. + /// The assembly module containing the instruction. + /// The CIL instruction to rewrite. + /// The field reference. + /// The actual field resolved by Cecil. + private bool TryRewriteToInheritedField(ModuleDefinition module, Instruction instruction, FieldReference fieldRef, FieldDefinition? fieldDefinition) + { + // skip if not resolvable + if (fieldDefinition == null) + return false; + + // skip if no rewrite needed + if (RewriteHelper.HasSameNamespaceAndName(fieldRef.DeclaringType, fieldDefinition.DeclaringType)) + return false; + + // skip if static (it's less intuitive that rewriting should happen) + if (instruction.OpCode != OpCodes.Ldfld) + return false; + + // rewrite reference + instruction.Operand = module.ImportReference(fieldDefinition); + fieldRef.FieldType = fieldDefinition.FieldType; + + this.Phrases.Add($"{fieldRef.DeclaringType.Name}.{fieldRef.Name} -> {fieldDefinition.DeclaringType.Name}.{fieldRef.Name} (field now inherited)"); + return this.MarkRewritten(); + } } } diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/IRewriteFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/IRewriteFacade.cs new file mode 100644 index 000000000..0619e3c04 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/IRewriteFacade.cs @@ -0,0 +1,5 @@ +namespace StardewModdingAPI.Framework.ModLoading.Rewriters +{ + /// Marker class for a rewrite facade used to validate mappings. See comments on for more info. + internal interface IRewriteFacade { } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/ReplaceReferencesRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/ReplaceReferencesRewriter.cs index aeb2e1725..14c8ddb99 100644 --- a/src/SMAPI/Framework/ModLoading/Rewriters/ReplaceReferencesRewriter.cs +++ b/src/SMAPI/Framework/ModLoading/Rewriters/ReplaceReferencesRewriter.cs @@ -21,6 +21,8 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters /// call the new members. /// /// + /// Member mappings are only used in cases where the reference to the original member can't be resolved. + /// /// /// To auto-map members to a facade type: /// @@ -44,7 +46,8 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters /// /// When adding a facade for a type with a required constructor, you'll need a constructor on the facade type. /// This should be private and will never be called (unless you want to rewrite references to the original - /// constructors per the above). + /// constructors per the above). You can call in the + /// private constructor to enforce that facades aren't constructed manually. /// /// internal class ReplaceReferencesRewriter : BaseInstructionHandler @@ -87,7 +90,7 @@ public ReplaceReferencesRewriter MapType(string fromFullName, Type toType) return this; } - /// Rewrite field references to point to another field with the same field type. + /// Rewrite field references to point to another field with the same field type (not necessarily on the same parent). /// The full field name, like Microsoft.Xna.Framework.Vector2 StardewValley.Character::Tile. /// The new type which will have the field. /// The new field name to reference. @@ -99,25 +102,57 @@ public ReplaceReferencesRewriter MapField(string fromFullName, Type toType, stri if (toType is null) throw new ArgumentException("Can't replace a field given a null target type.", nameof(toType)); if (string.IsNullOrWhiteSpace(toName)) - throw new ArgumentException("Can't replace a field given an empty target name.", nameof(toType)); + throw new ArgumentException("Can't replace a field given an empty target name.", nameof(toName)); // get field FieldInfo? toField; try { toField = toType.GetField(toName); - if (toField is null) - throw new InvalidOperationException($"Required field {toType.FullName}::{toName} could not be loaded."); } catch (Exception ex) { throw new InvalidOperationException($"Required field {toType.FullName}::{toName} could not be loaded.", ex); } + if (toField is null) + throw new InvalidOperationException($"Required field {toType.FullName}::{toName} could not be found."); // add mapping return this.MapMember(fromFullName, toField, "field"); } + /// Rewrite field references to point to another field with the field and parent type. + /// The type which has the old and new fields. + /// The field name. + /// The new field name to reference. + public ReplaceReferencesRewriter MapFieldName(Type type, string fromName, string toName) + { + // validate parameters + if (type is null) + throw new ArgumentException("Can't replace a field given a null target type.", nameof(type)); + if (string.IsNullOrWhiteSpace(fromName)) + throw new ArgumentException("Can't replace a field given an empty name.", nameof(fromName)); + if (string.IsNullOrWhiteSpace(toName)) + throw new ArgumentException("Can't replace a field given an empty target name.", nameof(toName)); + + // get field + FieldInfo? toField; + try + { + toField = type.GetField(toName); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Required field {type.FullName}::{toName} could not be loaded.", ex); + } + if (toField is null) + throw new InvalidOperationException($"Required field {type.FullName}::{toName} could not be found."); + + // add mapping + string fromFullName = $"{this.FormatCecilType(toField.FieldType)} {this.FormatCecilType(type)}::{fromName}"; + return this.MapMember(fromFullName, toField, "field"); + } + /// Rewrite field references to point to a property with the same return type. /// The full field name, like Microsoft.Xna.Framework.Vector2 StardewValley.Character::Tile. /// The new type which will have the field. @@ -137,13 +172,13 @@ public ReplaceReferencesRewriter MapFieldToProperty(string fromFullName, Type to try { toProperty = toType.GetProperty(toName); - if (toProperty is null) - throw new InvalidOperationException($"Required property {toType.FullName}::{toName} could not be loaded."); } catch (Exception ex) { throw new InvalidOperationException($"Required property {toType.FullName}::{toName} could not be loaded.", ex); } + if (toProperty is null) + throw new InvalidOperationException($"Required property {toType.FullName}::{toName} could not be found."); // add mapping return this.MapMember(fromFullName, toProperty, "field-to-property"); @@ -168,16 +203,16 @@ public ReplaceReferencesRewriter MapMethod(string fromFullName, Type toType, str MethodInfo? method; try { - method = parameterTypes != null + method = parameterTypes is not null ? toType.GetMethod(toName, parameterTypes) : toType.GetMethod(toName); - if (method is null) - throw new InvalidOperationException($"Required method {toType.FullName}::{toName} could not be loaded."); } catch (Exception ex) { throw new InvalidOperationException($"Required method {toType.FullName}::{toName} could not be loaded.", ex); } + if (method is null) + throw new InvalidOperationException($"Required method {toType.FullName}::{toName} could not be found."); // add mapping return this.MapMember(fromFullName, method, "method"); @@ -188,7 +223,11 @@ public ReplaceReferencesRewriter MapMethod(string fromFullName, Type toType, str /// The facade type to which to point matching references. /// If the facade has a public constructor with no parameters, whether to rewrite references to empty constructors to use that one. (This is needed because .NET has no way to distinguish between an implicit and explicit constructor.) public ReplaceReferencesRewriter MapFacade(bool mapDefaultConstructor = false) + where TFacade : TFromType, IRewriteFacade { + if (typeof(IRewriteFacade).IsAssignableFrom(typeof(TFromType))) + throw new InvalidOperationException("Can't rewrite a rewrite facade."); + return this.MapFacade(typeof(TFromType).FullName!, typeof(TFacade), mapDefaultConstructor); } @@ -205,12 +244,12 @@ public ReplaceReferencesRewriter MapFacade(string fromTypeName, Type toType, boo // add getter MethodInfo? get = property.GetMethod; - if (get != null) + if (get is not null) this.MapMember($"{propertyType} {fromTypeName}::get_{property.Name}()", get, "method"); // add setter MethodInfo? set = property.SetMethod; - if (set != null) + if (set is not null) this.MapMember($"System.Void {fromTypeName}::set_{property.Name}({propertyType})", set, "method"); // add field => property @@ -273,10 +312,32 @@ public override bool Handle(ModuleDefinition module, TypeReference type, Action< /// public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction) { - if (instruction.Operand is not MemberReference fromMember || !this.MemberMap.TryGetValue(fromMember.FullName, out MemberInfo? toMember)) + if (instruction.Operand is not MemberReference fromMember) + return false; + + // get target member + if (!this.MemberMap.TryGetValue(fromMember.FullName, out MemberInfo? mappedToMethod)) + { + // If this is a generic type, there's two cases where the above might not match: + // 1. we mapped an open generic type like "Netcode.NetFieldBase`2::op_Implicit" without specific + // generic types; + // 2. or due to Cecil's odd generic type handling, which can result in type names like + // "Netcode.NetFieldBase`2". + // + // In either case, we can check for a mapping registered using the simple generic name like + // "Netcode.NetFieldBase`2" (without type args) by using `GetElementType().FullName` instead. + if (fromMember.DeclaringType is not GenericInstanceType) + return false; + if (!this.MemberMap.TryGetValue($"{fromMember.DeclaringType.GetElementType().FullName}::{fromMember.Name}", out mappedToMethod)) + return false; + } + + // apply options + if (fromMember.Resolve() is not null) return false; - switch (toMember) + // apply + switch (mappedToMethod) { // constructor case ConstructorInfo toConstructor: @@ -285,6 +346,24 @@ public override bool Handle(ModuleDefinition module, ILProcessor cil, Instructio // method case MethodInfo toMethod: + // resolve generic method to a specific implementation + if (toMethod.DeclaringType?.IsGenericTypeDefinition is true && fromMember.DeclaringType is GenericInstanceType generic) + { + Type?[] arguments = generic.GenericArguments.Select(RewriteHelper.GetCSharpType).ToArray(); + foreach (Type? argument in arguments) + { + if (argument is null) + return false; + } + + MethodInfo? newMethod = toMethod.DeclaringType.MakeGenericType(arguments!)?.GetMethod(toMethod.Name); + if (newMethod is null) + return false; + + toMethod = newMethod; + } + + // rewrite instruction.Operand = module.ImportReference(toMethod); if (instruction.OpCode == OpCodes.Newobj) // rewriting constructor to static method @@ -307,7 +386,7 @@ public override bool Handle(ModuleDefinition module, ILProcessor cil, Instructio else if (instruction.OpCode == OpCodes.Stfld || instruction.OpCode == OpCodes.Stsfld) toPropMethod = toProperty.SetMethod; - if (toPropMethod != null) + if (toPropMethod is not null) { instruction.OpCode = OpCodes.Call; instruction.Operand = module.ImportReference(toPropMethod); diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_5/AccessToolsFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_5/AccessToolsFacade.cs deleted file mode 100644 index 76b4fdc7a..000000000 --- a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_5/AccessToolsFacade.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using HarmonyLib; -using StardewModdingAPI.Framework.ModLoading.Framework; - -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. - -namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_5 -{ - /// Maps Harmony 1.x methods to Harmony 2.x to avoid breaking older mods. - /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See for more info. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] - public class AccessToolsFacade - { - /********* - ** Public methods - *********/ - public static ConstructorInfo DeclaredConstructor(Type type, Type[]? parameters = null) - { - // Harmony 1.x matched both static and instance constructors - return - AccessTools.DeclaredConstructor(type, parameters, searchForStatic: false) - ?? AccessTools.DeclaredConstructor(type, parameters, searchForStatic: true); - } - - public static ConstructorInfo Constructor(Type type, Type[]? parameters = null) - { - // Harmony 1.x matched both static and instance constructors - return - AccessTools.Constructor(type, parameters, searchForStatic: false) - ?? AccessTools.Constructor(type, parameters, searchForStatic: true); - } - - public static List GetDeclaredConstructors(Type type) - { - // Harmony 1.x matched both static and instance constructors - return - AccessTools.GetDeclaredConstructors(type, searchForStatic: false) - ?? AccessTools.GetDeclaredConstructors(type, searchForStatic: true); - } - - - /********* - ** Private methods - *********/ - private AccessToolsFacade() - { - RewriteHelper.ThrowFakeConstructorCalled(); - } - } -} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_5/HarmonyInstanceFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_5/HarmonyInstanceFacade.cs deleted file mode 100644 index 7dfa5628a..000000000 --- a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_5/HarmonyInstanceFacade.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using System.Reflection.Emit; -using HarmonyLib; -using StardewModdingAPI.Framework.ModLoading.Framework; - -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. - -namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_5 -{ - /// Maps Harmony 1.x HarmonyInstance methods to Harmony 2.x's to avoid breaking older mods. - /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See for more info. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] - public class HarmonyInstanceFacade : Harmony - { - /********* - ** Public methods - *********/ - public static Harmony Create(string id) - { - return new Harmony(id); - } - - [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract", Justification = "If the user passes a null original method, we let it fail in the underlying Harmony instance instead of handling it here.")] - public DynamicMethod Patch(MethodBase original, HarmonyMethod? prefix = null, HarmonyMethod? postfix = null, HarmonyMethod? transpiler = null) - { - // In Harmony 1.x you could target a virtual method that's not implemented by the - // target type, but in Harmony 2.0 you need to target the concrete implementation. - // This just resolves the method to the concrete implementation if needed. - if (original != null) - original = original.GetDeclaredMember(); - - // call Harmony 2.0 and show a detailed exception if it fails - try - { - MethodInfo method = base.Patch(original: original, prefix: prefix, postfix: postfix, transpiler: transpiler); - return (DynamicMethod)method; - } - catch (Exception ex) - { - string patchTypes = this.GetPatchTypesLabel(prefix, postfix, transpiler); - string methodLabel = this.GetMethodLabel(original); - throw new Exception($"Harmony instance {this.Id} failed applying {patchTypes} to {methodLabel}.", ex); - } - } - - - /********* - ** Private methods - *********/ - private HarmonyInstanceFacade() - : base(null) - { - RewriteHelper.ThrowFakeConstructorCalled(); - } - - /// Get a human-readable label for the patch types being applies. - /// The prefix method, if any. - /// The postfix method, if any. - /// The transpiler method, if any. - private string GetPatchTypesLabel(HarmonyMethod? prefix = null, HarmonyMethod? postfix = null, HarmonyMethod? transpiler = null) - { - var patchTypes = new List(); - - if (prefix != null) - patchTypes.Add("prefix"); - if (postfix != null) - patchTypes.Add("postfix"); - if (transpiler != null) - patchTypes.Add("transpiler"); - - return string.Join("/", patchTypes); - } - - /// Get a human-readable label for the method being patched. - /// The method being patched. - private string GetMethodLabel(MethodBase? method) - { - return method != null - ? $"method {method.DeclaringType?.FullName}.{method.Name}" - : "null method"; - } - } -} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_5/HarmonyMethodFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_5/HarmonyMethodFacade.cs deleted file mode 100644 index 3e125a0ec..000000000 --- a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_5/HarmonyMethodFacade.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using HarmonyLib; -using StardewModdingAPI.Framework.ModLoading.Framework; - -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. - -namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_5 -{ - /// Maps Harmony 1.x methods to Harmony 2.x to avoid breaking older mods. - /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See for more info. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] - public class HarmonyMethodFacade : HarmonyMethod - { - /********* - ** Public methods - *********/ - public HarmonyMethodFacade(MethodInfo method) - { - this.ImportMethodImpl(method); - } - - public HarmonyMethodFacade(Type type, string name, Type[]? parameters = null) - { - this.ImportMethodImpl(AccessTools.Method(type, name, parameters)); - } - - - /********* - ** Private methods - *********/ - // note: we deliberately don't use RewriteHelper.ThrowFakeConstructorCalled() here, since the constructors are - // used via HarmonyRewriter. - - /// Import a method directly using the internal HarmonyMethod code. - /// The method to import. - private void ImportMethodImpl(MethodInfo methodInfo) - { - // A null method is no longer allowed in the constructor with Harmony 2.0, but the - // internal code still handles null fine. For backwards compatibility, this bypasses - // the new restriction when the mod hasn't been updated for Harmony 2.0 yet. - - MethodInfo? importMethod = typeof(HarmonyMethod).GetMethod("ImportMethod", BindingFlags.Instance | BindingFlags.NonPublic); - if (importMethod == null) - throw new InvalidOperationException("Can't find 'HarmonyMethod.ImportMethod' method"); - importMethod.Invoke(this, new object[] { methodInfo }); - } - } -} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_5/HarmonyRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_5/HarmonyRewriter.cs index e5f108ad4..902b5c4b8 100644 --- a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_5/HarmonyRewriter.cs +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_5/HarmonyRewriter.cs @@ -1,34 +1,18 @@ using System; -using System.Diagnostics.CodeAnalysis; -using HarmonyLib; using Mono.Cecil; -using Mono.Cecil.Cil; using StardewModdingAPI.Framework.ModLoading.Framework; namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_5 { - /// Detects Harmony references, and rewrites Harmony 1.x assembly references to work with Harmony 2.x. - internal class HarmonyRewriter : BaseInstructionHandler + /// Detects Harmony references. + internal class HarmonyDetector : BaseInstructionHandler { - /********* - ** Fields - *********/ - /// Whether any Harmony 1.x types were replaced. - private bool ReplacedTypes; - - /// Whether to rewrite Harmony 1.x code. - private readonly bool ShouldRewrite; - - /********* ** Public methods *********/ /// Construct an instance. - public HarmonyRewriter(bool shouldRewrite = true) - : base(defaultPhrase: "Harmony 1.x") - { - this.ShouldRewrite = shouldRewrite; - } + public HarmonyDetector() + : base(defaultPhrase: "Harmony 1.x") { } /// public override bool Handle(ModuleDefinition module, TypeReference type, Action replaceWith) @@ -37,116 +21,8 @@ public override bool Handle(ModuleDefinition module, TypeReference type, Action< if (type.Scope is not AssemblyNameReference scope || scope.Name != "0Harmony") return false; - // rewrite Harmony 1.x type to Harmony 2.0 type - if (this.ShouldRewrite && scope.Version.Major == 1) - { - Type targetType = this.GetMappedType(type); - replaceWith(module.ImportReference(targetType)); - this.OnChanged(); - this.ReplacedTypes = true; - return true; - } - this.MarkFlag(InstructionHandleResult.DetectedGamePatch); return false; } - - /// - [SuppressMessage("ReSharper", "StringLiteralTypo", Justification = SuppressReasons.MatchesOriginal)] - public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction) - { - if (this.ShouldRewrite) - { - // rewrite Harmony 1.x methods to Harmony 2.0 - MethodReference? methodRef = RewriteHelper.AsMethodReference(instruction); - if (this.TryRewriteMethodsToFacade(module, methodRef)) - { - this.OnChanged(); - return true; - } - - // rewrite renamed fields - FieldReference? fieldRef = RewriteHelper.AsFieldReference(instruction); - if (fieldRef != null) - { - if (fieldRef.DeclaringType.FullName == "HarmonyLib.HarmonyMethod" && fieldRef.Name == "prioritiy") - { - fieldRef.Name = nameof(HarmonyMethod.priority); - this.OnChanged(); - } - } - } - - return false; - } - - /// - public override void Reset() - { - base.Reset(); - - this.ReplacedTypes = false; - } - - - /********* - ** Private methods - *********/ - /// Update the mod metadata when any Harmony 1.x code is migrated. - private void OnChanged() - { - this.MarkRewritten(); - this.MarkFlag(InstructionHandleResult.DetectedGamePatch); - } - - /// Rewrite methods to use Harmony facades if needed. - /// The assembly module containing the method reference. - /// The method reference to map. - private bool TryRewriteMethodsToFacade(ModuleDefinition module, MethodReference? methodRef) - { - if (!this.ReplacedTypes) - return false; // not Harmony (or already using Harmony 2.0) - - // get facade type - Type? toType = methodRef?.DeclaringType.FullName switch - { - "HarmonyLib.Harmony" => typeof(HarmonyInstanceFacade), - "HarmonyLib.AccessTools" => typeof(AccessToolsFacade), - "HarmonyLib.HarmonyMethod" => typeof(HarmonyMethodFacade), - _ => null - }; - if (toType == null) - return false; - - // map if there's a matching method - if (RewriteHelper.HasMatchingSignature(toType, methodRef!)) - { - methodRef!.DeclaringType = module.ImportReference(toType); - return true; - } - - return false; - } - - /// Get an equivalent Harmony 2.x type. - /// The Harmony 1.x type. - private Type GetMappedType(TypeReference type) - { - return type.FullName switch - { - "Harmony.HarmonyInstance" => typeof(Harmony), - "Harmony.ILCopying.ExceptionBlock" => typeof(ExceptionBlock), - _ => this.GetMappedTypeByConvention(type) - }; - } - - /// Get an equivalent Harmony 2.x type using the convention expected for most types. - /// The Harmony 1.x type. - private Type GetMappedTypeByConvention(TypeReference type) - { - string fullName = type.FullName.Replace("Harmony.", "HarmonyLib."); - string targetName = typeof(Harmony).AssemblyQualifiedName!.Replace(typeof(Harmony).FullName!, fullName); - return Type.GetType(targetName, throwOnError: true)!; - } } } diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_5/SpriteBatchFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_5/SpriteBatchFacade.cs index 0b36546c1..b0d5897aa 100644 --- a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_5/SpriteBatchFacade.cs +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_5/SpriteBatchFacade.cs @@ -11,7 +11,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_5 /// Provides method signatures that can be injected into mod code for compatibility with mods written for XNA Framework before Stardew Valley 1.5.5. /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] - public class SpriteBatchFacade : SpriteBatch + public class SpriteBatchFacade : SpriteBatch, IRewriteFacade { /**** ** XNA signatures diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/AbigailGameFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/AbigailGameFacade.cs new file mode 100644 index 000000000..bc6d3fa45 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/AbigailGameFacade.cs @@ -0,0 +1,36 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Minigames; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class AbigailGameFacade : AbigailGame, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static AbigailGame Constructor(bool playingWithAbby = false) + { + return new AbigailGame( + playingWithAbby + ? Game1.getCharacterFromName("Abigail") + : null + ); + } + + + /********* + ** Private methods + *********/ + private AbigailGameFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/AnimalHouseFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/AnimalHouseFacade.cs new file mode 100644 index 000000000..44e70dc2d --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/AnimalHouseFacade.cs @@ -0,0 +1,34 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Buildings; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class AnimalHouseFacade : AnimalHouse, IRewriteFacade + { + /********* + ** Public methods + *********/ + public Building getBuilding() + { + return base.GetContainingBuilding(); + } + + + /********* + ** Private methods + *********/ + private AnimalHouseFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BasicProjectileFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BasicProjectileFacade.cs new file mode 100644 index 000000000..cefb0be29 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BasicProjectileFacade.cs @@ -0,0 +1,61 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Projectiles; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class BasicProjectileFacade : BasicProjectile, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static BasicProjectile Constructor(int damageToFarmer, int parentSheetIndex, int bouncesTillDestruct, int tailLength, float rotationVelocity, float xVelocity, float yVelocity, Vector2 startingPosition, string collisionSound, string firingSound, bool explode, bool damagesMonsters = false, GameLocation? location = null, Character? firer = null, bool spriteFromObjectSheet = false, onCollisionBehavior? collisionBehavior = null) + { + var projectile = new BasicProjectile( + damageToFarmer: damageToFarmer, + spriteIndex: parentSheetIndex, + bouncesTillDestruct: bouncesTillDestruct, + tailLength: tailLength, + rotationVelocity: rotationVelocity, + xVelocity: xVelocity, + yVelocity: yVelocity, + startingPosition: startingPosition + ); + + projectile.explode.Value = explode; + projectile.collisionSound.Value = collisionSound; + projectile.damagesMonsters.Value = damagesMonsters; + projectile.theOneWhoFiredMe.Set(location, firer); + projectile.itemId.Value = spriteFromObjectSheet ? parentSheetIndex.ToString() : null; + projectile.collisionBehavior = collisionBehavior; + + if (!string.IsNullOrWhiteSpace(firingSound) && location != null) + location.playSound(firingSound); + + return projectile; + } + + public static BasicProjectile Constructor(int damageToFarmer, int parentSheetIndex, int bouncesTillDestruct, int tailLength, float rotationVelocity, float xVelocity, float yVelocity, Vector2 startingPosition) + { + return Constructor(damageToFarmer, parentSheetIndex, bouncesTillDestruct, tailLength, rotationVelocity, xVelocity, yVelocity, startingPosition, "flameSpellHit", "flameSpell", true); + } + + + /********* + ** Private methods + *********/ + private BasicProjectileFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BedFurnitureFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BedFurnitureFacade.cs new file mode 100644 index 000000000..5156c8e8c --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BedFurnitureFacade.cs @@ -0,0 +1,48 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Objects; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class BedFurnitureFacade : BedFurniture, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static BedFurniture Constructor(int which, Vector2 tile, int initialRotations) + { + return new BedFurniture(which.ToString(), tile, initialRotations); + } + + public static BedFurniture Constructor(int which, Vector2 tile) + { + return new BedFurniture(which.ToString(), tile); + } + + public bool CanModifyBed(GameLocation location, Farmer who) + { + return base.CanModifyBed(who); + } + + public bool IsBeingSleptIn(GameLocation location) + { + return base.IsBeingSleptIn(); + } + + + /********* + ** Private methods + *********/ + private BedFurnitureFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BoatTunnelFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BoatTunnelFacade.cs new file mode 100644 index 000000000..f76f8ca21 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BoatTunnelFacade.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.Locations; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class BoatTunnelFacade : BoatTunnel, IRewriteFacade + { + /********* + ** Public methods + *********/ + public int GetTicketPrice() + { + return base.TicketPrice; + } + + + /********* + ** Private methods + *********/ + private BoatTunnelFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BootsFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BootsFacade.cs new file mode 100644 index 000000000..a3e1a37f6 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BootsFacade.cs @@ -0,0 +1,44 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Objects; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class BootsFacade : Boots, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static Boots Constructor(int which) + { + return new Boots(which.ToString()); + } + + public virtual void onEquip() + { + base.onEquip(Game1.player); + } + + public virtual void onUnequip() + { + base.onUnequip(Game1.player); + } + + + /********* + ** Private methods + *********/ + private BootsFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BreakableContainer.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BreakableContainer.cs new file mode 100644 index 000000000..244842e68 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BreakableContainer.cs @@ -0,0 +1,54 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Locations; +using StardewValley.Objects; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + public class BreakableContainerFacade : BreakableContainer, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static BreakableContainer Constructor(Vector2 tile, int type, MineShaft mine) + { + var container = BreakableContainer.GetBarrelForMines(tile, mine); + + if (type.ToString() != BreakableContainer.barrelId) + { +#pragma warning disable CS0618 // obsolete code -- it's used for its intended purpose here + container.SetIdAndSprite(type); +#pragma warning restore CS0618 + } + + return container; + } + + public static BreakableContainer Constructor(Vector2 tile, bool isVolcano) + { + return BreakableContainer.GetBarrelForVolcanoDungeon(tile); + } + + public void releaseContents(GameLocation location, Farmer who) + { + base.releaseContents(who); + } + + + /********* + ** Private methods + *********/ + private BreakableContainerFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BuffFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BuffFacade.cs new file mode 100644 index 000000000..d479334af --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BuffFacade.cs @@ -0,0 +1,63 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Buffs; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "ParameterHidesMember", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = SuppressReasons.BaseForClarity)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = SuppressReasons.MatchesOriginal)] + public class BuffFacade : Buff, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static Buff Constructor(string description, int millisecondsDuration, string source, int index) + { + return new Buff(index.ToString(), source, description: description, duration: millisecondsDuration, iconSheetIndex: index); + } + + public static Buff Constructor(int which) + { + return new Buff(which.ToString()); + } + + public static Buff Constructor(int farming, int fishing, int mining, int digging, int luck, int foraging, int crafting, int maxStamina, int magneticRadius, int speed, int defense, int attack, int minutesDuration, string source, string displaySource) + { + return new Buff( + null, + source, + displaySource, + duration: minutesDuration / Game1.realMilliSecondsPerGameMinute, + effects: new BuffEffects { FarmingLevel = { farming }, FishingLevel = { fishing }, MiningLevel = { mining }, LuckLevel = { luck }, ForagingLevel = { foraging }, MaxStamina = { maxStamina }, MagneticRadius = { magneticRadius }, Speed = { speed }, Defense = { defense }, Attack = { attack } } + ); + } + + public void addBuff() + { + Game1.player.buffs.Apply(this); + } + + public void removeBuff() + { + Game1.player.buffs.Remove(base.id); + } + + + /********* + ** Private methods + *********/ + private BuffFacade() + : base(null) + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BuffsDisplayFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BuffsDisplayFacade.cs new file mode 100644 index 000000000..3d2cd528e --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BuffsDisplayFacade.cs @@ -0,0 +1,33 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Menus; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class BuffsDisplayFacade : BuffsDisplay, IRewriteFacade + { + /********* + ** Public methods + *********/ + public bool hasBuff(int which) + { + return Game1.player.hasBuff(which.ToString()); + } + + + /********* + ** Private methods + *********/ + private BuffsDisplayFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BuildableGameLocationFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BuildableGameLocationFacade.cs new file mode 100644 index 000000000..a66b096c3 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BuildableGameLocationFacade.cs @@ -0,0 +1,92 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Buildings; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's BuildableGameLocation methods to their newer form on to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "ParameterHidesMember", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = SuppressReasons.BaseForClarity)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class BuildableGameLocationFacade : GameLocation, IRewriteFacade + { + /********* + ** Public methods + *********/ + public new bool buildStructure(Building b, Vector2 tileLocation, Farmer who, bool skipSafetyChecks = false) + { + return base.buildStructure(b, tileLocation, who, skipSafetyChecks); + } + + public new bool destroyStructure(Vector2 tile) + { + return base.destroyStructure(tile); + } + + public new bool destroyStructure(Building b) + { + return base.destroyStructure(b); + } + + public new Building getBuildingAt(Vector2 tile) + { + return base.getBuildingAt(tile); + } + + public new Building getBuildingByName(string name) + { + return base.getBuildingByName(name); + } + + public Building? getBuildingUnderConstruction() + { + foreach (Building b in base.buildings) + { + if (b.daysOfConstructionLeft > 0 || b.daysUntilUpgrade > 0) + return b; + } + + return null; + } + + public int getNumberBuildingsConstructed(string name) + { + return base.getNumberBuildingsConstructed(name); + } + + public bool isBuildable(Vector2 tileLocation) + { + return base.isBuildable(tileLocation); + } + + public new bool isPath(Vector2 tileLocation) + { + return base.isPath(tileLocation); + } + + public new bool isBuildingConstructed(string name) + { + return base.isBuildingConstructed(name); + } + + public new bool isThereABuildingUnderConstruction() + { + return base.isThereABuildingUnderConstruction(); + } + + + /********* + ** Private methods + *********/ + private BuildableGameLocationFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BuildingFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BuildingFacade.cs new file mode 100644 index 000000000..eef69d46a --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BuildingFacade.cs @@ -0,0 +1,66 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Netcode; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6.Internal; +using StardewValley; +using StardewValley.Buildings; +using StardewValley.Objects; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = SuppressReasons.BaseForClarity)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class BuildingFacade : Building, IRewriteFacade + { + /********* + ** Accessors + *********/ + public NetRef input => NetRefWrapperCache.GetCachedWrapperFor(base.GetBuildingChest("Input")); // Mill + public NetRef output => NetRefWrapperCache.GetCachedWrapperFor(base.GetBuildingChest("Output")); // Mill + + public string nameOfIndoors + { + get + { + GameLocation? indoorLocation = base.GetIndoors(); + return indoorLocation is not null + ? indoorLocation.uniqueName.Value + : "null"; + } + } + + public string nameOfIndoorsWithoutUnique => base.GetData()?.IndoorMap ?? "null"; + + + /********* + ** Public methods + *********/ + public string getNameOfNextUpgrade() + { + string type = base.buildingType.Value; + + foreach (var pair in Game1.buildingData) + { + if (string.Equals(type, pair.Value?.BuildingToUpgrade, StringComparison.OrdinalIgnoreCase)) + return pair.Key; + } + + return "well"; // previous default + } + + + /********* + ** Private methods + *********/ + private BuildingFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BushFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BushFacade.cs new file mode 100644 index 000000000..e5d5356c0 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/BushFacade.cs @@ -0,0 +1,73 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.TerrainFeatures; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = SuppressReasons.BaseForClarity)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class BushFacade : Bush, IRewriteFacade + { + /********* + ** Public methods + *********/ + public void draw(Microsoft.Xna.Framework.Graphics.SpriteBatch spriteBatch, Vector2 tileLocation, float yDrawOffset) + { + base.draw(spriteBatch, yDrawOffset); + } + + public void draw(Microsoft.Xna.Framework.Graphics.SpriteBatch spriteBatch, Vector2 tileLocation) + { + base.draw(spriteBatch); + } + + public bool inBloom(string season, int dayOfMonth) + { + // call new method if possible + if (season == Game1.currentSeason && dayOfMonth == Game1.dayOfMonth) + return base.inBloom(); + + // else mimic old behavior with 1.6 features + if (base.size == Bush.greenTeaBush) + { + return + base.getAge() >= Bush.daysToMatureGreenTeaBush + && dayOfMonth >= 22 + && (season != "winter" || base.IsSheltered()); + } + + switch (season) + { + case "spring": + return dayOfMonth > 14 && dayOfMonth < 19; + + case "fall": + return dayOfMonth > 7 && dayOfMonth < 12; + + default: + return false; + } + } + + public bool isDestroyable(GameLocation location, Vector2 tile) + { + return base.isDestroyable(); + } + + + /********* + ** Private methods + *********/ + private BushFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ButterflyFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ButterflyFacade.cs new file mode 100644 index 000000000..ff00b3627 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ButterflyFacade.cs @@ -0,0 +1,34 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.BellsAndWhistles; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class ButterflyFacade : Butterfly, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static Butterfly Constructor(Vector2 position, bool islandButterfly = false) + { + return new Butterfly(Game1.currentLocation, position, islandButterfly); + } + + + /********* + ** Private methods + *********/ + private ButterflyFacade() + : base(null, Vector2.Zero) + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/CarpenterMenuFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/CarpenterMenuFacade.cs new file mode 100644 index 000000000..11a803e22 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/CarpenterMenuFacade.cs @@ -0,0 +1,40 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Menus; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class CarpenterMenuFacade : CarpenterMenu, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static CarpenterMenu Constructor(bool magicalConstruction = false) + { + return new CarpenterMenu(magicalConstruction ? Game1.builder_wizard : Game1.builder_robin); + } + + public void setNewActiveBlueprint() + { + base.SetNewActiveBlueprint(base.Blueprint); + } + + + /********* + ** Private methods + *********/ + private CarpenterMenuFacade() + : base(null) + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/CaskFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/CaskFacade.cs new file mode 100644 index 000000000..c5c765981 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/CaskFacade.cs @@ -0,0 +1,34 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Objects; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class CaskFacade : Cask, IRewriteFacade + { + /********* + ** Public methods + *********/ + public bool IsValidCaskLocation(GameLocation location) + { + return base.IsValidCaskLocation(); + } + + + /********* + ** Private methods + *********/ + private CaskFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/CharacterFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/CharacterFacade.cs new file mode 100644 index 000000000..644385d27 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/CharacterFacade.cs @@ -0,0 +1,70 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = SuppressReasons.BaseForClarity)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class CharacterFacade : Character, IRewriteFacade + { + /********* + ** Public methods + *********/ + public new int addedSpeed + { + get => (int)base.addedSpeed; + set => base.addedSpeed = value; + } + + public int getStandingX() + { + return base.StandingPixel.X; + } + + public int getStandingY() + { + return base.StandingPixel.Y; + } + + public Point getStandingXY() + { + return base.StandingPixel; + } + + public Vector2 getTileLocation() + { + return base.Tile; + } + + public Point getTileLocationPoint() + { + return base.TilePoint; + } + + public int getTileX() + { + return base.TilePoint.X; + } + + public int getTileY() + { + return base.TilePoint.Y; + } + + + /********* + ** Private methods + *********/ + private CharacterFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ChestFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ChestFacade.cs new file mode 100644 index 000000000..1604c33fa --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ChestFacade.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using Netcode; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6.Internal; +using StardewValley; +using StardewValley.Objects; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = SuppressReasons.MatchesOriginal)] + public class ChestFacade : Chest, IRewriteFacade + { + /********* + ** Accessors + *********/ + public NetObjectList items => InventoryToNetObjectList.GetCachedWrapperFor(base.Items); + + + /********* + ** Public methods + *********/ + public static Chest Constructor(bool playerChest, Vector2 tileLocation, int parentSheetIndex = 130) + { + return new Chest(playerChest, tileLocation, parentSheetIndex.ToString()); + } + + public static Chest Constructor(bool playerChest, int parentSheedIndex = 130) + { + return new Chest(playerChest, parentSheedIndex.ToString()); + } + + public static Chest Constructor(Vector2 location) + { + return new Chest { TileLocation = location }; + } + + public static Chest Constructor(int parent_sheet_index, Vector2 tile_location, int starting_lid_frame, int lid_frame_count) + { + return new Chest(parent_sheet_index.ToString(), tile_location, starting_lid_frame, lid_frame_count); + } + + public static Chest Constructor(int coins, List items, Vector2 location, bool giftbox = false, int giftboxIndex = 0) + { + return new Chest(items, location, giftbox, giftboxIndex); + } + + public void destroyAndDropContents(Vector2 pointToDropAt, GameLocation location) + { + base.destroyAndDropContents(pointToDropAt); + } + + public void dumpContents(GameLocation location) + { + base.dumpContents(); + } + + public void updateWhenCurrentLocation(GameTime time, GameLocation environment) + { + base.updateWhenCurrentLocation(time); + } + + + /********* + ** Private methods + *********/ + private ChestFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ClothingFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ClothingFacade.cs new file mode 100644 index 000000000..9d7bf226b --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ClothingFacade.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.Objects; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class ClothingFacade : Clothing, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static Clothing Constructor(int item_index) + { + return new Clothing(item_index.ToString()); + } + + + /********* + ** Private methods + *********/ + private ClothingFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ColoredObjectFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ColoredObjectFacade.cs new file mode 100644 index 000000000..ce6017502 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ColoredObjectFacade.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.Objects; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class ColoredObjectFacade : ColoredObject, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static ColoredObject Constructor(int parentSheetIndex, int stack, Color color) + { + return new ColoredObject(parentSheetIndex.ToString(), stack, color); + } + + + /********* + ** Private methods + *********/ + private ColoredObjectFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/CrabPotFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/CrabPotFacade.cs new file mode 100644 index 000000000..a7795af0f --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/CrabPotFacade.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.Objects; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = SuppressReasons.MatchesOriginal)] + public class CrabPotFacade : CrabPot, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static CrabPot Constructor(Vector2 tileLocation, int stack = 1) + { + return new CrabPot(); + } + + /********* + ** Private methods + *********/ + private CrabPotFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/CraftingRecipeFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/CraftingRecipeFacade.cs new file mode 100644 index 000000000..df5566a09 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/CraftingRecipeFacade.cs @@ -0,0 +1,50 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Extensions; +using StardewValley.ItemTypeDefinitions; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class CraftingRecipeFacade : CraftingRecipe, IRewriteFacade + { + /********* + ** Public methods + *********/ + public string getNameFromIndex(int index) + { + return base.getNameFromIndex(index.ToString()); + } + + public int getSpriteIndexFromRawIndex(int index) + { + string itemId = base.getSpriteIndexFromRawIndex(index.ToString()); + ParsedItemData? data = ItemRegistry.GetData(itemId); + + return data.HasTypeObject() + ? data.SpriteIndex + : index; + } + + public static bool isThereSpecialIngredientRule(Object potentialIngredient, int requiredIngredient) + { + return CraftingRecipe.isThereSpecialIngredientRule(potentialIngredient, requiredIngredient.ToString()); + } + + + /********* + ** Private methods + *********/ + private CraftingRecipeFacade() + : base(null) + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/CropFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/CropFacade.cs new file mode 100644 index 000000000..100b112bd --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/CropFacade.cs @@ -0,0 +1,42 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class CropFacade : Crop, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static Crop Constructor(bool forageCrop, int which, int tileX, int tileY) + { + return new Crop(forageCrop, which.ToString(), tileX, tileY, Game1.currentLocation); + } + + public static Crop Constructor(int seedIndex, int tileX, int tileY) + { + return new Crop(seedIndex.ToString(), tileX, tileY, Game1.currentLocation); + } + + public void newDay(int state, int fertilizer, int xTile, int yTile, GameLocation environment) + { + base.newDay(state); + } + + + /********* + ** Private methods + *********/ + private CropFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/DebuffingProjectileFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/DebuffingProjectileFacade.cs new file mode 100644 index 000000000..38cf0a9ef --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/DebuffingProjectileFacade.cs @@ -0,0 +1,46 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Projectiles; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class DebuffingProjectileFacade : DebuffingProjectile, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static DebuffingProjectile Constructor(int debuff, int parentSheetIndex, int bouncesTillDestruct, int tailLength, float rotationVelocity, float xVelocity, float yVelocity, Vector2 startingPosition, GameLocation? location = null, Character? owner = null) + { + return new DebuffingProjectile( + debuff: debuff.ToString(), + spriteIndex: parentSheetIndex, + bouncesTillDestruct: bouncesTillDestruct, + tailLength: tailLength, + rotationVelocity: rotationVelocity, + xVelocity: xVelocity, + yVelocity: yVelocity, + startingPosition: startingPosition, + location: location, + owner: owner + ); + } + + + /********* + ** Private methods + *********/ + private DebuffingProjectileFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/DelayedActionFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/DelayedActionFacade.cs new file mode 100644 index 000000000..6021efaaa --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/DelayedActionFacade.cs @@ -0,0 +1,39 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class DelayedActionFacade : DelayedAction, IRewriteFacade + { + /********* + ** Public methods + *********/ + public new static void functionAfterDelay(Action func, int timer) + { + DelayedAction.functionAfterDelay(func, timer); + } + + public static void playSoundAfterDelay(string soundName, int timer, GameLocation? location = null, int pitch = -1) + { + DelayedAction.playSoundAfterDelay(soundName, timer, location, pitch: pitch); + } + + + /********* + ** Private methods + *********/ + private DelayedActionFacade() + : base(0) + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/DialogueBoxFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/DialogueBoxFacade.cs new file mode 100644 index 000000000..4c1676937 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/DialogueBoxFacade.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Menus; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class DialogueBoxFacade : DialogueBox, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static DialogueBox Constructor(string dialogue, List responses, int width = 1200) + { + return new DialogueBox(dialogue, responses.ToArray(), width); + } + + + /********* + ** Private methods + *********/ + public DialogueBoxFacade() + : base(null as string) + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/DialogueFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/DialogueFacade.cs new file mode 100644 index 000000000..43ef82f0b --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/DialogueFacade.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class DialogueFacade : Dialogue, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static Dialogue Constructor(string masterDialogue, NPC speaker) + { + return new Dialogue(speaker, null, masterDialogue); + } + + + /********* + ** Private methods + *********/ + private DialogueFacade() + : base(null, null) + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/DiscreteColorPickerFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/DiscreteColorPickerFacade.cs new file mode 100644 index 000000000..a3397f9b1 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/DiscreteColorPickerFacade.cs @@ -0,0 +1,39 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.Menus; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class DiscreteColorPickerFacade : DiscreteColorPicker, IRewriteFacade + { + /********* + ** Public methods + *********/ + public new int getSelectionFromColor(Color c) + { + return DiscreteColorPicker.getSelectionFromColor(c); + } + + public new Color getColorFromSelection(int selection) + { + return DiscreteColorPicker.getColorFromSelection(selection); + } + + + /********* + ** Private methods + *********/ + public DiscreteColorPickerFacade() + : base(0, 0) + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/EventFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/EventFacade.cs new file mode 100644 index 000000000..cd9f7ea0d --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/EventFacade.cs @@ -0,0 +1,33 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class EventFacade : Event, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static Event Constructor(string eventString, int eventID = -1, Farmer? farmerActor = null) + { + return new Event(eventString, null, eventID != -1 ? eventID.ToString() : null, farmerActor); + } + + + /********* + ** Private methods + *********/ + private EventFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FarmAnimalFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FarmAnimalFacade.cs new file mode 100644 index 000000000..ee4df5c8f --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FarmAnimalFacade.cs @@ -0,0 +1,40 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.GameData.FarmAnimals; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = SuppressReasons.BaseForClarity)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class FarmAnimalFacade : FarmAnimal, IRewriteFacade + { + /********* + ** Public methods + *********/ + public bool isCoopDweller() + { + FarmAnimalData? data = base.GetAnimalData(); + return data?.House == "Coop"; + } + + public void warpHome(Farm f, FarmAnimal a) + { + base.warpHome(); + } + + + /********* + ** Private methods + *********/ + private FarmAnimalFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FarmFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FarmFacade.cs new file mode 100644 index 000000000..eed21521d --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FarmFacade.cs @@ -0,0 +1,39 @@ +using System.Diagnostics.CodeAnalysis; +using System.Drawing; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = SuppressReasons.BaseForClarity)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class FarmFacade : Farm, IRewriteFacade + { + /********* + ** Public methods + *********/ + public Point GetPetStartLocation() + { + var petBowl = Game1.player?.getPet()?.GetPetBowl(); + if (petBowl is not null) + return new Point(petBowl.tileX - 1, petBowl.tileY + 1); + + var petBowlPosition = base.GetStarterPetBowlLocation(); + return new Point((int)petBowlPosition.X - 1, (int)petBowlPosition.Y + 1); + } + + + /********* + ** Private methods + *********/ + private FarmFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FarmerFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FarmerFacade.cs new file mode 100644 index 000000000..ae6bcb63c --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FarmerFacade.cs @@ -0,0 +1,295 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using Netcode; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6.Internal; +using StardewValley; +using SObject = StardewValley.Object; + +#pragma warning disable CS0618 // Type or member is obsolete: this is backwards-compatibility code. +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "ParameterHidesMember", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = SuppressReasons.BaseForClarity)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class FarmerFacade : Farmer, IRewriteFacade + { + /********* + ** Accessors + *********/ + public NetObjectList items => InventoryToNetObjectList.GetCachedWrapperFor(base.Items); + + public int attack => base.buffs.Attack; + public int immunity => base.buffs.Immunity; + public int resilience => base.buffs.Defense; + + public float attackIncreaseModifier => base.buffs.AttackMultiplier; + public float critChanceModifier => base.buffs.CriticalChanceMultiplier; + public float critPowerModifier => base.buffs.CriticalPowerMultiplier; + public float knockbackModifier => base.buffs.KnockbackMultiplier; + public float weaponPrecisionModifier => base.buffs.WeaponPrecisionMultiplier; + public float weaponSpeedModifier => base.buffs.WeaponSpeedMultiplier; + + public new IList Items + { + get => base.Items; + set => base.Items.OverwriteWith(value); + } + + public new int toolPower + { + get => base.toolPower.Value; + set => base.toolPower.Value = value; + } + + public new int toolHold + { + get => base.toolHold.Value; + set => base.toolHold.Value = value; + } + + public int visibleQuestCount + { + get + { + int count = 0; + foreach (var quest in base.team.specialOrders) + { + if (quest?.IsHidden() is false) + count++; + } + + foreach (var quest in base.questLog) + { + if (quest?.IsHidden() is false) + count++; + } + return count; + } + } + + + /********* + ** Public methods + *********/ + public void addQuest(int questID) + { + base.addQuest(questID.ToString()); + } + + public bool areAllItemsNull() + { + return base.Items.CountItemStacks() == 0; + } + + public bool caughtFish(int index, int size, bool from_fish_pond = false, int numberCaught = 1) + { + return base.caughtFish(index.ToString(), size, from_fish_pond, numberCaught); + } + + public void changePants(Color color) + { + base.changePantsColor(color); + } + + public void changePantStyle(int whichPants, bool is_customization_screen = false) + { + base.changePantStyle(whichPants.ToString()); + } + + public void changeShirt(int whichShirt, bool is_customization_screen = false) + { + base.changeShirt(whichShirt.ToString()); + } + + public void changeShoeColor(int which) + { + base.changeShoeColor(which.ToString()); + } + + public void completeQuest(int questID) + { + base.completeQuest(questID.ToString()); + } + + public void cookedRecipe(int index) + { + base.cookedRecipe(index.ToString()); + } + + public bool couldInventoryAcceptThisObject(int index, int stack, int quality = 0) + { + return base.couldInventoryAcceptThisItem(index.ToString(), stack, quality); + } + + public void foundArtifact(int index, int number) + { + base.foundArtifact(index.ToString(), number); + } + + public void foundMineral(int index) + { + base.foundMineral(index.ToString()); + } + + public int GetEffectsOfRingMultiplier(int ring_index) + { + return base.GetEffectsOfRingMultiplier(ring_index.ToString()); + } + + public int getItemCount(int item_index, int min_price = 0) + { + // minPrice field was always ignored + + return base.getItemCount(item_index.ToString()); + } + + public bool hasBuff(int whichBuff) + { + return base.hasBuff(whichBuff.ToString()); + } + + public bool hasGiftTasteBeenRevealed(NPC npc, int item_index) + { + return base.hasGiftTasteBeenRevealed(npc, item_index.ToString()); + } + + public bool hasItemBeenGifted(NPC npc, int item_index) + { + return base.hasItemBeenGifted(npc, item_index.ToString()); + } + + public bool hasItemInInventory(int itemIndex, int quantity, int minPrice = 0) + { + // minPrice field was always ignored + + switch (itemIndex) + { + case 858: + return base.QiGems >= quantity; + + case 73: + return Game1.netWorldState.Value.GoldenWalnuts >= quantity; + + default: + return base.getItemCount(ItemRegistry.type_object + itemIndex) >= quantity; + } + } + + public bool hasItemInInventoryNamed(string? name) + { + if (name is not null) + { + foreach (Item item in base.Items) + { + if (item?.Name == name) + return true; + } + } + + return false; + } + + public Item? hasItemWithNameThatContains(string name) + { + foreach (Item item in base.Items) + { + if (item?.Name is not null && item.Name.Contains(name)) + return item; + } + + return null; + } + + public bool hasQuest(int id) + { + return base.hasQuest(id.ToString()); + } + + public bool isMarried() + { + return base.isMarriedOrRoommates(); + } + + public bool isWearingRing(int ringIndex) + { + return base.isWearingRing(ringIndex.ToString()); + } + + public void removeFirstOfThisItemFromInventory(int parentSheetIndexOfItem) + { + base.removeFirstOfThisItemFromInventory(parentSheetIndexOfItem.ToString()); + } + + public bool removeItemsFromInventory(int index, int stack) + { + if (this.hasItemInInventory(index, stack)) + { + switch (index) + { + case 858: + base.QiGems -= stack; + return true; + + case 73: + Game1.netWorldState.Value.GoldenWalnuts -= stack; + return true; + + default: + for (int i = 0; i < base.Items.Count; i++) + { + if (base.Items[i] is SObject obj && obj.parentSheetIndex == index) + { + if (obj.Stack > stack) + { + obj.Stack -= stack; + return true; + } + + stack -= obj.Stack; + base.Items[i] = null; + } + + if (stack <= 0) + return true; + } + return false; + } + } + + return false; + } + + public void removeQuest(int questID) + { + base.removeQuest(questID.ToString()); + } + + public void revealGiftTaste(NPC npc, int parent_sheet_index) + { + base.revealGiftTaste(npc.Name, parent_sheet_index.ToString()); + } + + public void revealGiftTaste(NPC npc, SObject item) + { + if (!item.bigCraftable) + base.revealGiftTaste(npc.Name, item.ItemId); + } + + + /********* + ** Private methods + *********/ + private FarmerFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FarmerRendererFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FarmerRendererFacade.cs new file mode 100644 index 000000000..37a19f18d --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FarmerRendererFacade.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class FarmerRendererFacade : FarmerRenderer, IRewriteFacade + { + /********* + ** Public methods + *********/ + public void recolorShoes(int which) + { + base.recolorShoes(which.ToString()); + } + + + /********* + ** Private methods + *********/ + private FarmerRendererFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FarmerTeamFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FarmerTeamFacade.cs new file mode 100644 index 000000000..6cc3e628e --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FarmerTeamFacade.cs @@ -0,0 +1,41 @@ +using System.Diagnostics.CodeAnalysis; +using Netcode; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6.Internal; +using StardewValley; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = SuppressReasons.BaseForClarity)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class FarmerTeamFacade : FarmerTeam, IRewriteFacade + { + /********* + ** Accessors + *********/ + public NetObjectList junimoChest => InventoryToNetObjectList.GetCachedWrapperFor(base.GetOrCreateGlobalInventory(FarmerTeam.GlobalInventoryId_JunimoChest)); + + + /********* + ** Public methods + *********/ + public void SetLocalReady(string checkName, bool ready) + { + Game1.netReady.SetLocalReady(checkName, ready); + } + + + /********* + ** Private methods + *********/ + private FarmerTeamFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FenceFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FenceFacade.cs new file mode 100644 index 000000000..7f099e88c --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FenceFacade.cs @@ -0,0 +1,43 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class FenceFacade : Fence, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static Fence Constructor(Vector2 tileLocation, int whichType, bool isGate) + { + return new Fence(tileLocation, whichType.ToString(), isGate); + } + + public void toggleGate(GameLocation location, bool open, bool is_toggling_counterpart = false, Farmer? who = null) + { + base.toggleGate(open, is_toggling_counterpart, who); + } + + public int getDrawSum(GameLocation location) + { + return base.getDrawSum(); + } + + + /********* + ** Private methods + *********/ + private FenceFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FishTankFurnitureFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FishTankFurnitureFacade.cs new file mode 100644 index 000000000..5db1b8e26 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FishTankFurnitureFacade.cs @@ -0,0 +1,38 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.Objects; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class FishTankFurnitureFacade : FishTankFurniture, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static FishTankFurniture Constructor(int which, Vector2 tile, int initialRotations) + { + return new FishTankFurniture(which.ToString(), tile, initialRotations); + } + + public static FishTankFurniture Constructor(int which, Vector2 tile) + { + return new FishTankFurniture(which.ToString(), tile); + } + + + /********* + ** Private methods + *********/ + private FishTankFurnitureFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FishingRodFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FishingRodFacade.cs new file mode 100644 index 000000000..6fc662279 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FishingRodFacade.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.Tools; +using SObject = StardewValley.Object; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "ParameterHidesMember", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = SuppressReasons.BaseForClarity)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class FishingRodFacade : FishingRod, IRewriteFacade + { + /********* + ** Accessors + *********/ + public bool caughtDoubleFish + { + get => base.numberOfFishCaught > 1; + set => base.numberOfFishCaught = value ? Math.Max(2, base.numberOfFishCaught) : 1; + } + + + /********* + ** Public methods + *********/ + public int getBaitAttachmentIndex() + { + return int.TryParse(base.GetBait()?.ItemId, out int index) + ? index + : -1; + } + + public int getBobberAttachmentIndex() + { + List? tackle = base.GetTackle(); + + return tackle?.Count > 0 && int.TryParse(tackle[0]?.ItemId, out int index) + ? index + : -1; + } + + public void pullFishFromWater(int whichFish, int fishSize, int fishQuality, int fishDifficulty, bool treasureCaught, bool wasPerfect, bool fromFishPond, bool caughtDouble = false, string itemCategory = "Object") + { + base.pullFishFromWater(whichFish.ToString(), fishSize, fishQuality, fishDifficulty, treasureCaught, wasPerfect, fromFishPond, null, false, caughtDouble ? 2 : 1); + } + + + /********* + ** Private methods + *********/ + private FishingRodFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ForestFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ForestFacade.cs new file mode 100644 index 000000000..16f8eace8 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ForestFacade.cs @@ -0,0 +1,54 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.Locations; +using StardewValley.TerrainFeatures; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = SuppressReasons.BaseForClarity)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class ForestFacade : Forest, IRewriteFacade + { + /********* + ** Accessors + *********/ + public ResourceClump? log + { + get + { + foreach (ResourceClump clump in base.resourceClumps) + { + if (clump.parentSheetIndex.Value == ResourceClump.hollowLogIndex && (int)clump.Tile.X == 2 && (int)clump.Tile.Y == 6) + return clump; + } + + return null; + } + set + { + // remove previous value + ResourceClump? clump = this.log; + if (clump != null) + base.resourceClumps.Remove(clump); + + // add new value + if (value != null) + base.resourceClumps.Add(value); + } + } + + + /********* + ** Private methods + *********/ + private ForestFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FruitTreeFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FruitTreeFacade.cs new file mode 100644 index 000000000..3132ffb91 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FruitTreeFacade.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using Netcode; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6.Internal; +using StardewValley; +using StardewValley.Objects; +using StardewValley.TerrainFeatures; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = SuppressReasons.BaseForClarity)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class FruitTreeFacade : FruitTree, IRewriteFacade + { + /********* + ** Accessors + *********/ + public NetString fruitSeason + { + get + { + List? seasons = base.GetData()?.Seasons; + string value = seasons?.Count > 0 + ? string.Join(",", seasons) + : string.Empty; + + return new ReadOnlyValueToNetString($"{nameof(FruitTree)}.{nameof(this.fruitSeason)}", value); + } + } + + + /********* + ** Public methods + *********/ + public static FruitTree Constructor(int saplingIndex) + { + return new FruitTree(saplingIndex.ToString()); + } + + public static FruitTree Constructor(int saplingIndex, int growthStage) + { + return new FruitTree(saplingIndex.ToString(), growthStage); + } + + public bool IsInSeasonHere(GameLocation location) + { + return base.IsInSeasonHere(); + } + + public void shake(Vector2 tileLocation, bool doEvenIfStillShaking, GameLocation location) + { + base.shake(tileLocation, doEvenIfStillShaking); + } + + + /********* + ** Private methods + *********/ + private FruitTreeFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FurnitureFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FurnitureFacade.cs new file mode 100644 index 000000000..7b6e5f245 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/FurnitureFacade.cs @@ -0,0 +1,69 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Objects; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class FurnitureFacade : Furniture, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static Furniture Constructor(int which, Vector2 tile, int initialRotations) + { + return new Furniture(which.ToString(), tile, initialRotations); + } + + public static Furniture Constructor(int which, Vector2 tile) + { + return new Furniture(which.ToString(), tile); + } + + public void AddLightGlow(GameLocation location) + { + base.AddLightGlow(); + } + + public void addLights(GameLocation environment) + { + base.addLights(); + } + + public static Furniture GetFurnitureInstance(int index, Vector2? position = null) + { + return Furniture.GetFurnitureInstance(index.ToString(), position); + } + + public void removeLights(GameLocation environment) + { + base.removeLights(); + } + + public void RemoveLightGlow(GameLocation location) + { + base.RemoveLightGlow(); + } + + public void setFireplace(GameLocation location, bool playSound = true, bool broadcast = false) + { + base.setFireplace(playSound, broadcast); + } + + + /********* + ** Private methods + *********/ + private FurnitureFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/Game1Facade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/Game1Facade.cs new file mode 100644 index 000000000..e696e4d33 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/Game1Facade.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "LocalVariableHidesMember", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "ParameterHidesMember", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class Game1Facade : Game1, IRewriteFacade + { + /********* + ** Accessors + *********/ + public bool gamePadControlsImplemented { get; set; } // never used + public static bool menuUp { get; set; } // mostly unused and always false + public static Color morningColor { get; set; } = Color.LightBlue; // never used + + + /********* + ** Public methods + *********/ + public static bool canHaveWeddingOnDay(int day, string season) + { + return + Utility.TryParseEnum(season, out Season parsedSeason) + && Game1.canHaveWeddingOnDay(day, parsedSeason); + } + + public static void createMultipleObjectDebris(int index, int xTile, int yTile, int number) + { + Game1.createMultipleObjectDebris(index.ToString(), xTile, yTile, number); + } + + public static void createMultipleObjectDebris(int index, int xTile, int yTile, int number, GameLocation location) + { + Game1.createMultipleObjectDebris(index.ToString(), xTile, yTile, number, location); + } + + public static void createMultipleObjectDebris(int index, int xTile, int yTile, int number, float velocityMultiplier) + { + Game1.createMultipleObjectDebris(index.ToString(), xTile, yTile, number, velocityMultiplier); + } + + public static void createMultipleObjectDebris(int index, int xTile, int yTile, int number, long who) + { + Game1.createMultipleObjectDebris(index.ToString(), xTile, yTile, number, who); + } + + public static void createMultipleObjectDebris(int index, int xTile, int yTile, int number, long who, GameLocation location) + { + Game1.createMultipleObjectDebris(index.ToString(), xTile, yTile, number, who, location); + } + + public static void createObjectDebris(int objectIndex, int xTile, int yTile, long whichPlayer) + { + Game1.createObjectDebris(objectIndex.ToString(), xTile, yTile, whichPlayer); + } + + public static void createObjectDebris(int objectIndex, int xTile, int yTile, long whichPlayer, GameLocation location) + { + Game1.createObjectDebris(objectIndex.ToString(), xTile, yTile, whichPlayer, location); + } + + public static void createObjectDebris(int objectIndex, int xTile, int yTile, GameLocation location) + { + Game1.createObjectDebris(objectIndex.ToString(), xTile, yTile, location); + } + + public static void createObjectDebris(int objectIndex, int xTile, int yTile, int groundLevel = -1, int itemQuality = 0, float velocityMultiplyer = 1f, GameLocation? location = null) + { + Game1.createObjectDebris(objectIndex.ToString(), xTile, yTile, groundLevel, itemQuality, velocityMultiplyer, location); + } + + public static void createRadialDebris(GameLocation location, int debrisType, int xTile, int yTile, int numberOfChunks, bool resource, int groundLevel = -1, bool item = false, int color = -1) + { + Game1.createRadialDebris( + location: location, + debrisType: debrisType, + xTile: xTile, + yTile: yTile, + numberOfChunks: numberOfChunks, + resource: resource, + groundLevel: groundLevel, + item: item, + color: Debris.getColorForDebris(color) + ); + } + + public static void drawDialogue(NPC speaker, string dialogue) + { + Game1.DrawDialogue(new Dialogue(speaker, null, dialogue)); + } + + public static void drawDialogue(NPC speaker, string dialogue, Texture2D overridePortrait) + { + Game1.DrawDialogue(new Dialogue(speaker, null, dialogue) { overridePortrait = overridePortrait }); + } + + public static void drawObjectQuestionDialogue(string dialogue, List? choices, int width) + { + Game1.drawObjectQuestionDialogue(dialogue, choices?.ToArray(), width); + } + + public static void drawObjectQuestionDialogue(string dialogue, List? choices) + { + Game1.drawObjectQuestionDialogue(dialogue, choices?.ToArray()); + } + + public new static NPC? getCharacterFromName(string name, bool mustBeVillager = true) + { + return Game1.getCharacterFromName(name, mustBeVillager); + } + + public static T? getCharacterFromName(string name, bool mustBeVillager = true) where T : NPC + { + return Game1.getCharacterFromName(name, mustBeVillager); + } + + public static T? GetCharacterOfType() where T : NPC + { + return Game1.GetCharacterOfType(); + } + + public static T? GetCharacterWhere(Func check) where T : NPC + { + return Game1.GetCharacterWhere(check); + } + + public static int getModeratelyDarkTime() + { + return Game1.getModeratelyDarkTime(Game1.currentLocation); + } + + public new static string GetSeasonForLocation(GameLocation location) + { + Season season = Game1.GetSeasonForLocation(location); + return season.ToString(); + } + + public static int getStartingToGetDarkTime() + { + return Game1.getStartingToGetDarkTime(Game1.currentLocation); + } + + public static int getTrulyDarkTime() + { + return Game1.getTrulyDarkTime(Game1.currentLocation); + } + + public static bool isDarkOut() + { + return Game1.isDarkOut(Game1.currentLocation); + } + + public static bool isStartingToGetDarkOut() + { + return Game1.isStartingToGetDarkOut(Game1.currentLocation); + } + + public static void playMorningSong() + { + Game1.playMorningSong(); + } + + public static void playSound(string cueName) + { + Game1.playSound(cueName); + } + + public static void playSoundPitched(string cueName, int pitch) + { + Game1.playSound(cueName, pitch); + } + + + /********* + ** Private methods + *********/ + private Game1Facade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/GameLocationFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/GameLocationFacade.cs new file mode 100644 index 000000000..9ba4d883b --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/GameLocationFacade.cs @@ -0,0 +1,161 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using Netcode; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Audio; +using StardewValley.Extensions; +using StardewValley.Objects; +using xTile.Dimensions; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "ParameterHidesMember", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "PossibleLossOfFraction", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = SuppressReasons.BaseForClarity)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class GameLocationFacade : GameLocation, IRewriteFacade + { + /********* + ** Accessors + *********/ + public static int CAROLINES_NECKLACE_ITEM => 191; + + + /********* + ** Public methods + *********/ + public NetCollection getCharacters() + { + return base.characters; + } + + public virtual int getExtraMillisecondsPerInGameMinuteForThisLocation() + { + return base.ExtraMillisecondsPerInGameMinute; + } + + public Object? getFish(float millisecondsAfterNibble, int bait, int waterDepth, Farmer who, double baitPotency, Vector2 bobberTile, string? location = null) + { + return base.getFish(millisecondsAfterNibble, bait.ToString(), waterDepth, who, baitPotency, bobberTile, location) as Object; + } + + public Dictionary GetLocationEvents() + { + return base.TryGetLocationEvents(out _, out Dictionary events) + ? events + : new Dictionary(); + } + + public Point GetMapPropertyPosition(string key, int default_x, int default_y) + { + return base.TryGetMapPropertyAs(key, out Point point) + ? point + : new Point(default_x, default_y); + } + + public int getNumberBuildingsConstructed(string name) + { + return base.getNumberBuildingsConstructed(name, includeUnderConstruction: false); + } + + public Object getObjectAtTile(int x, int y) + { + return base.getObjectAtTile(x, y); + } + + public string GetSeasonForLocation() + { + return base.GetSeasonKey(); + } + + public bool isTileLocationOpenIgnoreFrontLayers(Location tile) + { + return base.map.RequireLayer("Buildings").Tiles[tile.X, tile.Y] == null && !base.isWaterTile(tile.X, tile.Y); + } + + public bool isTileLocationTotallyClearAndPlaceable(int x, int y) + { + return this.isTileLocationTotallyClearAndPlaceable(new Vector2(x, y)); + } + + public bool isTileLocationTotallyClearAndPlaceable(Vector2 v) + { + Vector2 pixel = new((v.X * Game1.tileSize) + Game1.tileSize / 2, (v.Y * Game1.tileSize) + Game1.tileSize / 2); + foreach (Furniture f in base.furniture) + { + if (f.furniture_type != Furniture.rug && !f.isPassable() && f.GetBoundingBox().Contains((int)pixel.X, (int)pixel.Y) && !f.AllowPlacementOnThisTile((int)v.X, (int)v.Y)) + return false; + } + + return base.isTileOnMap(v) && !this.isTileOccupied(v) && base.isTilePassable(new Location((int)v.X, (int)v.Y), Game1.viewport) && base.isTilePlaceable(v); + } + + public bool isTileLocationTotallyClearAndPlaceableIgnoreFloors(Vector2 v) + { + return base.isTileOnMap(v) && !this.isTileOccupiedIgnoreFloors(v) && base.isTilePassable(new Location((int)v.X, (int)v.Y), Game1.viewport) && base.isTilePlaceable(v); + } + + public bool isTileOccupied(Vector2 tileLocation, string characterToIgnore = "", bool ignoreAllCharacters = false) + { + CollisionMask mask = ignoreAllCharacters ? CollisionMask.All & ~CollisionMask.Characters & ~CollisionMask.Farmers : CollisionMask.All; + return base.IsTileOccupiedBy(tileLocation, mask); + } + + public bool isTileOccupiedForPlacement(Vector2 tileLocation, Object? toPlace = null) + { + return base.CanItemBePlacedHere(tileLocation, toPlace != null && toPlace.isPassable()); + } + + public bool isTileOccupiedIgnoreFloors(Vector2 tileLocation, string characterToIgnore = "") + { + return base.IsTileOccupiedBy(tileLocation, CollisionMask.Buildings | CollisionMask.Furniture | CollisionMask.Objects | CollisionMask.Characters | CollisionMask.TerrainFeatures, ignorePassables: CollisionMask.Flooring); + } + + public void localSound(string audioName) + { + base.localSound(audioName); + } + + public void localSoundAt(string audioName, Vector2 position) + { + base.localSound(audioName, position); + } + + public void OnStoneDestroyed(int indexOfStone, int x, int y, Farmer who) + { + base.OnStoneDestroyed(indexOfStone.ToString(), x, y, who); + } + + public void playSound(string audioName, SoundContext soundContext = SoundContext.Default) + { + base.playSound(audioName, context: soundContext); + } + + public void playSoundAt(string audioName, Vector2 position, SoundContext soundContext = SoundContext.Default) + { + base.playSound(audioName, position, context: soundContext); + } + + public void playSoundPitched(string audioName, int pitch, SoundContext soundContext = SoundContext.Default) + { + base.playSound(audioName, pitch: pitch, context: soundContext); + } + + + /********* + ** Private methods + *********/ + private GameLocationFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/GiantCropFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/GiantCropFacade.cs new file mode 100644 index 000000000..89267b95d --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/GiantCropFacade.cs @@ -0,0 +1,33 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.TerrainFeatures; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class GiantCropFacade : GiantCrop, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static GiantCrop Constructor(int indexOfSmallerVersion, Vector2 tile) + { + return new GiantCrop(indexOfSmallerVersion.ToString(), tile); + } + + + /********* + ** Private methods + *********/ + private GiantCropFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/HatFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/HatFacade.cs new file mode 100644 index 000000000..0f524c608 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/HatFacade.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.Objects; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class HatFacade : Hat, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static Hat Constructor(int which) + { + return new Hat(which.ToString()); + } + + + /********* + ** Private methods + *********/ + private HatFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/HoeDirtFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/HoeDirtFacade.cs new file mode 100644 index 000000000..c824f2934 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/HoeDirtFacade.cs @@ -0,0 +1,60 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.TerrainFeatures; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "ParameterHidesMember", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class HoeDirtFacade : HoeDirt, IRewriteFacade + { + /********* + ** Public methods + *********/ + public bool canPlantThisSeedHere(int objectIndex, int tileX, int tileY, bool isFertilizer = false) + { + return base.canPlantThisSeedHere(objectIndex.ToString(), isFertilizer); + } + + public void destroyCrop(Vector2 tileLocation, bool showAnimation, GameLocation location) + { + base.destroyCrop(showAnimation); + } + + public Rectangle GetFertilizerSourceRect(int fertilizer) + { + return base.GetFertilizerSourceRect(); + } + + public bool paddyWaterCheck(GameLocation location, Vector2 tile_location) + { + return base.paddyWaterCheck(); + } + + public bool plant(int index, int tileX, int tileY, Farmer who, bool isFertilizer, GameLocation location) + { + return base.plant(index.ToString(), who, isFertilizer); + } + + public void updateNeighbors(GameLocation loc, Vector2 tilePos) + { + base.updateNeighbors(); + } + + + /********* + ** Private methods + *********/ + private HoeDirtFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/HudMessageFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/HudMessageFacade.cs new file mode 100644 index 000000000..e85c27c7d --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/HudMessageFacade.cs @@ -0,0 +1,79 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = SuppressReasons.BaseForClarity)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = SuppressReasons.MatchesOriginal)] + public class HudMessageFacade : HUDMessage, IRewriteFacade + { + /********* + ** Accessors + *********/ + public string Message + { + get => base.message; + set => base.message = value; + } + + + /********* + ** Public methods + *********/ + public static HUDMessage Constructor(string message, bool achievement) + { + return HUDMessage.ForAchievement(message); + } + + public static HUDMessage Constructor(string type, int number, bool add, Color color, Item? messageSubject = null) + { + if (!add) + number = -number; + + if (type == "Hay" && messageSubject is null) + return HUDMessage.ForItemGained(ItemRegistry.Create("(O)178"), number); + + return new HUDMessage(null) + { + type = type, + timeLeft = HUDMessage.defaultTime, + number = number, + messageSubject = messageSubject + }; + } + + public static HUDMessage Constructor(string message, Color color, float timeLeft) + { + return Constructor(message, color, timeLeft, false); + } + + public static HUDMessage Constructor(string message, string leaveMeNull) + { + return HUDMessage.ForCornerTextbox(message); + } + + public static HUDMessage Constructor(string message, Color color, float timeLeft, bool fadeIn) + { + return new HUDMessage(message, timeLeft, fadeIn); + } + + + /********* + ** Private methods + *********/ + private HudMessageFacade() + : base(null) + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/IClickableMenuFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/IClickableMenuFacade.cs new file mode 100644 index 000000000..12bce5794 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/IClickableMenuFacade.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Menus; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class IClickableMenuFacade : IClickableMenu, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static void drawHoverText(SpriteBatch b, string text, SpriteFont font, int xOffset = 0, int yOffset = 0, int moneyAmountToDisplayAtBottom = -1, string? boldTitleText = null, int healAmountToDisplay = -1, string[]? buffIconsToDisplay = null, Item? hoveredItem = null, int currencySymbol = 0, int extraItemToShowIndex = -1, int extraItemToShowAmount = -1, int overrideX = -1, int overrideY = -1, float alpha = 1f, CraftingRecipe? craftingIngredients = null, IList? additional_craft_materials = null) + { + IClickableMenu.drawHoverText( + b: b, + text: text, + font: font, + xOffset: xOffset, + yOffset: yOffset, + moneyAmountToDisplayAtBottom: moneyAmountToDisplayAtBottom, + boldTitleText: boldTitleText, + healAmountToDisplay: healAmountToDisplay, + buffIconsToDisplay: buffIconsToDisplay, + hoveredItem: hoveredItem, + currencySymbol: currencySymbol, + extraItemToShowAmount: extraItemToShowAmount, + extraItemToShowIndex: extraItemToShowIndex != -1 ? extraItemToShowAmount.ToString() : null, + overrideX: overrideX, + overrideY: overrideY, + alpha: alpha, + craftingIngredients: craftingIngredients, + additional_craft_materials: additional_craft_materials + ); + } + + public static void drawHoverText(SpriteBatch b, StringBuilder text, SpriteFont font, int xOffset = 0, int yOffset = 0, int moneyAmountToDisplayAtBottom = -1, string? boldTitleText = null, int healAmountToDisplay = -1, string[]? buffIconsToDisplay = null, Item? hoveredItem = null, int currencySymbol = 0, int extraItemToShowIndex = -1, int extraItemToShowAmount = -1, int overrideX = -1, int overrideY = -1, float alpha = 1f, CraftingRecipe? craftingIngredients = null, IList? additional_craft_materials = null) + { + IClickableMenu.drawHoverText( + b: b, + text: text, + font: font, + xOffset: xOffset, + yOffset: yOffset, + moneyAmountToDisplayAtBottom: moneyAmountToDisplayAtBottom, + boldTitleText: boldTitleText, + healAmountToDisplay: healAmountToDisplay, + buffIconsToDisplay: buffIconsToDisplay, + hoveredItem: hoveredItem, + currencySymbol: currencySymbol, + extraItemToShowAmount: extraItemToShowAmount, + extraItemToShowIndex: extraItemToShowIndex != -1 ? extraItemToShowAmount.ToString() : null, + overrideX: overrideX, + overrideY: overrideY, + alpha: alpha, + craftingIngredients: craftingIngredients, + additional_craft_materials: additional_craft_materials + ); + } + + public static void drawToolTip(SpriteBatch b, string hoverText, string hoverTitle, Item hoveredItem, bool heldItem = false, int healAmountToDisplay = -1, int currencySymbol = 0, int extraItemToShowIndex = -1, int extraItemToShowAmount = -1, CraftingRecipe? craftingIngredients = null, int moneyAmountToShowAtBottom = -1) + { + IClickableMenu.drawToolTip( + b: b, + hoverText: hoverText, + hoverTitle: hoverTitle, + hoveredItem: hoveredItem, + heldItem: heldItem, + healAmountToDisplay: healAmountToDisplay, + currencySymbol: currencySymbol, + extraItemToShowIndex: extraItemToShowIndex != -1 ? extraItemToShowAmount.ToString() : null, + extraItemToShowAmount: extraItemToShowAmount, + craftingIngredients: craftingIngredients, + moneyAmountToShowAtBottom: moneyAmountToShowAtBottom + ); + } + + + /********* + ** Private methods + *********/ + private IClickableMenuFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ImplicitConversionOperators.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ImplicitConversionOperators.cs new file mode 100644 index 000000000..c2f7d76ce --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ImplicitConversionOperators.cs @@ -0,0 +1,19 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.Network; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Reimplements implicit conversion operators that were removed in 1.6 for sealed classes. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public static class ImplicitConversionOperators + { + public static int NetDirection_ToInt(NetDirection netField) + { + return netField.Value; + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/Internal/InventoryToNetObjectList.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/Internal/InventoryToNetObjectList.cs new file mode 100644 index 000000000..585fd2752 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/Internal/InventoryToNetObjectList.cs @@ -0,0 +1,116 @@ +using System.Collections.Generic; +using Netcode; +using StardewValley; +using StardewValley.Inventories; + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6.Internal +{ + /// An implementation of which tracks an underlying instance. + internal class InventoryToNetObjectList : NetObjectList + { + /********* + ** Fields + *********/ + /// A cached lookup of inventory wrappers. + private static readonly Dictionary CachedWrappers = new Dictionary(ReferenceEqualityComparer.Instance); + + /// The underlying inventory to track. + private readonly Inventory Inventory; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying inventory to track. + public InventoryToNetObjectList(Inventory inventory) + { + this.Inventory = inventory; + + this.RebuildList(); + + this.Inventory.OnInventoryReplaced += this.OnInventoryReplaced; + this.Inventory.OnSlotChanged += this.OnInventorySlotChanged; + } + + /// Get a wrapper for a given inventory instance. + /// The inventory to track. + public static InventoryToNetObjectList GetCachedWrapperFor(Inventory inventory) + { + if (!CachedWrappers.TryGetValue(inventory, out InventoryToNetObjectList? wrapper)) + CachedWrappers[inventory] = wrapper = new InventoryToNetObjectList(inventory); + + return wrapper; + } + + /// + public override Item this[int index] + { + get => this.Inventory[index]; + set => this.Inventory[index] = value; + } + + /// + public override void Add(Item item) + { + this.Inventory.Add(item); + } + + /// + public override void Clear() + { + this.Inventory.Clear(); + } + + /// + public override void Insert(int index, Item item) + { + this.Inventory.Insert(index, item); + } + + /// + public override void RemoveAt(int index) + { + this.Inventory.RemoveAt(index); + } + + + /********* + ** Private methods + *********/ + /// Handle a change to the underlying inventory. + /// The inventory instance. + /// The slot index which changed. + /// The previous value. + /// The new value. + private void OnInventorySlotChanged(Inventory inventory, int index, Item before, Item after) + { + try + { + base[index] = after; + } + catch + { + this.RebuildList(); // if the item list is out of sync, rebuild it + } + } + + /// Handle the underlying inventory getting replaced with a new list. + /// The inventory instance. + /// The previous list of values. + /// The new list of values. + private void OnInventoryReplaced(Inventory inventory, IList before, IList after) + { + this.RebuildList(); + } + + /// Rebuild the list to match the underlying inventory. + private void RebuildList() + { + // don't use `this` to avoid re-editing the inventory + base.Clear(); + foreach (Item slot in this.Inventory) + base.Add(slot); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/Internal/NetRefWrapperCache.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/Internal/NetRefWrapperCache.cs new file mode 100644 index 000000000..7e0963feb --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/Internal/NetRefWrapperCache.cs @@ -0,0 +1,37 @@ +using System; +using System.Runtime.CompilerServices; +using Netcode; + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6.Internal +{ + /// A cache of instances for specific values. + internal static class NetRefWrapperCache + where T : class, INetObject + { + /********* + ** Fields + *********/ + /// A cached lookup of wrappers. + private static readonly ConditionalWeakTable> CachedWrappers = new(); + + + /********* + ** Public methods + *********/ + /// Get a wrapper for a given value. + /// The value to wrap. + public static NetRef GetCachedWrapperFor(T value) + { + if (value is null) + throw new InvalidOperationException($"{nameof(NetRefWrapperCache)} doesn't support wrapping null values."); + + if (!CachedWrappers.TryGetValue(value, out NetRef? wrapper)) + { + wrapper = new NetRef(value); + CachedWrappers.AddOrUpdate(value, wrapper); + } + + return wrapper; + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/Internal/ReadOnlyValueToNetString.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/Internal/ReadOnlyValueToNetString.cs new file mode 100644 index 000000000..c88e16631 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/Internal/ReadOnlyValueToNetString.cs @@ -0,0 +1,34 @@ +using System; +using Netcode; + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6.Internal +{ + /// An implementation of which returns a predetermined value and doesn't allow editing. + internal class ReadOnlyValueToNetString : NetString + { + /********* + ** Fields + *********/ + /// A human-readable name for the original field to show in error messages. + private readonly string FieldLabel; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// A human-readable name for the original field to show in error messages. + /// The value to set. + public ReadOnlyValueToNetString(string fieldLabel, string value) + : base(value) + { + this.FieldLabel = fieldLabel; + } + + /// + public override void Set(string newValue) + { + throw new InvalidOperationException($"The {this.FieldLabel} is no longer editable in Stardew Valley 1.6 and later."); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ItemFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ItemFacade.cs new file mode 100644 index 000000000..2107af457 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ItemFacade.cs @@ -0,0 +1,33 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public abstract class ItemFacade : Item, IRewriteFacade + { + /********* + ** Public methods + *********/ + public virtual bool canBePlacedHere(GameLocation l, Vector2 tile) + { + return base.canBePlacedHere(l, tile); + } + + + /********* + ** Private methods + *********/ + private ItemFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/JunimoHutFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/JunimoHutFacade.cs new file mode 100644 index 000000000..db6ff7b88 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/JunimoHutFacade.cs @@ -0,0 +1,33 @@ +using System.Diagnostics.CodeAnalysis; +using Netcode; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6.Internal; +using StardewValley.Buildings; +using StardewValley.Objects; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = SuppressReasons.BaseForClarity)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class JunimoHutFacade : JunimoHut, IRewriteFacade + { + /********* + ** Accessors + *********/ + public NetRef output => NetRefWrapperCache.GetCachedWrapperFor(base.GetBuildingChest("Output")); + + + /********* + ** Private methods + *********/ + private JunimoHutFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/LargeTerrainFeatureFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/LargeTerrainFeatureFacade.cs new file mode 100644 index 000000000..5f487b9f5 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/LargeTerrainFeatureFacade.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; +using Netcode; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.TerrainFeatures; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class LargeTerrainFeatureFacade : LargeTerrainFeature, IRewriteFacade + { + /********* + ** Accessors + *********/ + public NetVector2 tilePosition => base.netTilePosition; + + + /********* + ** Private methods + *********/ + private LargeTerrainFeatureFacade() + : base(false) + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/LayerFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/LayerFacade.cs new file mode 100644 index 000000000..ad6b8cdff --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/LayerFacade.cs @@ -0,0 +1,38 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using xTile.Dimensions; +using xTile.Display; +using xTile.Layers; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = SuppressReasons.BaseForClarity)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = SuppressReasons.MatchesOriginal)] + public class LayerFacade : Layer, IRewriteFacade + { + /********* + ** Public methods + *********/ + public void Draw(IDisplayDevice displayDevice, Rectangle mapViewport, Location displayOffset, bool wrapAround, int pixelZoom) + { + base.Draw(displayDevice, mapViewport, displayOffset, wrapAround, pixelZoom); + } + + + /********* + ** Private methods + *********/ + private LayerFacade() + : base(null, null, Size.Zero, Size.Zero) + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/LibraryMuseumFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/LibraryMuseumFacade.cs new file mode 100644 index 000000000..620922532 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/LibraryMuseumFacade.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.Locations; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class LibraryMuseumFacade : LibraryMuseum, IRewriteFacade + { + /********* + ** Public methods + *********/ + public bool museumAlreadyHasArtifact(int index) + { + return LibraryMuseum.HasDonatedArtifact(index.ToString()); + } + + + /********* + ** Private methods + *********/ + private LibraryMuseumFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/LocalizedContentManagerFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/LocalizedContentManagerFacade.cs new file mode 100644 index 000000000..41e71a278 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/LocalizedContentManagerFacade.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class LocalizedContentManagerFacade : LocalizedContentManager, IRewriteFacade + { + /********* + ** Public methods + *********/ + public new string LanguageCodeString(LanguageCode code) + { + return LocalizedContentManager.LanguageCodeString(code); + } + + + /********* + ** Private methods + *********/ + private LocalizedContentManagerFacade() + : base(null, null) + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/MeleeWeaponFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/MeleeWeaponFacade.cs new file mode 100644 index 000000000..1bbb1285a --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/MeleeWeaponFacade.cs @@ -0,0 +1,46 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Tools; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class MeleeWeaponFacade : MeleeWeapon, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static MeleeWeapon Constructor(int spriteIndex) + { + return new MeleeWeapon(spriteIndex.ToString()); + } + + public bool isScythe(int index = -1) + { + return base.isScythe(); // index argument was already ignored + } + + public static Rectangle getSourceRect(int index) + { + return + ItemRegistry.GetData(ItemRegistry.type_weapon + index)?.GetSourceRect() // get actual source rect if possible + ?? Game1.getSourceRectForStandardTileSheet(Tool.weaponsTexture, index, Game1.smallestTileSize, Game1.smallestTileSize); // else pre-1.6 logic + } + + + /********* + ** Private methods + *********/ + private MeleeWeaponFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/MineShaftFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/MineShaftFacade.cs new file mode 100644 index 000000000..50b71a197 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/MineShaftFacade.cs @@ -0,0 +1,37 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Locations; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class MineShaftFacade : MineShaft, IRewriteFacade + { + /********* + ** Public methods + *********/ + public new int getRandomGemRichStoneForThisLevel(int level) + { + string itemId = base.getRandomGemRichStoneForThisLevel(level); + + return int.TryParse(itemId, out int index) + ? index + : Object.mineStoneBrown1Index; // old default value + } + + + /********* + ** Private methods + *********/ + private MineShaftFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/MultiplayerFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/MultiplayerFacade.cs new file mode 100644 index 000000000..8cd63d92c --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/MultiplayerFacade.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class MultiplayerFacade : Multiplayer, IRewriteFacade + { + /********* + ** Public methods + *********/ + public void broadcastSprites(GameLocation location, List sprites) + { + var list = new TemporaryAnimatedSpriteList(); + list.AddRange(sprites); + + base.broadcastSprites(location, list); + } + + public void broadcastGlobalMessage(string localization_string_key, bool only_show_if_empty = false, params string[] substitutions) + { + base.broadcastGlobalMessage(localization_string_key, only_show_if_empty, null, substitutions); + } + + + /********* + ** Private methods + *********/ + private MultiplayerFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/NetFieldBaseFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/NetFieldBaseFacade.cs new file mode 100644 index 000000000..afb67157f --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/NetFieldBaseFacade.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; +using Netcode; +using StardewModdingAPI.Framework.ModLoading.Framework; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public abstract class NetFieldBaseFacade : NetFieldBase // don't mark IRewriteFacade; the op_Implicit method is mapped manually + where TSelf : NetFieldBase + { + /********* + ** Public methods + *********/ + public static T op_Implicit(NetFieldBase netField) + { + return netField.Value; + } + + + /********* + ** Private methods + *********/ + private NetFieldBaseFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/NetFieldsFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/NetFieldsFacade.cs new file mode 100644 index 000000000..35dc28e2b --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/NetFieldsFacade.cs @@ -0,0 +1,34 @@ +using System.Diagnostics.CodeAnalysis; +using Netcode; +using StardewModdingAPI.Framework.ModLoading.Framework; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public abstract class NetFieldsFacade : NetFields, IRewriteFacade + { + /********* + ** Public methods + *********/ + [SuppressMessage("ReSharper", "ForCanBeConvertedToForeach", Justification = "Deliberate to include index in field name")] + public void AddFields(params INetSerializable[] fields) + { + for (int i = 0; i < fields.Length; i++) + base.AddField(fields[i]); + } + + + /********* + ** Private methods + *********/ + private NetFieldsFacade() + : base(null) + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/NetLongFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/NetLongFacade.cs new file mode 100644 index 000000000..60298e875 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/NetLongFacade.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; +using Netcode; +using StardewModdingAPI.Framework.ModLoading.Framework; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public abstract class NetLongFacade : NetFieldBase // don't mark IRewriteFacade; the op_Implicit method is mapped manually + { + /********* + ** Public methods + *********/ + public static long op_Implicit(NetLong netField) + { + return netField.Value; + } + + + /********* + ** Private methods + *********/ + private NetLongFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/NetPausableFieldFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/NetPausableFieldFacade.cs new file mode 100644 index 000000000..dba0ad378 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/NetPausableFieldFacade.cs @@ -0,0 +1,35 @@ +using System.Diagnostics.CodeAnalysis; +using Netcode; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.Network; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public abstract class NetPausableFieldFacade : NetPausableField, IRewriteFacade + where TBaseField : NetFieldBase, new() + where TField : TBaseField, new() + { + /********* + ** Public methods + *********/ + public static T op_Implicit(NetPausableField field) + { + return field.Value; + } + + + /********* + ** Private methods + *********/ + private NetPausableFieldFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/NetWorldStateFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/NetWorldStateFacade.cs new file mode 100644 index 000000000..4576e1fb8 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/NetWorldStateFacade.cs @@ -0,0 +1,35 @@ +using System.Diagnostics.CodeAnalysis; +using Netcode; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.Network; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class NetWorldStateFacade : NetWorldState, IRewriteFacade + { + /********* + ** Public methods + *********/ + public new NetIntDelta MiniShippingBinsObtained => base.miniShippingBinsObtained; + public new NetIntDelta GoldenWalnutsFound => base.goldenWalnutsFound; + public new NetIntDelta GoldenWalnuts => base.goldenWalnuts; + public new NetBool GoldenCoconutCracked => base.goldenCoconutCracked; + public new NetBool ParrotPlatformsUnlocked => base.parrotPlatformsUnlocked; + public new NetIntDelta LostBooksFound => base.lostBooksFound; + + + /********* + ** Private methods + *********/ + private NetWorldStateFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/NpcFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/NpcFacade.cs new file mode 100644 index 000000000..4f7a5aa4e --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/NpcFacade.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.BellsAndWhistles; +using StardewValley.Pathfinding; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = SuppressReasons.BaseForClarity)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public abstract class NpcFacade : NPC, IRewriteFacade + { + /********* + ** Accessors + *********/ + public bool eventActor + { + get => base.EventActor; + set => base.EventActor = value; + } + + public new int Gender + { + get => (int)base.Gender; + set => base.Gender = (Gender)value; + } + + + /********* + ** Public methods + *********/ + public static NPC Constructor(AnimatedSprite sprite, Vector2 position, string defaultMap, int facingDirection, string name, bool datable, Dictionary schedule, Texture2D portrait) + { + return new NPC(sprite, position, defaultMap, facingDirection, name, datable, portrait); + } + + public static NPC Constructor(AnimatedSprite sprite, Vector2 position, string defaultMap, int facingDir, string name, Dictionary schedule, Texture2D portrait, bool eventActor, string? syncedPortraitPath = null) + { + NPC npc = new NPC(sprite, position, defaultMap, facingDir, name, portrait, eventActor); + + if (!string.IsNullOrWhiteSpace(syncedPortraitPath)) + { + npc.Portrait = Game1.content.Load(syncedPortraitPath); + npc.portraitOverridden = true; + } + + return npc; + } + + public bool isBirthday(string season, int day) + { + // call new method if possible + if (season == Game1.currentSeason && day == Game1.dayOfMonth) + return base.isBirthday(); + + // else replicate old behavior + return + base.Birthday_Season != null + && base.Birthday_Season == season + && base.Birthday_Day == day; + } + + public static void populateRoutesFromLocationToLocationList() + { + WarpPathfindingCache.PopulateCache(); + } + + public void showTextAboveHead(string Text, int spriteTextColor = -1, int style = NPC.textStyle_none, int duration = 3000, int preTimer = 0) + { + Color? color = spriteTextColor != -1 ? SpriteText.getColorFromIndex(spriteTextColor) : null; + base.showTextAboveHead(Text, color, style, duration, preTimer); + } + + + /********* + ** Private methods + *********/ + private NpcFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ObjectFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ObjectFacade.cs new file mode 100644 index 000000000..16e872a10 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ObjectFacade.cs @@ -0,0 +1,124 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using SObject = StardewValley.Object; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "ParameterHidesMember", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = SuppressReasons.MatchesOriginal)] + public class ObjectFacade : SObject, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static SObject Constructor(Vector2 tileLocation, int parentSheetIndex, bool isRecipe = false) + { + return new SObject(tileLocation, parentSheetIndex.ToString(), isRecipe); + } + + public static SObject Constructor(int parentSheetIndex, int initialStack, bool isRecipe = false, int price = -1, int quality = 0) + { + return new SObject(parentSheetIndex.ToString(), initialStack, isRecipe, price, quality); + } + + public static SObject Constructor(Vector2 tileLocation, int parentSheetIndex, int initialStack) + { + SObject obj = Constructor(tileLocation, parentSheetIndex, null, true, true, false, false); + obj.stack.Value = initialStack; + return obj; + } + + public static SObject Constructor(Vector2 tileLocation, int parentSheetIndex, string? Givenname, bool canBeSetDown, bool canBeGrabbed, bool isHoedirt, bool isSpawnedObject) + { + SObject obj = new(parentSheetIndex.ToString(), 1); + + if (Givenname != null && obj.name is (null or "Error Item")) + obj.name = Givenname; + + obj.tileLocation.Value = tileLocation; + obj.canBeSetDown.Value = canBeSetDown; + obj.canBeGrabbed.Value = canBeGrabbed; + obj.isSpawnedObject.Value = isSpawnedObject; + + return obj; + } + + public void ApplySprinkler(GameLocation location, Vector2 tile) + { + base.ApplySprinkler(tile); + } + + public void ApplySprinklerAnimation(GameLocation location) + { + base.ApplySprinklerAnimation(); + } + + public new void ConsumeInventoryItem(Farmer who, Item drop_in, int amount) + { + Object.ConsumeInventoryItem(who, drop_in, amount); + } + + public void DayUpdate(GameLocation location) + { + base.DayUpdate(); + } + + public void farmerAdjacentAction(GameLocation location) + { + base.farmerAdjacentAction(Game1.player); + } + + public Rectangle getBoundingBox(Vector2 tileLocation) + { + return base.GetBoundingBoxAt((int)tileLocation.X, (int)tileLocation.Y); + } + + public bool isForage(GameLocation location) + { + return base.isForage(); + } + + public bool minutesElapsed(int minutes, GameLocation environment) + { + return base.minutesElapsed(minutes); + } + + public bool onExplosion(Farmer who, GameLocation location) + { + return base.onExplosion(who); + } + + public void performRemoveAction(Vector2 tileLocation, GameLocation environment) + { + base.performRemoveAction(); + } + + public bool performToolAction(Tool t, GameLocation location) + { + return base.performToolAction(t); + } + + public void updateWhenCurrentLocation(GameTime time, GameLocation environment) + { + base.updateWhenCurrentLocation(time); + } + + + /********* + ** Private methods + *********/ + private ObjectFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/OverlaidDictionaryFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/OverlaidDictionaryFacade.cs new file mode 100644 index 000000000..73f70daaf --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/OverlaidDictionaryFacade.cs @@ -0,0 +1,216 @@ +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.Network; +using SObject = StardewValley.Object; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "StructCanBeMadeReadOnly", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class OverlaidDictionaryFacade : OverlaidDictionary, IRewriteFacade + { + /********* + ** Accessors + *********/ + public new KeysCollection Keys => new(this); + public new ValuesCollection Values => new(this); + public new PairsCollection Pairs => new(this); + + + /********* + ** Enumerator facades + *********/ + public struct KeysCollection : IEnumerable + { + private readonly OverlaidDictionary Dictionary; + + public KeysCollection(OverlaidDictionary dictionary) + { + this.Dictionary = dictionary; + } + + public int Count() + { + return this.Dictionary.Length; + } + + public Enumerator GetEnumerator() + { + return new Enumerator(this.Dictionary); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return new Enumerator(this.Dictionary); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return new Enumerator(this.Dictionary); + } + + public struct Enumerator : IEnumerator + { + private readonly OverlaidDictionary Dictionary; + private Dictionary.KeyCollection.Enumerator CurEnumerator; + + public Vector2 Current => this.CurEnumerator.Current; + object IEnumerator.Current => this.CurEnumerator.Current; + + public Enumerator(OverlaidDictionary dictionary) + { + this.Dictionary = dictionary; + this.CurEnumerator = dictionary.Keys.GetEnumerator(); + } + + public bool MoveNext() + { + return this.CurEnumerator.MoveNext(); + } + + public void Dispose() + { + this.CurEnumerator.Dispose(); + } + + void IEnumerator.Reset() + { + this.CurEnumerator = this.Dictionary.Keys.GetEnumerator(); + } + } + } + + public struct PairsCollection : IEnumerable> + { + private readonly OverlaidDictionary Dictionary; + + public PairsCollection(OverlaidDictionary dictionary) + { + this.Dictionary = dictionary; + } + + public KeyValuePair ElementAt(int index) + { + return this.Dictionary.Pairs.ElementAt(index); + } + + public Enumerator GetEnumerator() + { + return new Enumerator(this.Dictionary); + } + + IEnumerator> IEnumerable>.GetEnumerator() + { + return new Enumerator(this.Dictionary); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return new Enumerator(this.Dictionary); + } + + public struct Enumerator : IEnumerator> + { + private readonly OverlaidDictionary Dictionary; + private IEnumerator> CurEnumerator; + + public KeyValuePair Current => this.CurEnumerator.Current; + object IEnumerator.Current => this.CurEnumerator.Current; + + public Enumerator(OverlaidDictionary dictionary) + { + this.Dictionary = dictionary; + this.CurEnumerator = dictionary.Pairs.GetEnumerator(); + } + + public bool MoveNext() + { + return this.CurEnumerator.MoveNext(); + } + + public void Dispose() + { + this.CurEnumerator.Dispose(); + } + + void IEnumerator.Reset() + { + this.CurEnumerator = this.Dictionary.Pairs.GetEnumerator(); + } + } + } + + public struct ValuesCollection : IEnumerable + { + private readonly OverlaidDictionary Dictionary; + + public ValuesCollection(OverlaidDictionary dictionary) + { + this.Dictionary = dictionary; + } + + public Enumerator GetEnumerator() + { + return new Enumerator(this.Dictionary); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return new Enumerator(this.Dictionary); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return new Enumerator(this.Dictionary); + } + + public struct Enumerator : IEnumerator + { + private readonly OverlaidDictionary Dictionary; + private Dictionary.ValueCollection.Enumerator CurEnumerator; + + public SObject Current => this.CurEnumerator.Current; + object IEnumerator.Current => this.CurEnumerator.Current; + + public Enumerator(OverlaidDictionary dictionary) + { + this.Dictionary = dictionary; + this.CurEnumerator = dictionary.Values.GetEnumerator(); + } + + public bool MoveNext() + { + return this.CurEnumerator.MoveNext(); + } + + public void Dispose() + { + this.CurEnumerator.Dispose(); + } + + void IEnumerator.Reset() + { + this.CurEnumerator = this.Dictionary.Values.GetEnumerator(); + } + } + } + + + /********* + ** Private methods + *********/ + private OverlaidDictionaryFacade() + : base(null, null) + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/PathFindControllerFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/PathFindControllerFacade.cs new file mode 100644 index 000000000..aee05bbae --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/PathFindControllerFacade.cs @@ -0,0 +1,42 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Pathfinding; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = SuppressReasons.MatchesOriginal)] + public class PathFindControllerFacade : PathFindController, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static PathFindController Constructor(Character c, GameLocation location, Point endPoint, int finalFacingDirection, bool eraseOldPathController, bool clearMarriageDialogues = true) + { + return new PathFindController(c, location, endPoint, finalFacingDirection, clearMarriageDialogues); + } + + public static PathFindController Constructor(Character c, GameLocation location, isAtEnd endFunction, int finalFacingDirection, bool eraseOldPathController, endBehavior endBehaviorFunction, int limit, Point endPoint, bool clearMarriageDialogues = true) + { + return new PathFindController(c, location, endFunction, finalFacingDirection, endBehaviorFunction, limit, endPoint, clearMarriageDialogues); + } + + + /********* + ** Private methods + *********/ + private PathFindControllerFacade() + : base(null, null, Point.Zero, 0) + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ProfileMenuFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ProfileMenuFacade.cs new file mode 100644 index 000000000..0fff5ff37 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ProfileMenuFacade.cs @@ -0,0 +1,34 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Menus; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class ProfileMenuFacade : ProfileMenu, IRewriteFacade + { + /********* + ** Public methods + *********/ + public Character? GetCharacter() + { + return base.Current?.Character; + } + + + /********* + ** Private methods + *********/ + private ProfileMenuFacade() + : base(null, null) + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ProjectileFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ProjectileFacade.cs new file mode 100644 index 000000000..f425ff386 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ProjectileFacade.cs @@ -0,0 +1,30 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Projectiles; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public abstract class ProjectileFacade : Projectile, IRewriteFacade + { + /********* + ** Accessors + *********/ + public static int boundingBoxHeight { get; set; } = Game1.tileSize / 3; // field was never used + + + /********* + ** Private methods + *********/ + private ProjectileFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/QuestFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/QuestFacade.cs new file mode 100644 index 000000000..5f4fed0a9 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/QuestFacade.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.Quests; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class QuestFacade : Quest, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static Quest getQuestFromId(int id) + { + return Quest.getQuestFromId(id.ToString()); + } + + + /********* + ** Private methods + *********/ + private QuestFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ResourceClumpFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ResourceClumpFacade.cs new file mode 100644 index 000000000..bd2567eed --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ResourceClumpFacade.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; +using Netcode; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.TerrainFeatures; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = SuppressReasons.BaseForClarity)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class ResourceClumpFacade : ResourceClump, IRewriteFacade + { + /********* + ** Public methods + *********/ + public NetVector2 tile => base.netTile; + + + /********* + ** Private methods + *********/ + private ResourceClumpFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/RingFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/RingFacade.cs new file mode 100644 index 000000000..84b6e5f8d --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/RingFacade.cs @@ -0,0 +1,48 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Objects; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class RingFacade : Ring, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static Ring Constructor(int which) + { + return new Ring(which.ToString()); + } + + public virtual bool GetsEffectOfRing(int ring_index) + { + return base.GetsEffectOfRing(ring_index.ToString()); + } + + public virtual void onEquip(Farmer who, GameLocation location) + { + base.onEquip(who); + } + + public virtual void onUnequip(Farmer who, GameLocation location) + { + base.onUnequip(who); + } + + + /********* + ** Private methods + *********/ + private RingFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ShopMenuFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ShopMenuFacade.cs new file mode 100644 index 000000000..9fa40ea09 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ShopMenuFacade.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Menus; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class ShopMenuFacade : ShopMenu, IRewriteFacade + { + /********* + ** Accessors + *********/ + public string storeContext + { + get => base.ShopId; + set => base.ShopId = value; + } + + + /********* + ** Public methods + *********/ + public static ShopMenu Constructor(Dictionary itemPriceAndStock, int currency = 0, string? who = null, Func? on_purchase = null, Func? on_sell = null, string? context = null) + { + return new ShopMenu(ShopMenuFacade.GetShopId(context), ShopMenuFacade.ToItemStockInformation(itemPriceAndStock), currency, who, on_purchase, on_sell, playOpenSound: true); + } + + public static ShopMenu Constructor(List itemsForSale, int currency = 0, string? who = null, Func? on_purchase = null, Func? on_sell = null, string? context = null) + { + return new ShopMenu(ShopMenuFacade.GetShopId(context), itemsForSale, currency, who, on_purchase, on_sell, playOpenSound: true); + } + + + /********* + ** Private methods + *********/ + private ShopMenuFacade() + : base(null, null, null) + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + + private static string GetShopId(string? context) + { + return string.IsNullOrWhiteSpace(context) + ? "legacy_mod_code_" + Guid.NewGuid().ToString("N") + : context; + } + + private static Dictionary ToItemStockInformation(Dictionary? itemPriceAndStock) + { + Dictionary stock = new(); + + if (itemPriceAndStock != null) + { + foreach (var pair in itemPriceAndStock) + stock[pair.Key] = new ItemStockInformation(pair.Value[0], pair.Value[1]); + } + + return stock; + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/SignFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/SignFacade.cs new file mode 100644 index 000000000..c25187e23 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/SignFacade.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.Objects; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class SignFacade : Sign, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static Sign Constructor(Vector2 tile, int which) + { + return new Sign(tile, which.ToString()); + } + + + /********* + ** Private methods + *********/ + private SignFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/SlingshotFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/SlingshotFacade.cs new file mode 100644 index 000000000..b65b4591b --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/SlingshotFacade.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.Tools; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class SlingshotFacade : Slingshot, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static Slingshot Constructor(int which = 32) + { + return new Slingshot(which.ToString()); + } + + + /********* + ** Private methods + *********/ + private SlingshotFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/SoundEffectFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/SoundEffectFacade.cs new file mode 100644 index 000000000..b79f823bc --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/SoundEffectFacade.cs @@ -0,0 +1,33 @@ +using System.Diagnostics.CodeAnalysis; +using System.IO; +using Microsoft.Xna.Framework.Audio; +using StardewModdingAPI.Framework.ModLoading.Framework; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class SoundEffectFacade : SoundEffect, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static SoundEffect FromStream(Stream stream) + { + return SoundEffect.FromStream(stream); + } + + + /********* + ** Private methods + *********/ + private SoundEffectFacade() + : base(null, 0, AudioChannels.Mono) + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/SpriteTextFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/SpriteTextFacade.cs new file mode 100644 index 000000000..89f6f059c --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/SpriteTextFacade.cs @@ -0,0 +1,113 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.BellsAndWhistles; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class SpriteTextFacade : SpriteText, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static void drawString(SpriteBatch b, string s, int x, int y, int characterPosition = maxCharacter, int width = -1, int height = maxHeight, float alpha = 1f, float layerDepth = .88f, bool junimoText = false, int drawBGScroll = -1, string placeHolderScrollWidthText = "", int color = -1, ScrollTextAlignment scroll_text_alignment = ScrollTextAlignment.Left) + { + SpriteText.drawString( + b: b, + s: s, + x: x, + y: y, + characterPosition: characterPosition, + width: width, + height: height, + alpha: alpha, + layerDepth: layerDepth, + junimoText: junimoText, + drawBGScroll: drawBGScroll, + placeHolderScrollWidthText: placeHolderScrollWidthText, + color: color != -1 ? SpriteText.getColorFromIndex(color) : null, + scroll_text_alignment: scroll_text_alignment + ); + } + + public static void drawStringWithScrollBackground(SpriteBatch b, string s, int x, int y, string placeHolderWidthText = "", float alpha = 1f, int color = -1, ScrollTextAlignment scroll_text_alignment = ScrollTextAlignment.Left) + { + SpriteText.drawStringWithScrollBackground( + b: b, + s: s, + x: x, + y: y, + placeHolderWidthText: placeHolderWidthText, + alpha: alpha, + color: color != -1 ? SpriteText.getColorFromIndex(color) : null, + scroll_text_alignment: scroll_text_alignment + ); + } + + public static void drawStringWithScrollCenteredAt(SpriteBatch b, string s, int x, int y, int width, float alpha = 1f, int color = -1, int scrollType = SpriteText.scrollStyle_scroll, float layerDepth = .88f, bool junimoText = false) + { + SpriteText.drawStringWithScrollCenteredAt( + b: b, + s: s, + x: x, + y: y, + width: width, + alpha: alpha, + color: color != -1 ? SpriteText.getColorFromIndex(color) : null, + scrollType: scrollType, + layerDepth: layerDepth, + junimoText: junimoText + ); + } + + public static void drawStringWithScrollCenteredAt(SpriteBatch b, string s, int x, int y, string placeHolderWidthText = "", float alpha = 1f, int color = -1, int scrollType = scrollStyle_scroll, float layerDepth = .88f, bool junimoText = false) + { + SpriteText.drawStringWithScrollCenteredAt( + b: b, + s: s, + x: x, + y: y, + placeHolderWidthText: placeHolderWidthText, + alpha: alpha, + color: color != -1 ? SpriteText.getColorFromIndex(color) : null, + scrollType: scrollType, + layerDepth: layerDepth, + junimoText: junimoText + ); + } + + public static void drawStringHorizontallyCenteredAt(SpriteBatch b, string s, int x, int y, int characterPosition = maxCharacter, int width = -1, int height = maxHeight, float alpha = 1f, float layerDepth = .88f, bool junimoText = false, int color = -1, int maxWidth = 99999) + { + SpriteText.drawStringHorizontallyCenteredAt( + b: b, + s: s, + x: x, + y: y, + characterPosition: characterPosition, + width: width, + height: height, + alpha: alpha, + layerDepth: layerDepth, + junimoText: junimoText, + color: color != -1 ? SpriteText.getColorFromIndex(color) : null, + maxWidth: maxWidth + ); + } + + + /********* + ** Private methods + *********/ + private SpriteTextFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/StatsFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/StatsFacade.cs new file mode 100644 index 000000000..2c927606c --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/StatsFacade.cs @@ -0,0 +1,343 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = SuppressReasons.BaseForClarity)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class StatsFacade : Stats, IRewriteFacade + { + /********* + ** Accessors + *********/ + /**** + ** Other fields + ****/ + public new SerializableDictionary specificMonstersKilled + { + get => base.specificMonstersKilled; + } + + //started using this in 1.4 to track stats, rather than the annoying and messy uint fields above + public SerializableDictionary stat_dictionary + { + get => base.Values; + } + + /**** + ** Former uint fields + ****/ + public uint averageBedtime + { + get => base.AverageBedtime; + set => base.AverageBedtime = value; + } + + public uint beveragesMade + { + get => base.BeveragesMade; + set => base.BeveragesMade = value; + } + + public uint caveCarrotsFound + { + get => base.CaveCarrotsFound; + set => base.CaveCarrotsFound = value; + } + + public uint cheeseMade + { + get => base.CheeseMade; + set => base.CheeseMade = value; + } + + public uint chickenEggsLayed + { + get => base.ChickenEggsLayed; + set => base.ChickenEggsLayed = value; + } + + public uint copperFound + { + get => base.CopperFound; + set => base.CopperFound = value; + } + + public uint cowMilkProduced + { + get => base.CowMilkProduced; + set => base.CowMilkProduced = value; + } + + public uint cropsShipped + { + get => base.CropsShipped; + set => base.CropsShipped = value; + } + + public uint daysPlayed + { + get => base.DaysPlayed; + set => base.DaysPlayed = value; + } + + public uint diamondsFound + { + get => base.DiamondsFound; + set => base.DiamondsFound = value; + } + + public uint dirtHoed + { + get => base.DirtHoed; + set => base.DirtHoed = value; + } + + public uint duckEggsLayed + { + get => base.DuckEggsLayed; + set => base.DuckEggsLayed = value; + } + + public uint fishCaught + { + get => base.FishCaught; + set => base.FishCaught = value; + } + + public uint geodesCracked + { + get => base.GeodesCracked; + set => base.GeodesCracked = value; + } + + public uint giftsGiven + { + get => base.GiftsGiven; + set => base.GiftsGiven = value; + } + + public uint goatCheeseMade + { + get => base.GoatCheeseMade; + set => base.GoatCheeseMade = value; + } + + public uint goatMilkProduced + { + get => base.GoatMilkProduced; + set => base.GoatMilkProduced = value; + } + + public uint goldFound + { + get => base.GoldFound; + set => base.GoldFound = value; + } + + public uint goodFriends + { + get => base.GoodFriends; + set => base.GoodFriends = value; + } + + public uint individualMoneyEarned + { + get => base.IndividualMoneyEarned; + set => base.IndividualMoneyEarned = value; + } + + public uint iridiumFound + { + get => base.IridiumFound; + set => base.IridiumFound = value; + } + + public uint ironFound + { + get => base.IronFound; + set => base.IronFound = value; + } + + public uint itemsCooked + { + get => base.ItemsCooked; + set => base.ItemsCooked = value; + } + + public uint itemsCrafted + { + get => base.ItemsCrafted; + set => base.ItemsCrafted = value; + } + + public uint itemsForaged + { + get => base.ItemsForaged; + set => base.ItemsForaged = value; + } + + public uint itemsShipped + { + get => base.ItemsShipped; + set => base.ItemsShipped = value; + } + + public uint monstersKilled + { + get => base.MonstersKilled; + set => base.MonstersKilled = value; + } + + public uint mysticStonesCrushed + { + get => base.MysticStonesCrushed; + set => base.MysticStonesCrushed = value; + } + + public uint notesFound + { + get => base.NotesFound; + set => base.NotesFound = value; + } + + public uint otherPreciousGemsFound + { + get => base.OtherPreciousGemsFound; + set => base.OtherPreciousGemsFound = value; + } + + public uint piecesOfTrashRecycled + { + get => base.PiecesOfTrashRecycled; + set => base.PiecesOfTrashRecycled = value; + } + + public uint preservesMade + { + get => base.PreservesMade; + set => base.PreservesMade = value; + } + + public uint prismaticShardsFound + { + get => base.PrismaticShardsFound; + set => base.PrismaticShardsFound = value; + } + + public uint questsCompleted + { + get => base.QuestsCompleted; + set => base.QuestsCompleted = value; + } + + public uint rabbitWoolProduced + { + get => base.RabbitWoolProduced; + set => base.RabbitWoolProduced = value; + } + + public uint rocksCrushed + { + get => base.RocksCrushed; + set => base.RocksCrushed = value; + } + + public uint seedsSown + { + get => base.SeedsSown; + set => base.SeedsSown = value; + } + + public uint sheepWoolProduced + { + get => base.SheepWoolProduced; + set => base.SheepWoolProduced = value; + } + + public uint slimesKilled + { + get => base.SlimesKilled; + set => base.SlimesKilled = value; + } + + public uint stepsTaken + { + get => base.StepsTaken; + set => base.StepsTaken = value; + } + + public uint stoneGathered + { + get => base.StoneGathered; + set => base.StoneGathered = value; + } + + public uint stumpsChopped + { + get => base.StumpsChopped; + set => base.StumpsChopped = value; + } + + public uint timesFished + { + get => base.TimesFished; + set => base.TimesFished = value; + } + + public uint timesUnconscious + { + get => base.TimesUnconscious; + set => base.TimesUnconscious = value; + } + + public uint totalMoneyGifted + { + get => base.Get("totalMoneyGifted"); + set => base.Set("totalMoneyGifted", value); + } + + public uint trufflesFound + { + get => base.TrufflesFound; + set => base.TrufflesFound = value; + } + + public uint weedsEliminated + { + get => base.WeedsEliminated; + set => base.WeedsEliminated = value; + } + + + /********* + ** Public methods + *********/ + public uint getStat(string label) + { + return base.Get(label); + } + + public void incrementStat(string label, int amount) + { + base.Increment(label, amount); + } + + + + /********* + ** Private methods + *********/ + private StatsFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/StorageFurnitureFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/StorageFurnitureFacade.cs new file mode 100644 index 000000000..7949f6821 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/StorageFurnitureFacade.cs @@ -0,0 +1,37 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.Objects; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class StorageFurnitureFacade : StorageFurniture, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static StorageFurniture Constructor(int which, Vector2 tile, int initialRotations) + { + return new StorageFurniture(which.ToString(), tile, initialRotations); + } + + public static StorageFurniture Constructor(int which, Vector2 tile) + { + return new StorageFurniture(which.ToString(), tile); + } + + + /********* + ** Private methods + *********/ + private StorageFurnitureFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/TemporaryAnimatedSpriteFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/TemporaryAnimatedSpriteFacade.cs new file mode 100644 index 000000000..7e5ed0203 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/TemporaryAnimatedSpriteFacade.cs @@ -0,0 +1,34 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + [SuppressMessage("Style", "IDE1006:Naming Styles", Justification = SuppressReasons.MatchesOriginal)] + public class TemporaryAnimatedSpriteFacade : TemporaryAnimatedSprite, IRewriteFacade + { + /********* + ** Accessors + *********/ + public new float id + { + get => base.id; + set => base.id = (int)value; + } + + + /********* + ** Private methods + *********/ + private TemporaryAnimatedSpriteFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/TerrainFeatureFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/TerrainFeatureFacade.cs new file mode 100644 index 000000000..a862933b5 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/TerrainFeatureFacade.cs @@ -0,0 +1,66 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.TerrainFeatures; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class TerrainFeatureFacade : TerrainFeature, IRewriteFacade + { + /********* + ** Accessors + *********/ + public GameLocation currentLocation + { + get => base.Location; + set => base.Location = value; + } + + public Vector2 currentTileLocation + { + get => base.Tile; + set => base.Tile = value; + } + + + /********* + ** Public methods + *********/ + public virtual void dayUpdate(GameLocation environment, Vector2 tileLocation) + { + base.dayUpdate(); + } + + public Rectangle getBoundingBox(Vector2 tileLocation) + { + return base.getBoundingBox(); + } + + public virtual bool performToolAction(Tool t, int damage, Vector2 tileLocation, GameLocation location) + { + return base.performToolAction(t, damage, tileLocation); + } + + public virtual bool performUseAction(Vector2 tileLocation, GameLocation location) + { + return base.performUseAction(tileLocation); + } + + + /********* + ** Private methods + *********/ + private TerrainFeatureFacade() + : base(false) + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ToolFactoryFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ToolFactoryFacade.cs new file mode 100644 index 000000000..9fe6ef980 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ToolFactoryFacade.cs @@ -0,0 +1,57 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Tools; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's ToolFactory methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class ToolFactoryFacade : IRewriteFacade + { + /********* + ** Accessors + *********/ + public const byte axe = 0; + public const byte hoe = 1; + public const byte fishingRod = 2; + public const byte pickAxe = 3; + public const byte wateringCan = 4; + public const byte meleeWeapon = 5; + public const byte slingshot = 6; + + + /********* + ** Public methods + *********/ + public static Tool getToolFromDescription(byte index, int upgradeLevel) + { + Tool? t = null; + switch (index) + { + case axe: t = new Axe(); break; + case hoe: t = new Hoe(); break; + case fishingRod: t = new FishingRod(); break; + case pickAxe: t = new Pickaxe(); break; + case wateringCan: t = new WateringCan(); break; + case meleeWeapon: t = new MeleeWeapon("0"); break; + case slingshot: t = new Slingshot(); break; + } + t.UpgradeLevel = upgradeLevel; + return t; + } + + + /********* + ** Private methods + *********/ + private ToolFactoryFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/TreeFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/TreeFacade.cs new file mode 100644 index 000000000..da7271211 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/TreeFacade.cs @@ -0,0 +1,49 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.TerrainFeatures; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class TreeFacade : Tree, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static Tree Constructor(int which) + { + return new Tree(which.ToString()); + } + + public static Tree Constructor(int which, int growthStage) + { + return new Tree(which.ToString(), growthStage); + } + + public bool fertilize(GameLocation location) + { + return base.fertilize(); + } + + public bool instantDestroy(Vector2 tileLocation, GameLocation location) + { + return base.instantDestroy(tileLocation); + } + + + /********* + ** Private methods + *********/ + private TreeFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/TvFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/TvFacade.cs new file mode 100644 index 000000000..235dc874f --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/TvFacade.cs @@ -0,0 +1,33 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.Objects; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class TvFacade : TV, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static TV Constructor(int which, Vector2 tile) + { + return new TV(which.ToString(), tile); + } + + + /********* + ** Private methods + *********/ + private TvFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/UtilityFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/UtilityFacade.cs new file mode 100644 index 000000000..af21d0f06 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/UtilityFacade.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; +using StardewValley.Buildings; +using StardewValley.Extensions; +using SObject = StardewValley.Object; + +#pragma warning disable CS0618 // Type or member is obsolete: this is backwards-compatibility code. +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class UtilityFacade : Utility, IRewriteFacade + { + /********* + ** Public methods + *********/ + public static bool doesItemWithThisIndexExistAnywhere(int index, bool bigCraftable = false) + { + bool found = false; + + Utility.ForEachItem(item => + { + found = item is SObject obj && obj.bigCraftable.Value == bigCraftable && obj.ParentSheetIndex == index; + return !found; + }); + + return found; + } + + public static void ForAllLocations(Action action) + { + Utility.ForEachLocation(location => + { + action(location); + return true; + }); + } + + public new static DisposableList getAllCharacters() + { + return new DisposableList(Utility.getAllCharacters()); + } + + public static List getAllCharacters(List list) + { + list.AddRange(Utility.getAllCharacters()); + return list; + } + + public new static IEnumerable GetHorseWarpRestrictionsForFarmer(Farmer who) + { + Utility.HorseWarpRestrictions restrictions = Utility.GetHorseWarpRestrictionsForFarmer(who); + + if (restrictions.HasFlag(Utility.HorseWarpRestrictions.NoOwnedHorse)) + yield return 1; + if (restrictions.HasFlag(Utility.HorseWarpRestrictions.Indoors)) + yield return 2; + if (restrictions.HasFlag(Utility.HorseWarpRestrictions.NoRoom)) + yield return 3; + if (restrictions.HasFlag(Utility.HorseWarpRestrictions.InUse)) + yield return 3; + } + + public static T GetRandom(List list, Random? random = null) + { + return (random ?? Game1.random).ChooseFrom(list); + } + + public static NPC? getTodaysBirthdayNPC(string season, int day) + { + // use new method if possible + if (season == Game1.currentSeason && day == Game1.dayOfMonth) + return Utility.getTodaysBirthdayNPC(); + + // else replicate old behavior + NPC? found = null; + Utility.ForEachCharacter(npc => + { + if (npc.birthday_Season.Value == season && npc.birthday_Day.Value == day) + found = npc; + + return found is null; + }); + return found; + } + + public static bool HasAnyPlayerSeenEvent(int event_number) + { + return Utility.HasAnyPlayerSeenEvent(event_number.ToString()); + } + + public static bool HaveAllPlayersSeenEvent(int event_number) + { + return Utility.HaveAllPlayersSeenEvent(event_number.ToString()); + } + + public static bool isFestivalDay(int day, string season) + { + return + Utility.TryParseEnum(season, out Season parsedSeason) + && Utility.isFestivalDay(day, parsedSeason); + } + + public static bool IsNormalObjectAtParentSheetIndex(Item item, int index) + { + return Utility.IsNormalObjectAtParentSheetIndex(item, index.ToString()); + } + + public static int numObelisksOnFarm() + { + return Utility.GetObeliskTypesBuilt(); + } + + public static int numSilos() + { + return Game1.GetNumberBuildingsConstructed("Silo"); + } + + public new static List sparkleWithinArea(Rectangle bounds, int numberOfSparkles, Color sparkleColor, int delayBetweenSparkles = 100, int delayBeforeStarting = 0, string sparkleSound = "") + { + TemporaryAnimatedSpriteList list = Utility.getTemporarySpritesWithinArea(new[] { 10, 11 }, bounds, numberOfSparkles, sparkleColor, delayBetweenSparkles, delayBeforeStarting, sparkleSound); + return list.ToList(); + } + + public static void spreadAnimalsAround(Building b, Farm environment) + { + Utility.spreadAnimalsAround(b, environment); + } + + + /********* + ** Private methods + *********/ + private UtilityFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ViewportExtensionsFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ViewportExtensionsFacade.cs new file mode 100644 index 000000000..2e7345616 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/ViewportExtensionsFacade.cs @@ -0,0 +1,44 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.Extensions; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's ViewportExtensions methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class ViewportExtensionsFacade : IRewriteFacade + { + /********* + ** Public methods + *********/ + public static Rectangle GetTitleSafeArea(Viewport vp) + { + return vp.GetTitleSafeArea(); + } + + public static Rectangle ToXna(xTile.Dimensions.Rectangle xrect) + { + return new Rectangle(xrect.X, xrect.Y, xrect.Width, xrect.Height); + } + + public static Vector2 Size(Viewport vp) + { + return new Vector2(vp.Width, vp.Height); + } + + + /********* + ** Private methods + *********/ + private ViewportExtensionsFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/WallpaperFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/WallpaperFacade.cs new file mode 100644 index 000000000..7d56da5f3 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/WallpaperFacade.cs @@ -0,0 +1,40 @@ +using System.Diagnostics.CodeAnalysis; +using Netcode; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.GameData; +using StardewValley.Objects; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class WallpaperFacade : Wallpaper, IRewriteFacade + { + /********* + ** Accessors + *********/ + public NetString modDataID => base.itemId; + + + /********* + ** Public methods + *********/ + public virtual ModWallpaperOrFlooring GetModData() + { + return base.GetSetData(); + } + + + /********* + ** Private methods + *********/ + private WallpaperFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/WateringCanFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/WateringCanFacade.cs new file mode 100644 index 000000000..45104b6aa --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/WateringCanFacade.cs @@ -0,0 +1,34 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley.Tools; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = SuppressReasons.MatchesOriginal)] + [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = SuppressReasons.BaseForClarity)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class WateringCanFacade : WateringCan, IRewriteFacade + { + /********* + ** Accessors + *********/ + public int waterLeft + { + get => base.WaterLeft; + set => base.WaterLeft = value; + } + + + /********* + ** Private methods + *********/ + private WateringCanFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/WorldDateFacade.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/WorldDateFacade.cs new file mode 100644 index 000000000..43fdcd9a7 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StardewValley_1_6/WorldDateFacade.cs @@ -0,0 +1,33 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewValley; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member: This is internal code to support rewriters and shouldn't be called directly. + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6 +{ + /// Maps Stardew Valley 1.5.6's methods to their newer form to avoid breaking older mods. + /// This is public to support SMAPI rewriting and should never be referenced directly by mods. See remarks on for more info. + [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = SuppressReasons.BaseForClarity)] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = SuppressReasons.UsedViaRewriting)] + public class WorldDateFacade : WorldDate, IRewriteFacade + { + /********* + ** Accessors + *********/ + public new string Season + { + get => base.SeasonKey; + set => base.SeasonKey = value; + } + + + /********* + ** Private methods + *********/ + private WorldDateFacade() + { + RewriteHelper.ThrowFakeConstructorCalled(); + } + } +} diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs index 87970e6c0..40bdb1304 100644 --- a/src/SMAPI/Framework/Models/SConfig.cs +++ b/src/SMAPI/Framework/Models/SConfig.cs @@ -22,7 +22,9 @@ internal class SConfig [nameof(GitHubProjectName)] = "Pathoschild/SMAPI", [nameof(WebApiBaseUrl)] = "https://smapi.io/api/", [nameof(LogNetworkTraffic)] = false, + [nameof(LogTechnicalDetailsForBrokenMods)] = false, [nameof(RewriteMods)] = true, + [nameof(FixHarmony)] = true, [nameof(UseCaseInsensitivePaths)] = Constants.Platform is Platform.Android or Platform.Linux, [nameof(SuppressHarmonyDebugMode)] = true }; @@ -31,7 +33,6 @@ internal class SConfig private static readonly HashSet DefaultSuppressUpdateChecks = new(StringComparer.OrdinalIgnoreCase) { "SMAPI.ConsoleCommands", - "SMAPI.ErrorHandler", "SMAPI.SaveBackup" }; @@ -71,12 +72,18 @@ internal class SConfig /// Whether SMAPI should rewrite mods for compatibility. public bool RewriteMods { get; set; } + /// Whether to apply fixes to Harmony so it works with Stardew Valley. + public bool FixHarmony { get; set; } + /// Whether to make SMAPI file APIs case-insensitive, even on Linux. public bool UseCaseInsensitivePaths { get; set; } /// Whether SMAPI should log network traffic. Best combined with , which includes network metadata. public bool LogNetworkTraffic { get; set; } + /// Whether to include more technical details about broken mods in the TRACE logs. This is mainly useful for creating compatibility rewriters. + public bool LogTechnicalDetailsForBrokenMods { get; set; } + /// The colors to use for text written to the SMAPI console. public ColorSchemeConfig ConsoleColors { get; set; } @@ -106,14 +113,16 @@ internal class SConfig /// /// /// + /// /// /// + /// /// /// /// /// /// - public SConfig(bool developerMode, bool? checkForUpdates, bool? listenForConsoleInput, bool? paranoidWarnings, bool? useBetaChannel, string gitHubProjectName, string webApiBaseUrl, string[]? verboseLogging, bool? rewriteMods, bool? useCaseInsensitivePaths, bool? logNetworkTraffic, ColorSchemeConfig consoleColors, bool? suppressHarmonyDebugMode, string[]? suppressUpdateChecks, string[]? modsToLoadEarly, string[]? modsToLoadLate) + public SConfig(bool developerMode, bool? checkForUpdates, bool? listenForConsoleInput, bool? paranoidWarnings, bool? useBetaChannel, string gitHubProjectName, string webApiBaseUrl, string[]? verboseLogging, bool? rewriteMods, bool? fixHarmony, bool? useCaseInsensitivePaths, bool? logNetworkTraffic, bool? logTechnicalDetailsForBrokenMods, ColorSchemeConfig consoleColors, bool? suppressHarmonyDebugMode, string[]? suppressUpdateChecks, string[]? modsToLoadEarly, string[]? modsToLoadLate) { this.DeveloperMode = developerMode; this.CheckForUpdates = checkForUpdates ?? (bool)SConfig.DefaultValues[nameof(this.CheckForUpdates)]; @@ -124,8 +133,10 @@ public SConfig(bool developerMode, bool? checkForUpdates, bool? listenForConsole this.WebApiBaseUrl = webApiBaseUrl; this.VerboseLogging = new HashSet(verboseLogging ?? Array.Empty(), StringComparer.OrdinalIgnoreCase); this.RewriteMods = rewriteMods ?? (bool)SConfig.DefaultValues[nameof(this.RewriteMods)]; + this.FixHarmony = fixHarmony ?? (bool)SConfig.DefaultValues[nameof(this.FixHarmony)]; this.UseCaseInsensitivePaths = useCaseInsensitivePaths ?? (bool)SConfig.DefaultValues[nameof(this.UseCaseInsensitivePaths)]; this.LogNetworkTraffic = logNetworkTraffic ?? (bool)SConfig.DefaultValues[nameof(this.LogNetworkTraffic)]; + this.LogTechnicalDetailsForBrokenMods = logTechnicalDetailsForBrokenMods ?? (bool)SConfig.DefaultValues[nameof(this.LogTechnicalDetailsForBrokenMods)]; this.ConsoleColors = consoleColors; this.SuppressHarmonyDebugMode = suppressHarmonyDebugMode ?? (bool)SConfig.DefaultValues[nameof(this.SuppressHarmonyDebugMode)]; this.SuppressUpdateChecks = new HashSet(suppressUpdateChecks ?? Array.Empty(), StringComparer.OrdinalIgnoreCase); diff --git a/src/SMAPI/Framework/Monitor.cs b/src/SMAPI/Framework/Monitor.cs index 4ed2c9bbb..cecb0040c 100644 --- a/src/SMAPI/Framework/Monitor.cs +++ b/src/SMAPI/Framework/Monitor.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using StardewModdingAPI.Framework.Logging; using StardewModdingAPI.Internal.ConsoleWriting; @@ -18,9 +19,6 @@ internal class Monitor : IMonitor /// Handles writing text to the console. private readonly IConsoleWriter ConsoleWriter; - /// Prefixing a message with this character indicates that the console interceptor should write the string without intercepting it. (The character itself is not written.) - private readonly char IgnoreChar; - /// The log file to which to write messages. private readonly LogFileManager LogFile; @@ -28,7 +26,7 @@ internal class Monitor : IMonitor private static readonly int MaxLevelLength = Enum.GetValues().Max(level => level.ToString().Length); /// The cached representation for each level when added to a log header. - private static readonly Dictionary LogStrings = Enum.GetValues().ToDictionary(level => level, level => level.ToString().ToUpper().PadRight(Monitor.MaxLevelLength)); + private static readonly Dictionary LogStrings = Enum.GetValues().ToDictionary(level => level, level => level.ToString().ToUpperInvariant().PadRight(Monitor.MaxLevelLength)); /// A cache of messages that should only be logged once. private readonly HashSet LogOnceCache = new(); @@ -40,6 +38,12 @@ internal class Monitor : IMonitor /********* ** Accessors *********/ + /// Whether to log basic contextual info (like buttons pressed and menus opened) even if is disabled. + public static bool ForceLogContext { get; set; } + + /// The current log level for contextual info that's relevant to the flag. + public static LogLevel ContextLogLevel => Monitor.ForceLogContext ? LogLevel.Info : LogLevel.Trace; + /// public bool IsVerbose { get; } @@ -58,12 +62,11 @@ internal class Monitor : IMonitor *********/ /// Construct an instance. /// The name of the module which logs messages using this instance. - /// 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. /// The log file to which to write messages. /// The colors to use for text written to the SMAPI console. /// Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed. /// Get the screen ID that should be logged to distinguish between players in split-screen mode, if any. - public Monitor(string source, char ignoreChar, LogFileManager logFile, ColorSchemeConfig colorConfig, bool isVerbose, Func getScreenIdForLog) + public Monitor(string source, LogFileManager logFile, ColorSchemeConfig colorConfig, bool isVerbose, Func getScreenIdForLog) { // validate if (string.IsNullOrWhiteSpace(source)) @@ -73,7 +76,6 @@ public Monitor(string source, char ignoreChar, LogFileManager logFile, ColorSche 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.IgnoreChar = ignoreChar; this.IsVerbose = isVerbose; this.GetScreenIdForLog = getScreenIdForLog; } @@ -98,6 +100,13 @@ public void VerboseLog(string message) this.Log(message); } + /// + public void VerboseLog([InterpolatedStringHandlerArgument("")] ref VerboseLogStringHandler message) + { + if (this.IsVerbose) + this.Log(message.ToString()); + } + /// Write a newline to the console and log file. internal void Newline() { @@ -139,7 +148,7 @@ private void LogImpl(string source, string message, ConsoleLogLevel level) // write to console if (this.WriteToConsole && (this.ShowTraceInConsole || level != ConsoleLogLevel.Trace)) - this.ConsoleWriter.WriteLine(this.IgnoreChar + consoleMessage, level); + this.ConsoleWriter.WriteLine(consoleMessage, level); // write to log file this.LogFile.WriteLine(fullMessage); diff --git a/src/SMAPI/Framework/Networking/SGalaxyNetClient.cs b/src/SMAPI/Framework/Networking/SGalaxyNetClient.cs deleted file mode 100644 index 01095c66b..000000000 --- a/src/SMAPI/Framework/Networking/SGalaxyNetClient.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using Galaxy.Api; -using StardewValley.Network; -using StardewValley.SDKs; - -namespace StardewModdingAPI.Framework.Networking -{ - /// A multiplayer client used to connect to a hosted server. This is an implementation of with callbacks for SMAPI functionality. - internal class SGalaxyNetClient : GalaxyNetClient - { - /********* - ** Fields - *********/ - /// A callback to raise when receiving a message. This receives the incoming message, a method to send an arbitrary message, and a callback to run the default logic. - private readonly Action, Action> OnProcessingMessage; - - /// A callback to raise when sending a message. This receives the outgoing message, a method to send an arbitrary message, and a callback to resume the default logic. - private readonly Action, Action> OnSendingMessage; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The remote address being connected. - /// A callback to raise when receiving a message. This receives the incoming message, a method to send an arbitrary message, and a callback to run the default logic. - /// A callback to raise when sending a message. This receives the outgoing message, a method to send an arbitrary message, and a callback to resume the default logic. - public SGalaxyNetClient(GalaxyID address, Action, Action> onProcessingMessage, Action, Action> onSendingMessage) - : base(address) - { - this.OnProcessingMessage = onProcessingMessage; - this.OnSendingMessage = onSendingMessage; - } - - /// Send a message to the connected peer. - public override void sendMessage(OutgoingMessage message) - { - this.OnSendingMessage(message, base.sendMessage, () => base.sendMessage(message)); - } - - - /********* - ** Protected methods - *********/ - /// Process an incoming network message. - /// The message to process. - protected override void processIncomingMessage(IncomingMessage message) - { - this.OnProcessingMessage(message, base.sendMessage, () => base.processIncomingMessage(message)); - } - } -} diff --git a/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs b/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs deleted file mode 100644 index 71e11576d..000000000 --- a/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using Galaxy.Api; -using StardewValley.Network; -using StardewValley.SDKs; - -namespace StardewModdingAPI.Framework.Networking -{ - /// A multiplayer server used to connect to an incoming player. This is an implementation of that adds support for SMAPI's metadata context exchange. - internal class SGalaxyNetServer : GalaxyNetServer - { - /********* - ** Fields - *********/ - /// A callback to raise when receiving a message. This receives the incoming message, a method to send a message, and a callback to run the default logic. - private readonly Action, Action> OnProcessingMessage; - - /// SMAPI's implementation of the game's core multiplayer logic. - private readonly SMultiplayer Multiplayer; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The underlying game server. - /// SMAPI's implementation of the game's core multiplayer logic. - /// A callback to raise when receiving a message. This receives the incoming message, a method to send a message, and a callback to run the default logic. - public SGalaxyNetServer(IGameServer gameServer, SMultiplayer multiplayer, Action, Action> onProcessingMessage) - : base(gameServer) - { - this.Multiplayer = multiplayer; - this.OnProcessingMessage = onProcessingMessage; - } - - - /********* - ** Protected methods - *********/ - /// Read and process a message from the client. - /// The Galaxy peer ID. - /// The data to process. - /// This reimplements , but adds a callback to . - [SuppressMessage("ReSharper", "AccessToDisposedClosure", Justification = "The callback is invoked synchronously.")] - protected override void onReceiveMessage(GalaxyID peer, Stream messageStream) - { - using IncomingMessage message = new(); - using BinaryReader reader = new(messageStream); - - message.Read(reader); - ulong peerID = peer.ToUint64(); // note: GalaxyID instances get reused, so need to store the underlying ID instead - this.OnProcessingMessage(message, outgoing => this.SendMessageToPeerID(peerID, outgoing), () => - { - if (this.peers.ContainsLeft(message.FarmerID) && (long)this.peers[message.FarmerID] == (long)peerID) - this.gameServer.processIncomingMessage(message); - else if (message.MessageType == StardewValley.Multiplayer.playerIntroduction) - { - NetFarmerRoot farmer = this.Multiplayer.readFarmer(message.Reader); - GalaxyID capturedPeer = new(peerID); - this.gameServer.checkFarmhandRequest(Convert.ToString(peerID), this.getConnectionId(peer), farmer, msg => this.sendMessage(capturedPeer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = capturedPeer.ToUint64()); - } - }); - } - - /// Send a message to a remote peer. - /// The unique Galaxy ID, derived from . - /// The message to send. - private void SendMessageToPeerID(ulong peerID, OutgoingMessage message) - { - this.sendMessage(new GalaxyID(peerID), message); - } - } -} diff --git a/src/SMAPI/Framework/Networking/SLidgrenClient.cs b/src/SMAPI/Framework/Networking/SLidgrenClient.cs deleted file mode 100644 index 398767448..000000000 --- a/src/SMAPI/Framework/Networking/SLidgrenClient.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using StardewValley.Network; - -namespace StardewModdingAPI.Framework.Networking -{ - /// A multiplayer client used to connect to a hosted server. This is an implementation of with callbacks for SMAPI functionality. - internal class SLidgrenClient : LidgrenClient - { - /********* - ** Fields - *********/ - /// A callback to raise when receiving a message. This receives the incoming message, a method to send an arbitrary message, and a callback to run the default logic. - private readonly Action, Action> OnProcessingMessage; - - /// A callback to raise when sending a message. This receives the outgoing message, a method to send an arbitrary message, and a callback to resume the default logic. - private readonly Action, Action> OnSendingMessage; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The remote address being connected. - /// A callback to raise when receiving a message. This receives the incoming message, a method to send an arbitrary message, and a callback to run the default logic. - /// A callback to raise when sending a message. This receives the outgoing message, a method to send an arbitrary message, and a callback to resume the default logic. - public SLidgrenClient(string address, Action, Action> onProcessingMessage, Action, Action> onSendingMessage) - : base(address) - { - this.OnProcessingMessage = onProcessingMessage; - this.OnSendingMessage = onSendingMessage; - } - - /// Send a message to the connected peer. - public override void sendMessage(OutgoingMessage message) - { - this.OnSendingMessage(message, base.sendMessage, () => base.sendMessage(message)); - } - - - /********* - ** Protected methods - *********/ - /// Process an incoming network message. - /// The message to process. - protected override void processIncomingMessage(IncomingMessage message) - { - this.OnProcessingMessage(message, base.sendMessage, () => base.processIncomingMessage(message)); - } - } -} diff --git a/src/SMAPI/Framework/Networking/SLidgrenServer.cs b/src/SMAPI/Framework/Networking/SLidgrenServer.cs deleted file mode 100644 index ff871e641..000000000 --- a/src/SMAPI/Framework/Networking/SLidgrenServer.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using Lidgren.Network; -using StardewValley.Network; - -namespace StardewModdingAPI.Framework.Networking -{ - /// A multiplayer server used to connect to an incoming player. This is an implementation of that adds support for SMAPI's metadata context exchange. - internal class SLidgrenServer : LidgrenServer - { - /********* - ** Fields - *********/ - /// SMAPI's implementation of the game's core multiplayer logic. - private readonly SMultiplayer Multiplayer; - - /// A callback to raise when receiving a message. This receives the incoming message, a method to send a message, and a callback to run the default logic. - private readonly Action, Action> OnProcessingMessage; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// SMAPI's implementation of the game's core multiplayer logic. - /// The underlying game server. - /// A callback to raise when receiving a message. This receives the incoming message, a method to send a message, and a callback to run the default logic. - public SLidgrenServer(IGameServer gameServer, SMultiplayer multiplayer, Action, Action> onProcessingMessage) - : base(gameServer) - { - this.Multiplayer = multiplayer; - this.OnProcessingMessage = onProcessingMessage; - } - - - /********* - ** Protected methods - *********/ - /// Parse a data message from a client. - /// The raw network message to parse. - [SuppressMessage("ReSharper", "AccessToDisposedClosure", Justification = "The callback is invoked synchronously.")] - protected override void parseDataMessageFromClient(NetIncomingMessage rawMessage) - { - // add hook to call multiplayer core - NetConnection peer = rawMessage.SenderConnection; - using IncomingMessage message = new(); - using Stream readStream = new NetBufferReadStream(rawMessage); - using BinaryReader reader = new(readStream); - - while (rawMessage.LengthBits - rawMessage.Position >= 8) - { - message.Read(reader); - NetConnection connection = rawMessage.SenderConnection; // don't pass rawMessage into context because it gets reused - this.OnProcessingMessage(message, outgoing => this.sendMessage(connection, outgoing), () => - { - if (this.peers.ContainsLeft(message.FarmerID) && this.peers[message.FarmerID] == peer) - this.gameServer.processIncomingMessage(message); - else if (message.MessageType == StardewValley.Multiplayer.playerIntroduction) - { - NetFarmerRoot farmer = this.Multiplayer.readFarmer(message.Reader); - this.gameServer.checkFarmhandRequest("", this.getConnectionId(rawMessage.SenderConnection), farmer, msg => this.sendMessage(peer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = peer); - } - }); - } - } - } -} diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index f83943e85..e82c8ceb8 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -5,12 +5,12 @@ using System.Linq; using System.Net; using System.Reflection; -using System.Runtime.ExceptionServices; using System.Security; using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; #if SMAPI_FOR_WINDOWS using Microsoft.Win32; #endif @@ -31,14 +31,9 @@ using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Rendering; using StardewModdingAPI.Framework.Serialization; -#if SMAPI_DEPRECATED -using StardewModdingAPI.Framework.StateTracking.Comparers; -#endif using StardewModdingAPI.Framework.StateTracking.Snapshots; using StardewModdingAPI.Framework.Utilities; using StardewModdingAPI.Internal; -using StardewModdingAPI.Internal.Patching; -using StardewModdingAPI.Patches; using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; using StardewModdingAPI.Toolkit.Framework.ModData; @@ -48,13 +43,13 @@ using StardewModdingAPI.Utilities; using StardewValley; using StardewValley.Menus; +using StardewValley.Mods; using StardewValley.Objects; using StardewValley.SDKs; using xTile.Display; using LanguageCode = StardewValley.LocalizedContentManager.LanguageCode; using MiniMonoModHotfix = MonoMod.Utils.MiniMonoModHotfix; using PathUtilities = StardewModdingAPI.Toolkit.Utilities.PathUtilities; -using SObject = StardewValley.Object; namespace StardewModdingAPI.Framework { @@ -144,17 +139,16 @@ internal class SCore : IDisposable /// The maximum number of consecutive attempts SMAPI should make to recover from an update error. private readonly Countdown UpdateCrashTimer = new(60); // 60 ticks = roughly one second -#if SMAPI_DEPRECATED - /// Asset interceptors added or removed since the last tick. - private readonly List ReloadAssetInterceptorsQueue = new(); -#endif - /// A list of queued commands to parse and execute. private readonly CommandQueue RawCommandQueue = new(); /// A list of commands to execute on each screen. private readonly PerScreen> ScreenCommandQueue = new(() => new List()); + /// The last for which display events were raised. + private readonly PerScreen LastRenderEventTick = new(); + + /********* ** Accessors *********/ @@ -163,7 +157,7 @@ internal class SCore : IDisposable internal static DeprecationManager DeprecationManager { get; private set; } = null!; // initialized in constructor, which happens before other code can access it /// The singleton instance. - /// This is only intended for use by external code like the Error Handler mod. + /// This is only intended for use by external code. internal static SCore Instance { get; private set; } = null!; // initialized in constructor, which happens before other code can access it /// The number of game update ticks which have already executed. This is similar to , but incremented more consistently for every tick. @@ -200,6 +194,8 @@ public SCore(string modsPath, bool writeToConsole, bool? developerMode) this.Settings = JsonConvert.DeserializeObject(File.ReadAllText(Constants.ApiConfigPath)) ?? throw new InvalidOperationException("The 'smapi-internal/config.json' file is missing or invalid. You can reinstall SMAPI to fix this."); if (File.Exists(Constants.ApiUserConfigPath)) JsonConvert.PopulateObject(File.ReadAllText(Constants.ApiUserConfigPath), this.Settings, deserializerSettings); + if (File.Exists(Constants.ApiModGroupConfigPath)) + JsonConvert.PopulateObject(File.ReadAllText(Constants.ApiModGroupConfigPath), this.Settings, deserializerSettings); if (developerMode.HasValue) this.Settings.OverrideDeveloperMode(developerMode.Value); } @@ -231,7 +227,7 @@ public SCore(string modsPath, bool writeToConsole, bool? developerMode) } /// Launch SMAPI. - [HandleProcessCorruptedStateExceptions, SecurityCritical] // let try..catch handle corrupted state exceptions + [SecurityCritical] public void RunInteractively() { // initialize SMAPI @@ -257,33 +253,35 @@ public void RunInteractively() LocalizedContentManager.OnLanguageChange += _ => this.OnLocaleChanged(); // override game - this.Multiplayer = new SMultiplayer(this.Monitor, this.EventManager, this.Toolkit.JsonHelper, this.ModRegistry, this.Reflection, this.OnModMessageReceived, this.Settings.LogNetworkTraffic); + this.Multiplayer = new SMultiplayer(this.Monitor, this.EventManager, this.Toolkit.JsonHelper, this.ModRegistry, this.OnModMessageReceived, this.Settings.LogNetworkTraffic); SGame.CreateContentManagerImpl = this.CreateContentManager; // must be static since the game accesses it before the SGame constructor is called this.Game = new SGameRunner( monitor: this.Monitor, reflection: this.Reflection, - eventManager: this.EventManager, modHooks: new SModHooks( parent: new ModHooks(), beforeNewDayAfterFade: this.OnNewDayAfterFade, + onStageChanged: this.OnLoadStageChanged, + onRenderingStep: this.OnRenderingStep, + onRenderedStep: this.OnRenderedStep, monitor: this.Monitor ), + gameLogger: new SGameLogger(this.GetMonitorForGame()), multiplayer: this.Multiplayer, exitGameImmediately: this.ExitGameImmediately, onGameContentLoaded: this.OnInstanceContentLoaded, + onLoadStageChanged: this.OnLoadStageChanged, onGameUpdating: this.OnGameUpdating, onPlayerInstanceUpdating: this.OnPlayerInstanceUpdating, + onPlayerInstanceRendered: this.OnRendered, onGameExiting: this.OnGameExiting ); - StardewValley.GameRunner.instance = this.Game; + GameRunner.instance = this.Game; - // apply game patches - MiniMonoModHotfix.Apply(); - HarmonyPatcher.Apply("SMAPI", this.Monitor, - new Game1Patcher(this.Reflection, this.OnLoadStageChanged), - new TitleMenuPatcher(this.OnLoadStageChanged) - ); + // fix Harmony for mods + if (this.Settings.FixHarmony) + MiniMonoModHotfix.Apply(); // set window titles this.UpdateWindowTitles(); @@ -320,7 +318,6 @@ public void RunInteractively() } /// Get the core logger and monitor on behalf of the game. - /// This method is called using reflection by the ErrorHandler mod to log game errors. [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Used via reflection")] public IMonitor GetMonitorForGame() { @@ -433,7 +430,7 @@ private void InitializeBeforeFirstAssetLoaded() mods = mods.Where(p => !p.IsIgnored).ToArray(); // validate manifests - resolver.ValidateManifests(mods, Constants.ApiVersion, toolkit.GetUpdateUrl, getFileLookup: this.GetFileLookup); + resolver.ValidateManifests(mods, Constants.ApiVersion, Constants.GameVersion, toolkit.GetUpdateUrl, getFileLookup: this.GetFileLookup); // apply load order customizations if (this.Settings.ModsToLoadEarly.Any() || this.Settings.ModsToLoadLate.Any()) @@ -472,10 +469,6 @@ private void InitializeBeforeFirstAssetLoaded() /// Raised after the game finishes initializing. private void OnGameInitialized() { - // 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 if (this.Settings.ListenForConsoleInput) { @@ -543,41 +536,6 @@ private void OnGameUpdating(GameTime gameTime, Action runGameUpdate) this.Monitor.LogOnce("A mod enabled Harmony debug mode, which impacts performance and creates a file on your desktop. SMAPI will try to keep it disabled. (You can allow debug mode by editing the smapi-internal/config.json file.)", LogLevel.Warn); } -#if SMAPI_DEPRECATED - /********* - ** Reload assets when interceptors are added/removed - *********/ - if (this.ReloadAssetInterceptorsQueue.Any()) - { - // get unique interceptors - AssetInterceptorChange[] interceptors = this.ReloadAssetInterceptorsQueue - .GroupBy(p => p.Instance, new ObjectReferenceComparer()) - .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))); - } -#endif - /********* ** Parse commands *********/ @@ -594,7 +552,7 @@ private void OnGameUpdating(GameTime gameTime, Action runGameUpdate) { if (!this.CommandManager.TryParse(rawInput, out name, out args, out command, out screenId)) { - this.Monitor.Log("Unknown command; type 'help' for a list of available commands.", LogLevel.Error); + this.Monitor.Log($"Unknown command '{(!string.IsNullOrWhiteSpace(name) ? name : rawInput)}'; type 'help' for a list of available commands.", LogLevel.Error); continue; } } @@ -700,8 +658,8 @@ private void OnPlayerInstanceUpdating(SGame instance, GameTime gameTime, Action bool saveParsed = false; if (Game1.currentLoader != null) { - this.Monitor.Log("Game loader synchronizing..."); - this.Reflection.GetMethod(Game1.game1, "UpdateTitleScreen").Invoke(Game1.currentGameTime); // run game logic to change music on load, etc + this.Monitor.Log("Game loader synchronizing...", Monitor.ContextLogLevel); + Game1.game1.UpdateTitleScreen(Game1.currentGameTime); // run game logic to change music on load, etc // ReSharper disable once ConstantConditionalAccessQualifier -- may become null within the loop while (Game1.currentLoader?.MoveNext() == true) { @@ -731,7 +689,7 @@ private void OnPlayerInstanceUpdating(SGame instance, GameTime gameTime, Action } Game1.currentLoader = null; - this.Monitor.Log("Game loader done."); + this.Monitor.Log("Game loader done.", Monitor.ContextLogLevel); } // While a background task is in progress, the game may make changes to the game @@ -763,7 +721,7 @@ private void OnPlayerInstanceUpdating(SGame instance, GameTime gameTime, Action if (!Context.IsWorldReady && !instance.IsBetweenCreateEvents) { instance.IsBetweenCreateEvents = true; - this.Monitor.Log("Context: before save creation."); + this.Monitor.Log("Context: before save creation.", Monitor.ContextLogLevel); events.SaveCreating.RaiseEmpty(); } @@ -771,7 +729,7 @@ private void OnPlayerInstanceUpdating(SGame instance, GameTime gameTime, Action if (Context.IsWorldReady && !instance.IsBetweenSaveEvents) { instance.IsBetweenSaveEvents = true; - this.Monitor.Log("Context: before save."); + this.Monitor.Log("Context: before save.", Monitor.ContextLogLevel); events.Saving.RaiseEmpty(); } @@ -818,7 +776,7 @@ private void OnPlayerInstanceUpdating(SGame instance, GameTime gameTime, Action { // raise after-create instance.IsBetweenCreateEvents = false; - this.Monitor.Log($"Context: after save creation, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}."); + this.Monitor.Log($"Context: after save creation, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.", Monitor.ContextLogLevel); this.OnLoadStageChanged(LoadStage.CreatedSaveFile); events.SaveCreated.RaiseEmpty(); } @@ -827,7 +785,7 @@ private void OnPlayerInstanceUpdating(SGame instance, GameTime gameTime, Action { // raise after-save instance.IsBetweenSaveEvents = false; - this.Monitor.Log($"Context: after save, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}."); + this.Monitor.Log($"Context: after save, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.", Monitor.ContextLogLevel); events.Saved.RaiseEmpty(); events.DayStarted.RaiseEmpty(); } @@ -836,7 +794,7 @@ private void OnPlayerInstanceUpdating(SGame instance, GameTime gameTime, Action ** Locale changed events *********/ if (state.Locale.IsChanged) - this.Monitor.Log($"Context: locale set to {state.Locale.New} ({this.ContentCore.GetLocaleCode(state.Locale.New)})."); + this.Monitor.Log($"Context: locale set to {state.Locale.New} ({this.ContentCore.GetLocaleCode(state.Locale.New)}).", Monitor.ContextLogLevel); /********* ** Load / return-to-title events @@ -855,7 +813,7 @@ private void OnPlayerInstanceUpdating(SGame instance, GameTime gameTime, Action else context += " Single-player."; - this.Monitor.Log(context); + this.Monitor.Log(context, Monitor.ContextLogLevel); // add context to window titles this.UpdateWindowTitles(); @@ -876,7 +834,7 @@ private void OnPlayerInstanceUpdating(SGame instance, GameTime gameTime, Action if (state.WindowSize.IsChanged) { if (verbose) - this.Monitor.Log($"Events: window size changed to {state.WindowSize.New}."); + this.Monitor.Log($"Events: window size changed to {state.WindowSize.New}.", Monitor.ContextLogLevel); if (events.WindowResized.HasListeners) events.WindowResized.Raise(new WindowResizedEventArgs(state.WindowSize.Old, state.WindowSize.New)); @@ -915,24 +873,25 @@ private void OnPlayerInstanceUpdating(SGame instance, GameTime gameTime, Action bool raisePressed = events.ButtonPressed.HasListeners; bool raiseReleased = events.ButtonReleased.HasListeners; + bool logInput = verbose || Monitor.ForceLogContext; - if (verbose || raisePressed || raiseReleased) + if (logInput || raisePressed || raiseReleased) { foreach ((SButton button, SButtonState status) in inputState.ButtonStates) { switch (status) { case SButtonState.Pressed: - if (verbose) - this.Monitor.Log($"Events: button {button} pressed."); + if (logInput) + this.Monitor.Log($"Events: button {button} pressed.", Monitor.ContextLogLevel); if (raisePressed) events.ButtonPressed.Raise(new ButtonPressedEventArgs(button, cursor, inputState)); break; case SButtonState.Released: - if (verbose) - this.Monitor.Log($"Events: button {button} released."); + if (logInput) + this.Monitor.Log($"Events: button {button} released.", Monitor.ContextLogLevel); if (raiseReleased) events.ButtonReleased.Raise(new ButtonReleasedEventArgs(button, cursor, inputState)); @@ -952,8 +911,8 @@ private void OnPlayerInstanceUpdating(SGame instance, GameTime gameTime, Action IClickableMenu? was = state.ActiveMenu.Old; IClickableMenu? now = state.ActiveMenu.New; - if (verbose) - this.Monitor.Log($"Context: menu changed from {was?.GetType().FullName ?? "none"} to {now?.GetType().FullName ?? "none"}."); + if (verbose || Monitor.ForceLogContext) + this.Monitor.Log($"Context: menu changed from {was?.GetType().FullName ?? "none"} to {now?.GetType().FullName ?? "none"}.", Monitor.ContextLogLevel); // raise menu events if (events.MenuChanged.HasListeners) @@ -977,7 +936,7 @@ private void OnPlayerInstanceUpdating(SGame instance, GameTime gameTime, Action { 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})."); + this.Monitor.Log($"Context: location list changed (added {addedText}; removed {removedText}).", Monitor.ContextLogLevel); } if (events.LocationListChanged.HasListeners) @@ -1032,7 +991,7 @@ private void OnPlayerInstanceUpdating(SGame instance, GameTime gameTime, Action if (raiseWorldEvents && state.Time.IsChanged) { if (verbose) - this.Monitor.Log($"Context: time changed to {state.Time.New}."); + this.Monitor.Log($"Context: time changed to {state.Time.New}.", Monitor.ContextLogLevel); if (events.TimeChanged.HasListeners) events.TimeChanged.Raise(new TimeChangedEventArgs(state.Time.Old, state.Time.New)); @@ -1048,7 +1007,7 @@ private void OnPlayerInstanceUpdating(SGame instance, GameTime gameTime, Action if (playerState.Location.IsChanged) { if (verbose) - this.Monitor.Log($"Context: set location to {playerState.Location.New}."); + this.Monitor.Log($"Context: set location to {playerState.Location.New}.", Monitor.ContextLogLevel); if (events.Warped.HasListeners) events.Warped.Raise(new WarpedEventArgs(player, playerState.Location.Old!, playerState.Location.New!)); @@ -1064,7 +1023,7 @@ private void OnPlayerInstanceUpdating(SGame instance, GameTime gameTime, Action continue; if (verbose) - this.Monitor.Log($"Events: player skill '{skill}' changed from {value.Old} to {value.New}."); + this.Monitor.Log($"Events: player skill '{skill}' changed from {value.Old} to {value.New}.", Monitor.ContextLogLevel); if (raiseLevelChanged) events.LevelChanged.Raise(new LevelChangedEventArgs(player, skill, value.Old, value.New)); @@ -1075,7 +1034,7 @@ private void OnPlayerInstanceUpdating(SGame instance, GameTime gameTime, Action if (playerState.Inventory.IsChanged) { if (verbose) - this.Monitor.Log("Events: player inventory changed."); + this.Monitor.Log("Events: player inventory changed.", Monitor.ContextLogLevel); if (events.InventoryChanged.HasListeners) { @@ -1199,13 +1158,14 @@ internal void OnLoadStageChanged(LoadStage newStage) // update data LoadStage oldStage = Context.LoadStage; Context.LoadStage = newStage; - this.Monitor.VerboseLog($"Context: load stage changed to {newStage}"); + if (this.Monitor.IsVerbose || Monitor.ForceLogContext) + this.Monitor.Log($"Context: load stage changed to {newStage}", Monitor.ContextLogLevel); // handle stages switch (newStage) { case LoadStage.ReturningToTitle: - this.Monitor.Log("Context: returning to title"); + this.Monitor.Log("Context: returning to title", Monitor.ContextLogLevel); this.OnReturningToTitle(); break; @@ -1229,6 +1189,133 @@ internal void OnLoadStageChanged(LoadStage newStage) events.ReturnedToTitle.RaiseEmpty(); } + /// Raised when the game starts a render step in the draw loop. + /// The render step being started. + /// The sprite batch being drawn (which might not always be open yet). + /// The render target being drawn. + private void OnRenderingStep(RenderSteps step, SpriteBatch spriteBatch, RenderTarget2D renderTarget) + { + EventManager events = this.EventManager; + + // raise 'Rendering' before first event + if (this.LastRenderEventTick.Value != SCore.TicksElapsed) + { + this.RaiseRenderEvent(events.Rendering, spriteBatch, renderTarget); + this.LastRenderEventTick.Value = SCore.TicksElapsed; + } + + // raise other events + switch (step) + { + case RenderSteps.World: + this.RaiseRenderEvent(events.RenderingWorld, spriteBatch, renderTarget); + break; + + case RenderSteps.Menu: + this.RaiseRenderEvent(events.RenderingActiveMenu, spriteBatch, renderTarget); + break; + + case RenderSteps.HUD: + this.RaiseRenderEvent(events.RenderingHud, spriteBatch, renderTarget); + break; + } + + // raise generic rendering stage event + if (events.RenderingStep.HasListeners) + this.RaiseRenderEvent(events.RenderingStep, spriteBatch, renderTarget, RenderingStepEventArgs.Instance(step)); + } + + /// Raised when the game finishes a render step in the draw loop. + /// The render step being started. + /// The sprite batch being drawn (which might not always be open yet). + /// The render target being drawn. + private void OnRenderedStep(RenderSteps step, SpriteBatch spriteBatch, RenderTarget2D renderTarget) + { + var events = this.EventManager; + + switch (step) + { + case RenderSteps.World: + this.RaiseRenderEvent(events.RenderedWorld, spriteBatch, renderTarget); + break; + + case RenderSteps.Menu: + this.RaiseRenderEvent(events.RenderedActiveMenu, spriteBatch, renderTarget); + break; + + case RenderSteps.HUD: + this.RaiseRenderEvent(events.RenderedHud, spriteBatch, renderTarget); + break; + } + + // raise generic rendering stage event + if (events.RenderedStep.HasListeners) + this.RaiseRenderEvent(events.RenderedStep, spriteBatch, renderTarget, RenderedStepEventArgs.Instance(step)); + } + + /// Raised after an instance finishes a draw loop. + /// The render target being drawn to the screen. + private void OnRendered(RenderTarget2D renderTarget) + { + this.RaiseRenderEvent(this.EventManager.Rendered, Game1.spriteBatch, renderTarget); + } + + /// Raise a rendering/rendered event, temporarily opening the given sprite batch if needed to let mods draw to it. + /// The event args type to construct. + /// The event to raise. + /// The sprite batch being drawn to the screen. + /// The render target being drawn to the screen. + private void RaiseRenderEvent(ManagedEvent @event, SpriteBatch spriteBatch, RenderTarget2D renderTarget) + where TEventArgs : EventArgs, new() + { + this.RaiseRenderEvent(@event, spriteBatch, renderTarget, Singleton.Instance); + } + + /// Raise a rendering/rendered event, temporarily opening the given sprite batch if needed to let mods draw to it. + /// The event args type to construct. + /// The event to raise. + /// The sprite batch being drawn to the screen. + /// The render target being drawn to the screen. + /// The event arguments to pass to the event. + private void RaiseRenderEvent(ManagedEvent @event, SpriteBatch spriteBatch, RenderTarget2D renderTarget, TEventArgs eventArgs) + where TEventArgs : EventArgs + { + if (!@event.HasListeners) + return; + + bool wasOpen = spriteBatch.IsOpen(this.Reflection); + bool hadRenderTarget = Game1.graphics.GraphicsDevice.RenderTargetCount > 0; + + if (!hadRenderTarget && !Game1.IsOnMainThread()) + return; // can't set render target on background thread + + try + { + if (!wasOpen) + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp); + + if (!hadRenderTarget) + { + renderTarget ??= Game1.game1.uiScreen?.IsDisposed != true + ? Game1.game1.uiScreen + : Game1.nonUIRenderTarget; + + if (renderTarget != null) + Game1.SetRenderTarget(renderTarget); + } + + @event.Raise(eventArgs); + } + finally + { + if (!wasOpen) + spriteBatch.End(); + + if (!hadRenderTarget && renderTarget != null) + Game1.SetRenderTarget(null); + } + } + /// A callback invoked before runs. protected void OnNewDayAfterFade() { @@ -1398,61 +1485,6 @@ private SGame GetCurrentGameInstance() ?? throw new InvalidOperationException("The current game instance wasn't created by SMAPI."); } - /// Look for common issues with the game's XNB content, and log warnings if anything looks broken or outdated. - /// Returns whether all integrity checks passed. - private bool ValidateContentIntegrity() - { - 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})."); - foreach ((int id, string? fieldsStr) in Game1.objectInformation) - { - // must not be empty - if (string.IsNullOrWhiteSpace(fieldsStr)) - { - LogIssue(id, "entry is empty"); - hasObjectIssues = true; - continue; - } - - // require core fields - string[] fields = fieldsStr.Split('/'); - if (fields.Length < SObject.objectInfoDescriptionIndex + 1) - { - LogIssue(id, "too few fields for an object"); - hasObjectIssues = true; - continue; - } - - // check min length for specific types - switch (fields[SObject.objectInfoTypeIndex].Split(' ', 2)[0]) - { - case "Cooking": - if (fields.Length < SObject.objectInfoBuffDurationIndex + 1) - { - LogIssue(id, "too few fields for a cooking item"); - hasObjectIssues = true; - } - break; - } - } - - // log error - if (hasObjectIssues) - { - issuesFound = true; - this.Monitor.Log(@"Your Content\Data\ObjectInformation.xnb file seems to be broken or outdated.", LogLevel.Warn); - } - } - - return !issuesFound; - } - /// Set the titles for the game and console windows. private void UpdateWindowTitles() { @@ -1687,7 +1719,7 @@ private void LoadMods(IModMetadata[] mods, JsonHelper jsonHelper, ContentCoordin // load mods IList skippedMods = new List(); - using (AssemblyLoader modAssemblyLoader = new(Constants.Platform, this.Monitor, this.Settings.ParanoidWarnings, this.Settings.RewriteMods)) + using (AssemblyLoader modAssemblyLoader = new(Constants.Platform, this.Monitor, this.Settings.ParanoidWarnings, this.Settings.RewriteMods, this.Settings.LogTechnicalDetailsForBrokenMods)) { // init HashSet suppressUpdateChecks = this.Settings.SuppressUpdateChecks; @@ -1712,24 +1744,11 @@ private void LoadMods(IModMetadata[] mods, JsonHelper jsonHelper, ContentCoordin this.ModRegistry.AreAllModsLoaded = true; // log mod info - this.LogManager.LogModInfo(loaded, loadedContentPacks, loadedMods, skippedMods.ToArray(), this.Settings.ParanoidWarnings); + this.LogManager.LogModInfo(loaded, loadedContentPacks, loadedMods, skippedMods.ToArray(), this.Settings.ParanoidWarnings, this.Settings.LogTechnicalDetailsForBrokenMods, this.Settings.FixHarmony); // initialize translations this.ReloadTranslations(loaded); - // set temporary PyTK compatibility mode - // This is part of a three-part fix for PyTK 1.23.* and earlier. When removing this, - // search 'Platonymous.Toolkit' to find the other part in SMAPI and Content Patcher. - { - IModInfo? pyTk = this.ModRegistry.Get("Platonymous.Toolkit"); - if (pyTk is not null && pyTk.Manifest.Version.IsOlderThan("1.24.0")) -#if SMAPI_DEPRECATED - ModContentManager.EnablePyTkLegacyMode = true; -#else - this.Monitor.Log("PyTK's image scaling is not compatible with SMAPI strict mode.", LogLevel.Warn); -#endif - } - // initialize loaded non-content-pack mods this.Monitor.Log("Launching mods...", LogLevel.Debug); foreach (IModMetadata metadata in loadedMods) @@ -1738,69 +1757,6 @@ private void LoadMods(IModMetadata[] mods, JsonHelper jsonHelper, ContentCoordin metadata.Mod ?? throw new InvalidOperationException($"The '{metadata.DisplayName}' mod is not initialized correctly."); // should never happen, but avoids nullability warnings -#if SMAPI_DEPRECATED - // add interceptors - if (mod.Helper is ModHelper helper) - { - // ReSharper disable SuspiciousTypeConversion.Global - if (mod is IAssetEditor editor) - { - SCore.DeprecationManager.Warn( - source: metadata, - nounPhrase: $"{nameof(IAssetEditor)}", - version: "3.14.0", - severity: DeprecationLevel.PendingRemoval, - logStackTrace: false - ); - - this.ContentCore.Editors.Add(new ModLinked(metadata, editor)); - } - - if (mod is IAssetLoader loader) - { - SCore.DeprecationManager.Warn( - source: metadata, - nounPhrase: $"{nameof(IAssetLoader)}", - version: "3.14.0", - severity: DeprecationLevel.PendingRemoval, - logStackTrace: false - ); - - this.ContentCore.Loaders.Add(new ModLinked(metadata, loader)); - } - // ReSharper restore SuspiciousTypeConversion.Global - - ContentHelper content = helper.GetLegacyContentHelper(); - content.ObservableAssetEditors.CollectionChanged += (_, e) => this.OnAssetInterceptorsChanged(metadata, e.NewItems?.Cast(), e.OldItems?.Cast(), this.ContentCore.Editors); - content.ObservableAssetLoaders.CollectionChanged += (_, e) => this.OnAssetInterceptorsChanged(metadata, e.NewItems?.Cast(), e.OldItems?.Cast(), this.ContentCore.Loaders); - } - - // log deprecation warnings - if (metadata.HasWarnings(ModWarning.DetectedLegacyCachingDll, ModWarning.DetectedLegacyConfigurationDll, ModWarning.DetectedLegacyPermissionsDll)) - { - string?[] referenced = - new[] - { - metadata.Warnings.HasFlag(ModWarning.DetectedLegacyConfigurationDll) ? "System.Configuration.ConfigurationManager" : null, - metadata.Warnings.HasFlag(ModWarning.DetectedLegacyCachingDll) ? "System.Runtime.Caching" : null, - metadata.Warnings.HasFlag(ModWarning.DetectedLegacyPermissionsDll) ? "System.Security.Permissions" : null - } - .Where(p => p is not null) - .ToArray(); - - foreach (string? name in referenced) - { - DeprecationManager.Warn( - metadata, - $"using {name} without bundling it", - "3.14.7", - DeprecationLevel.PendingRemoval, - logStackTrace: false - ); - } - } -#endif - // initialize mod Context.HeuristicModsRunningCode.Push(metadata); { @@ -1846,31 +1802,6 @@ private void LoadMods(IModMetadata[] mods, JsonHelper jsonHelper, ContentCoordin this.Monitor.Log("Mods loaded and ready!", LogLevel.Debug); } -#if SMAPI_DEPRECATED - /// Raised after a mod adds or removes asset interceptors. - /// The asset interceptor type (one of or ). - /// The mod metadata. - /// The interceptors that were added. - /// The interceptors that were removed. - /// A list of interceptors to update for the change. - private void OnAssetInterceptorsChanged(IModMetadata mod, IEnumerable? added, IEnumerable? removed, IList> list) - where T : notnull - { - foreach (T interceptor in added ?? Array.Empty()) - { - this.ReloadAssetInterceptorsQueue.Add(new AssetInterceptorChange(mod, interceptor, wasAdded: true)); - list.Add(new ModLinked(mod, interceptor)); - } - - foreach (T interceptor in removed ?? Array.Empty()) - { - this.ReloadAssetInterceptorsQueue.Add(new AssetInterceptorChange(mod, interceptor, wasAdded: false)); - foreach (ModLinked entry in list.Where(p => p.Mod == mod && object.ReferenceEquals(p.Data, interceptor)).ToArray()) - list.Remove(entry); - } - } -#endif - /// Load a given mod. /// The mod to load. /// The mods being loaded. @@ -1882,7 +1813,7 @@ private void OnAssetInterceptorsChanged(IModMetadata mod, IEnumerable? add /// The mod IDs to ignore when validating update keys. /// The reason the mod couldn't be loaded, if applicable. /// The user-facing reason phrase explaining why the mod couldn't be loaded (if applicable). - /// More detailed details about the error intended for developers (if any). + /// More detailed info about the error intended for developers (if any). /// Returns whether the mod was successfully loaded. private bool TryLoadMod(IModMetadata mod, IModMetadata[] mods, AssemblyLoader assemblyLoader, IInterfaceProxyFactory proxyFactory, JsonHelper jsonHelper, ContentCoordinator contentCore, ModDatabase modDatabase, HashSet suppressUpdateChecks, [NotNullWhen(false)] out ModFailReason? failReason, out string? errorReasonPhrase, out string? errorDetails) { @@ -1892,12 +1823,12 @@ private bool TryLoadMod(IModMetadata mod, IModMetadata[] mods, AssemblyLoader as { string relativePath = mod.GetRelativePathWithRoot(); if (mod.IsContentPack) - this.Monitor.Log($" {mod.DisplayName} (from {relativePath}) [content pack]..."); + this.Monitor.Log($" {mod.DisplayName} (from {relativePath}, ID: {mod.Manifest.UniqueID}) [content pack]..."); // ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract -- mod may be invalid at this point else if (mod.Manifest?.EntryDll != null) - this.Monitor.Log($" {mod.DisplayName} (from {relativePath}{Path.DirectorySeparatorChar}{mod.Manifest.EntryDll})..."); // don't use Path.Combine here, since EntryDLL might not be valid + this.Monitor.Log($" {mod.DisplayName} (from {relativePath}{Path.DirectorySeparatorChar}{mod.Manifest.EntryDll}, ID: {mod.Manifest.UniqueID})..."); // don't use Path.Combine here, since EntryDLL might not be valid else - this.Monitor.Log($" {mod.DisplayName} (from {relativePath})..."); + this.Monitor.Log($" {mod.DisplayName} (from {relativePath}, ID: {mod.Manifest?.UniqueID ?? ""})..."); } // add warning for missing update key @@ -1918,15 +1849,21 @@ private bool TryLoadMod(IModMetadata mod, IModMetadata[] mods, AssemblyLoader as // Although dependencies are validated before mods are loaded, a dependency may have failed to load. foreach (IManifestDependency dependency in manifest.Dependencies.Where(p => p.IsRequired)) { - if (this.ModRegistry.Get(dependency.UniqueID) == null) - { - string dependencyName = mods - .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; - } + // not missing + if (this.ModRegistry.Get(dependency.UniqueID) != null) + continue; + + // ignored in compatibility list (e.g. fully replaced by the game code) + if (modDatabase.Get(dependency.UniqueID)?.IgnoreDependencies is true) + continue; + + // mark failed + string dependencyName = mods + .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; } // load as content pack @@ -2013,9 +1950,6 @@ IContentPack[] GetContentPacks() { IModEvents events = new ModEvents(mod, this.EventManager); ICommandHelper commandHelper = new CommandHelper(mod, this.CommandManager); -#if SMAPI_DEPRECATED - ContentHelper contentHelper = new(contentCore, mod.DirectoryPath, mod, monitor, this.Reflection); -#endif GameContentHelper gameContentHelper = new(contentCore, mod, mod.DisplayName, monitor, this.Reflection); IModContentHelper modContentHelper = new ModContentHelper(contentCore, mod.DirectoryPath, mod, mod.DisplayName, gameContentHelper.GetUnderlyingContentManager(), this.Reflection); IContentPackHelper contentPackHelper = new ContentPackHelper( @@ -2028,11 +1962,7 @@ IContentPack[] GetContentPacks() IModRegistry modRegistryHelper = new ModRegistryHelper(mod, this.ModRegistry, proxyFactory, monitor); IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(mod, this.Multiplayer); - modHelper = new ModHelper(mod, mod.DirectoryPath, () => this.GetCurrentGameInstance().Input, events, -#if SMAPI_DEPRECATED - contentHelper, -#endif - gameContentHelper, modContentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper); + modHelper = new ModHelper(mod, mod.DirectoryPath, () => this.GetCurrentGameInstance().Input, events, gameContentHelper, modContentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper); } // init mod diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index feb0988a1..a67b8d05b 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -1,11 +1,10 @@ using System; using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; -using System.Text; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Enums; using StardewModdingAPI.Events; -using StardewModdingAPI.Framework.Events; using StardewModdingAPI.Framework.Input; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.StateTracking.Snapshots; @@ -13,13 +12,9 @@ using StardewModdingAPI.Internal; using StardewModdingAPI.Utilities; using StardewValley; -using StardewValley.BellsAndWhistles; -using StardewValley.Locations; +using StardewValley.Logging; using StardewValley.Menus; -using StardewValley.Tools; -using xTile.Dimensions; -using xTile.Layers; -using xTile.Tiles; +using StardewValley.Minigames; namespace StardewModdingAPI.Framework { @@ -32,9 +27,6 @@ internal class SGame : Game1 /// Encapsulates monitoring and logging for SMAPI. private readonly Monitor Monitor; - /// Manages SMAPI events for mods. - private readonly EventManager Events; - /// The maximum number of consecutive attempts SMAPI should make to recover from a draw error. private readonly Countdown DrawCrashTimer = new(60); // 60 ticks = roughly one second @@ -56,6 +48,12 @@ internal class SGame : Game1 /// Raised after the instance finishes loading its initial content. private readonly Action OnContentLoaded; + /// Raised invoke when the load stage changes through a method like . + private readonly Action OnLoadStageChanged; + + /// Raised after the instance finishes a draw loop. + private readonly Action OnRendered; + /********* ** Accessors @@ -99,14 +97,16 @@ internal class SGame : Game1 /// The instance index. /// Encapsulates monitoring and logging for SMAPI. /// Simplifies access to private game code. - /// Manages SMAPI events for mods. /// Manages the game's input state. /// Handles mod hooks provided by the game. + /// The game log output handler. /// The core multiplayer logic. /// 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. /// Raised when the instance is updating its state (roughly 60 times per second). /// Raised after the game finishes loading its initial content. - public SGame(PlayerIndex playerIndex, int instanceIndex, Monitor monitor, Reflector reflection, EventManager eventManager, SInputState input, SModHooks modHooks, SMultiplayer multiplayer, Action exitGameImmediately, Action onUpdating, Action onContentLoaded) + /// Raised invoke when the load stage changes through a method like . + /// Raised after the instance finishes a draw loop. + public SGame(PlayerIndex playerIndex, int instanceIndex, Monitor monitor, Reflector reflection, SInputState input, SModHooks modHooks, IGameLogger gameLogger, SMultiplayer multiplayer, Action exitGameImmediately, Action onUpdating, Action onContentLoaded, Action onLoadStageChanged, Action onRendered) : base(playerIndex, instanceIndex) { // init XNA @@ -114,17 +114,19 @@ public SGame(PlayerIndex playerIndex, int instanceIndex, Monitor monitor, Reflec // hook into game Game1.input = this.InitialInput = input; + Game1.log = gameLogger; Game1.multiplayer = this.InitialMultiplayer = multiplayer; Game1.hooks = modHooks; this._locations = new ObservableCollection(); // init SMAPI this.Monitor = monitor; - this.Events = eventManager; this.Reflection = reflection; this.ExitGameImmediately = exitGameImmediately; this.OnUpdating = onUpdating; this.OnContentLoaded = onContentLoaded; + this.OnLoadStageChanged = onLoadStageChanged; + this.OnRendered = onRendered; } /// Get the current input state for a button. @@ -152,7 +154,7 @@ protected override void LoadContent() /// Construct a content manager to read game content files. /// The service provider to use to locate services. /// The root directory to search for content. - protected override LocalizedContentManager CreateContentManager(IServiceProvider serviceProvider, string rootDirectory) + protected internal override LocalizedContentManager CreateContentManager(IServiceProvider serviceProvider, string rootDirectory) { if (SGame.CreateContentManagerImpl == null) throw new InvalidOperationException($"The {nameof(SGame)}.{nameof(SGame.CreateContentManagerImpl)} must be set."); @@ -168,12 +170,28 @@ protected override void Initialize() // The game resets public static fields after the class is constructed (see GameRunner.SetInstanceDefaults), so SMAPI needs to re-override them here. Game1.input = this.InitialInput; Game1.multiplayer = this.InitialMultiplayer; + if (this.IsMainInstance) + TitleMenu.OnCreatedNewCharacter += () => this.OnLoadStageChanged(LoadStage.CreatedBasicInfo); // event is static and shared between screens // The Initial* fields should no longer be used after this point, since mods may further override them after initialization. this.InitialInput = null; this.InitialMultiplayer = null; } + /// The method called when loading or creating a save. + /// Whether this is being called from the game's load enumerator. + public override void loadForNewGame(bool loadedGame = false) + { + base.loadForNewGame(loadedGame); + + bool isCreating = + (Game1.currentMinigame is Intro) // creating save with intro + || (Game1.activeClickableMenu is TitleMenu menu && menu.transitioningCharacterCreationMenu); // creating save, skipped intro + + if (isCreating) + this.OnLoadStageChanged(LoadStage.CreatedLocations); + } + /// The method called when the instance is updating its state (roughly 60 times per second). /// A snapshot of the game timing state. protected override void Update(GameTime gameTime) @@ -206,13 +224,14 @@ protected override void _draw(GameTime gameTime, RenderTarget2D target_screen) Context.IsInDrawLoop = true; try { - this.DrawImpl(gameTime, target_screen); + base._draw(gameTime, target_screen); + this.OnRendered(target_screen); this.DrawCrashTimer.Reset(); } catch (Exception ex) { // log error - this.Monitor.Log($"An error occurred in the overridden draw loop: {ex.GetLogSummary()}", LogLevel.Error); + this.Monitor.Log($"An error occurred in the game's draw loop: {ex.GetLogSummary()}", LogLevel.Error); // exit if irrecoverable if (!this.DrawCrashTimer.Decrement()) @@ -242,713 +261,12 @@ protected override void _draw(GameTime gameTime, RenderTarget2D target_screen) Context.IsInDrawLoop = false; } -#nullable disable - /// Replicate the game's draw logic with some changes for SMAPI. - /// A snapshot of the game timing state. - /// The render target, if any. - /// This implementation is identical to , except for try..catch around menu draw code, private field references replaced by wrappers, and added events. - [SuppressMessage("ReSharper", "CompareOfFloatsByEqualityOperator", Justification = "copied from game code as-is")] - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "copied from game code as-is")] - [SuppressMessage("ReSharper", "LocalVariableHidesMember", Justification = "copied from game code as-is")] - [SuppressMessage("ReSharper", "MergeIntoPattern", Justification = "copied from game code as-is")] - [SuppressMessage("ReSharper", "PossibleLossOfFraction", Justification = "copied from game code as-is")] - [SuppressMessage("ReSharper", "PossibleNullReferenceException", Justification = "copied from game code as-is")] - [SuppressMessage("ReSharper", "RedundantArgumentDefaultValue", Justification = "copied from game code as-is")] - [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = "copied from game code as-is")] - [SuppressMessage("ReSharper", "RedundantCast", Justification = "copied from game code as-is")] - [SuppressMessage("ReSharper", "RedundantExplicitNullableCreation", Justification = "copied from game code as-is")] - [SuppressMessage("ReSharper", "RedundantTypeArgumentsOfMethod", Justification = "copied from game code as-is")] - [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "copied from game code as-is")] - [SuppressMessage("ReSharper", "MergeIntoPattern", Justification = "copied from game code as-is")] - [SuppressMessage("SMAPI.CommonErrors", "AvoidImplicitNetFieldCast", Justification = "copied from game code as-is")] - [SuppressMessage("SMAPI.CommonErrors", "AvoidNetField", Justification = "copied from game code as-is")] - - [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalse", Justification = "Deliberate to minimize chance of errors when copying event calls into new versions of this code.")] - private void DrawImpl(GameTime gameTime, RenderTarget2D target_screen) + /// The method called when the game is returning to the title screen from a loaded save. + public override void CleanupReturningToTitle() { - var events = this.Events; - - Game1.showingHealthBar = false; - if (Game1._newDayTask != null || this.isLocalMultiplayerNewDayActive) - { - base.GraphicsDevice.Clear(Game1.bgColor); - return; - } - if (target_screen != null) - { - Game1.SetRenderTarget(target_screen); - } - if (this.IsSaving) - { - base.GraphicsDevice.Clear(Game1.bgColor); - Game1.PushUIMode(); - IClickableMenu menu = Game1.activeClickableMenu; - if (menu != null) - { - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp); - 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); - Game1.overlayMenu.draw(Game1.spriteBatch); - Game1.spriteBatch.End(); - } - Game1.PopUIMode(); - return; - } - base.GraphicsDevice.Clear(Game1.bgColor); - if (Game1.activeClickableMenu != null && Game1.options.showMenuBackground && Game1.activeClickableMenu.showWithoutTransparencyIfOptionIsSet() && !this.takingMapScreenshot) - { - Game1.PushUIMode(); - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp); + this.OnLoadStageChanged(LoadStage.ReturningToTitle); - events.Rendering.RaiseEmpty(); - IClickableMenu curMenu = null; - try - { - Game1.activeClickableMenu.drawBackground(Game1.spriteBatch); - events.RenderingActiveMenu.RaiseEmpty(); - for (curMenu = Game1.activeClickableMenu; curMenu != null; curMenu = curMenu.GetChildMenu()) - { - curMenu.draw(Game1.spriteBatch); - } - events.RenderedActiveMenu.RaiseEmpty(); - } - catch (Exception ex) - { - this.Monitor.Log($"The {curMenu.GetMenuChainLabel()} 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(); - if (Game1.specialCurrencyDisplay != null) - { - Game1.specialCurrencyDisplay.Draw(Game1.spriteBatch); - } - Game1.spriteBatch.End(); - this.drawOverlays(Game1.spriteBatch); - Game1.PopUIMode(); - return; - } - if (Game1.gameMode == 11) - { - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp); - 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.PushUIMode(); - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp); - Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * ((Game1.gameMode == 0) ? (1f - Game1.fadeToBlackAlpha) : Game1.fadeToBlackAlpha)); - Game1.spriteBatch.End(); - Game1.PopUIMode(); - } - Game1.PushUIMode(); - this.drawOverlays(Game1.spriteBatch); - Game1.PopUIMode(); - if (events.Rendered.HasListeners) - { - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); - events.Rendered.RaiseEmpty(); - Game1.spriteBatch.End(); - } - Game1.SetRenderTarget(target_screen); - return; - } - if (Game1.showingEndOfNightStuff) - { - Game1.PushUIMode(); - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp); - events.Rendering.RaiseEmpty(); - if (Game1.activeClickableMenu != null) - { - IClickableMenu curMenu = null; - try - { - events.RenderingActiveMenu.RaiseEmpty(); - for (curMenu = Game1.activeClickableMenu; curMenu != null; curMenu = curMenu.GetChildMenu()) - { - curMenu.draw(Game1.spriteBatch); - } - events.RenderedActiveMenu.RaiseEmpty(); - } - catch (Exception ex) - { - this.Monitor.Log($"The {curMenu.GetMenuChainLabel()} menu crashed while drawing itself. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); - Game1.activeClickableMenu.exitThisMenu(); - } - } - Game1.spriteBatch.End(); - this.drawOverlays(Game1.spriteBatch); - Game1.PopUIMode(); - return; - } - if (Game1.gameMode == 6 || (Game1.gameMode == 3 && Game1.currentLocation == null)) - { - Game1.PushUIMode(); - base.GraphicsDevice.Clear(Game1.bgColor); - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp); - events.Rendering.RaiseEmpty(); - string addOn = ""; - for (int i = 0; (double)i < gameTime.TotalGameTime.TotalMilliseconds % 999.0 / 333.0; i++) - { - addOn += "."; - } - string text = Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3688"); - string msg = text + addOn; - string largestMessage = text + "... "; - 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); - Game1.PopUIMode(); - return; - } - - byte batchOpens = 0; // used for rendering event - if (Game1.gameMode == 0) - { - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp); - if (++batchOpens == 1) - events.Rendering.RaiseEmpty(); - } - else - { - if (Game1.gameMode == 3 && Game1.dayOfMonth == 0 && Game1.newDay) - { - //base.Draw(gameTime); - return; - } - if (Game1.drawLighting) - { - Game1.SetRenderTarget(Game1.lightmap); - base.GraphicsDevice.Clear(Color.White * 0f); - Matrix lighting_matrix = Matrix.Identity; - if (this.useUnscaledLighting) - { - lighting_matrix = Matrix.CreateScale(Game1.options.zoomLevel); - } - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, null, null, null, lighting_matrix); - if (++batchOpens == 1) - events.Rendering.RaiseEmpty(); - Color lighting = ((Game1.currentLocation.Name.StartsWith("UndergroundMine") && Game1.currentLocation is MineShaft) ? (Game1.currentLocation as MineShaft).getLightingColor(gameTime) : ((Game1.ambientLight.Equals(Color.White) || (Game1.IsRainingHere() && (bool)Game1.currentLocation.isOutdoors)) ? Game1.outdoorLight : Game1.ambientLight)); - float light_multiplier = 1f; - if (Game1.player.hasBuff(26)) - { - if (lighting == Color.White) - { - lighting = new Color(0.75f, 0.75f, 0.75f); - } - else - { - lighting.R = (byte)Utility.Lerp((int)lighting.R, 255f, 0.5f); - lighting.G = (byte)Utility.Lerp((int)lighting.G, 255f, 0.5f); - lighting.B = (byte)Utility.Lerp((int)lighting.B, 255f, 0.5f); - } - light_multiplier = 0.33f; - } - Game1.spriteBatch.Draw(Game1.staminaRect, Game1.lightmap.Bounds, lighting); - foreach (LightSource lightSource in Game1.currentLightSources) - { - if ((Game1.IsRainingHere() || Game1.isDarkOut()) && lightSource.lightContext.Value == LightSource.LightContext.WindowLight) - { - continue; - } - if (lightSource.PlayerID != 0L && lightSource.PlayerID != Game1.player.UniqueMultiplayerID) - { - Farmer farmer = Game1.getFarmerMaybeOffline(lightSource.PlayerID); - if (farmer == null || (farmer.currentLocation != null && farmer.currentLocation.Name != Game1.currentLocation.Name) || (bool)farmer.hidden) - { - continue; - } - } - if (Utility.isOnScreen(lightSource.position, (int)((float)lightSource.radius * 64f * 4f))) - { - Game1.spriteBatch.Draw(lightSource.lightTexture, Game1.GlobalToLocal(Game1.viewport, lightSource.position) / (Game1.options.lightingQuality / 2), lightSource.lightTexture.Bounds, lightSource.color.Value * light_multiplier, 0f, new Vector2(lightSource.lightTexture.Bounds.Width / 2, lightSource.lightTexture.Bounds.Height / 2), (float)lightSource.radius / (float)(Game1.options.lightingQuality / 2), SpriteEffects.None, 0.9f); - } - } - Game1.spriteBatch.End(); - Game1.SetRenderTarget(target_screen); - } - base.GraphicsDevice.Clear(Game1.bgColor); - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp); - if (++batchOpens == 1) - events.Rendering.RaiseEmpty(); - events.RenderingWorld.RaiseEmpty(); - if (Game1.background != null) - { - Game1.background.draw(Game1.spriteBatch); - } - Game1.currentLocation.drawBackground(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); - Game1.spriteBatch.End(); - Game1.spriteBatch.Begin(SpriteSortMode.FrontToBack, BlendState.AlphaBlend, SamplerState.PointClamp); - Game1.currentLocation.drawFloorDecorations(Game1.spriteBatch); - Game1.spriteBatch.End(); - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp); - 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) - { - if ((f.IsLocalPlayer && Game1.displayFarmer) || !f.hidden) - { - this._farmerShadows.Add(f); - } - } - } - else - { - foreach (Farmer f2 in Game1.currentLocation.farmers) - { - if ((f2.IsLocalPlayer && Game1.displayFarmer) || !f2.hidden) - { - this._farmerShadows.Add(f2); - } - } - } - if (!Game1.currentLocation.shouldHideCharacters()) - { - if (Game1.CurrentEvent == null) - { - foreach (NPC k in Game1.currentLocation.characters) - { - if (!k.swimming && !k.HideShadow && !k.IsInvisible && !this.checkCharacterTilesForShadowDrawFlag(k)) - { - Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, k.GetShadowOffset() + k.Position + new Vector2((float)(k.GetSpriteWidthForPositioning() * 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), Math.Max(0f, (4f + (float)k.yJumpOffset / 40f) * (float)k.scale), SpriteEffects.None, Math.Max(0f, (float)k.getStandingY() / 10000f) - 1E-06f); - } - } - } - else - { - foreach (NPC l in Game1.CurrentEvent.actors) - { - if ((Game1.CurrentEvent == null || !Game1.CurrentEvent.ShouldHideCharacter(l)) && !l.swimming && !l.HideShadow && !this.checkCharacterTilesForShadowDrawFlag(l)) - { - Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, l.GetShadowOffset() + l.Position + new Vector2((float)(l.GetSpriteWidthForPositioning() * 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), Math.Max(0f, 4f + (float)l.yJumpOffset / 40f) * (float)l.scale, SpriteEffects.None, Math.Max(0f, (float)l.getStandingY() / 10000f) - 1E-06f); - } - } - } - foreach (Farmer f3 in this._farmerShadows) - { - if (!Game1.multiplayer.isDisconnecting(f3.UniqueMultiplayerID) && !f3.swimming && !f3.isRidingHorse() && !f3.IsSitting() && (Game1.currentLocation == null || !this.checkCharacterTilesForShadowDrawFlag(f3))) - { - Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(f3.GetShadowOffset() + 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); - } - } - } - 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); - if (!Game1.currentLocation.shouldHideCharacters()) - { - if (Game1.CurrentEvent == null) - { - foreach (NPC m in Game1.currentLocation.characters) - { - if (!m.swimming && !m.HideShadow && !m.isInvisible && this.checkCharacterTilesForShadowDrawFlag(m)) - { - Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, m.GetShadowOffset() + m.Position + new Vector2((float)(m.GetSpriteWidthForPositioning() * 4) / 2f, m.GetBoundingBox().Height + ((!m.IsMonster) ? 12 : 0))), Game1.shadowTexture.Bounds, Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), Math.Max(0f, (4f + (float)m.yJumpOffset / 40f) * (float)m.scale), SpriteEffects.None, Math.Max(0f, (float)m.getStandingY() / 10000f) - 1E-06f); - } - } - } - else - { - foreach (NPC n in Game1.CurrentEvent.actors) - { - if ((Game1.CurrentEvent == null || !Game1.CurrentEvent.ShouldHideCharacter(n)) && !n.swimming && !n.HideShadow && this.checkCharacterTilesForShadowDrawFlag(n)) - { - Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, n.GetShadowOffset() + n.Position + new Vector2((float)(n.GetSpriteWidthForPositioning() * 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), Math.Max(0f, (4f + (float)n.yJumpOffset / 40f) * (float)n.scale), SpriteEffects.None, Math.Max(0f, (float)n.getStandingY() / 10000f) - 1E-06f); - } - } - } - foreach (Farmer f4 in this._farmerShadows) - { - float draw_layer = Math.Max(0.0001f, f4.getDrawLayer() + 0.00011f) - 0.0001f; - if (!f4.swimming && !f4.isRidingHorse() && !f4.IsSitting() && Game1.currentLocation != null && this.checkCharacterTilesForShadowDrawFlag(f4)) - { - Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(f4.GetShadowOffset() + 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.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((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); - 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); - } - if (!Game1.IsFakedBlackScreen()) - { - 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); - if (Game1.eventUp && Game1.currentLocation.currentEvent != null) - { - foreach (NPC n2 in Game1.currentLocation.currentEvent.actors) - { - if (n2.isEmoting) - { - Vector2 emotePosition = n2.getLocalPosition(Game1.viewport); - if (n2.NeedsBirdieEmoteHack()) - { - emotePosition.X += 64f; - } - emotePosition.Y -= 140f; - if (n2.Age == 2) - { - emotePosition.Y += 32f; - } - else if (n2.Gender == 1) - { - emotePosition.Y += 10f; - } - Game1.spriteBatch.Draw(Game1.emoteSpriteSheet, emotePosition, new Microsoft.Xna.Framework.Rectangle(n2.CurrentEmoteIndex * 16 % Game1.emoteSpriteSheet.Width, n2.CurrentEmoteIndex * 16 / Game1.emoteSpriteSheet.Width * 16, 16, 16), Color.White, 0f, Vector2.Zero, 4f, SpriteEffects.None, (float)n2.getStandingY() / 10000f); - } - } - } - Game1.spriteBatch.End(); - if (Game1.drawLighting && !Game1.IsFakedBlackScreen()) - { - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, this.lightingBlend, SamplerState.LinearClamp); - Viewport vp = base.GraphicsDevice.Viewport; - vp.Bounds = target_screen?.Bounds ?? base.GraphicsDevice.PresentationParameters.Bounds; - base.GraphicsDevice.Viewport = vp; - float render_zoom = Game1.options.lightingQuality / 2; - if (this.useUnscaledLighting) - { - render_zoom /= Game1.options.zoomLevel; - } - Game1.spriteBatch.Draw(Game1.lightmap, Vector2.Zero, Game1.lightmap.Bounds, Color.White, 0f, Vector2.Zero, render_zoom, SpriteEffects.None, 1f); - if (Game1.IsRainingHere() && (bool)Game1.currentLocation.isOutdoors && !(Game1.currentLocation is Desert)) - { - Game1.spriteBatch.Draw(Game1.staminaRect, vp.Bounds, Color.OrangeRed * 0.45f); - } - Game1.spriteBatch.End(); - } - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp); - 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.ShouldShowOnscreenUsernames() && Game1.currentLocation != null) - { - Game1.currentLocation.DrawFarmerUsernames(Game1.spriteBatch); - } - 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, -Game1.viewport.X, 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, Game1.graphics.GraphicsDevice.Viewport.Width - (-Game1.viewport.X + Game1.currentLocation.map.Layers[0].LayerWidth * 64), Game1.graphics.GraphicsDevice.Viewport.Height), Color.Black); - Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle(0, 0, Game1.graphics.GraphicsDevice.Viewport.Width, -Game1.viewport.Y), Color.Black); - Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle(0, -Game1.viewport.Y + Game1.currentLocation.map.Layers[0].LayerHeight * 64, Game1.graphics.GraphicsDevice.Viewport.Width, Game1.graphics.GraphicsDevice.Viewport.Height - (-Game1.viewport.Y + Game1.currentLocation.map.Layers[0].LayerHeight * 64)), Color.Black); - } - Game1.spriteBatch.End(); - Game1.PushUIMode(); - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp); - 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); - } - } - Game1.spriteBatch.End(); - Game1.PopUIMode(); - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp); - } - if (Game1.farmEvent != null) - { - Game1.farmEvent.draw(Game1.spriteBatch); - Game1.spriteBatch.End(); - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp); - } - Game1.PushUIMode(); - 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); - } - Game1.spriteBatch.End(); - Game1.PopUIMode(); - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp); - if (Game1.eventUp && Game1.currentLocation != null && Game1.currentLocation.currentEvent != null) - { - Game1.currentLocation.currentEvent.drawAfterMap(Game1.spriteBatch); - } - if (!Game1.IsFakedBlackScreen() && Game1.IsRainingHere() && 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.End(); - Game1.PushUIMode(); - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp); - Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * ((Game1.gameMode == 0) ? (1f - Game1.fadeToBlackAlpha) : Game1.fadeToBlackAlpha)); - Game1.spriteBatch.End(); - Game1.PopUIMode(); - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp); - } - 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); - } - Game1.spriteBatch.End(); - Game1.PushUIMode(); - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp); - foreach (TemporaryAnimatedSprite uiOverlayTempSprite in Game1.uiOverlayTempSprites) - { - uiOverlayTempSprite.draw(Game1.spriteBatch, localPosition: true); - } - Game1.spriteBatch.End(); - Game1.PopUIMode(); - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp); - } - 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); - } - Game1.spriteBatch.End(); - Game1.PushUIMode(); - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp); - 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) - { - IClickableMenu curMenu = null; - try - { - events.RenderingActiveMenu.RaiseEmpty(); - for (curMenu = Game1.activeClickableMenu; curMenu != null; curMenu = curMenu.GetChildMenu()) - { - curMenu.draw(Game1.spriteBatch); - } - events.RenderedActiveMenu.RaiseEmpty(); - } - catch (Exception ex) - { - this.Monitor.Log($"The {curMenu.GetMenuChainLabel()} 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.specialCurrencyDisplay != null) - { - Game1.specialCurrencyDisplay.Draw(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); - Game1.PopUIMode(); + base.CleanupReturningToTitle(); } -#nullable enable } } diff --git a/src/SMAPI/Framework/SGameLogger.cs b/src/SMAPI/Framework/SGameLogger.cs new file mode 100644 index 000000000..1b33fdb80 --- /dev/null +++ b/src/SMAPI/Framework/SGameLogger.cs @@ -0,0 +1,78 @@ +using System; +using StardewModdingAPI.Internal; +using StardewValley.Logging; + +namespace StardewModdingAPI.Framework +{ + /// Redirects log output from the game code to a SMAPI monitor. + internal class SGameLogger : IGameLogger + { + /********* + ** Fields + *********/ + /// The monitor to which to log output. + private readonly IMonitor Monitor; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The monitor to which to log output. + public SGameLogger(IMonitor monitor) + { + this.Monitor = monitor; + } + + /// + public void Verbose(string message) + { + this.Monitor.Log(message); + } + + /// + public void Debug(string message) + { + this.Monitor.Log(message, LogLevel.Debug); + } + + /// + public void Info(string message) + { + this.Monitor.Log(message, LogLevel.Info); + } + + /// + public void Warn(string message) + { + this.Monitor.Log(message, LogLevel.Warn); + } + + /// + public void Error(string error, Exception? exception = null) + { + // steam not loaded + if (error == "Error connecting to Steam." && exception?.Message == "Steamworks is not initialized.") + { + this.Monitor.Log( +#if SMAPI_FOR_WINDOWS + "Oops! Steam achievements won't work because Steam isn't loaded. See 'Configure your game client' 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.Error + ); + } + + // any other error + else + { + string message = exception != null + ? $"{error}\n{exception.GetLogSummary()}" + : error; + + this.Monitor.Log(message, LogLevel.Error); + } + } + } +} diff --git a/src/SMAPI/Framework/SGameRunner.cs b/src/SMAPI/Framework/SGameRunner.cs index 213fe561f..28946f6b0 100644 --- a/src/SMAPI/Framework/SGameRunner.cs +++ b/src/SMAPI/Framework/SGameRunner.cs @@ -3,10 +3,11 @@ using System.Linq; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; -using StardewModdingAPI.Framework.Events; +using StardewModdingAPI.Enums; using StardewModdingAPI.Framework.Input; using StardewModdingAPI.Framework.Reflection; using StardewValley; +using StardewValley.Logging; namespace StardewModdingAPI.Framework { @@ -19,9 +20,6 @@ internal class SGameRunner : GameRunner /// Encapsulates monitoring and logging for SMAPI. private readonly Monitor Monitor; - /// Manages SMAPI events for mods. - private readonly EventManager Events; - /// Simplifies access to private game code. private readonly Reflector Reflection; @@ -31,12 +29,18 @@ internal class SGameRunner : GameRunner /// The core SMAPI mod hooks. private readonly SModHooks ModHooks; + /// The game log output handler. + private readonly IGameLogger GameLogger; + /// The core multiplayer logic. private readonly SMultiplayer Multiplayer; /// Raised after the game finishes loading its initial content. private readonly Action OnGameContentLoaded; + /// Raised invoke when the load stage changes through a method like . + private readonly Action OnLoadStageChanged; + /// Raised when XNA is updating (roughly 60 times per second). private readonly Action OnGameUpdating; @@ -46,6 +50,9 @@ internal class SGameRunner : GameRunner /// Raised before the game exits. private readonly Action OnGameExiting; + /// Raised after an instance finishes a draw loop. + private readonly Action OnPlayerInstanceRendered; + /********* ** Public methods @@ -60,31 +67,35 @@ internal class SGameRunner : GameRunner /// Construct an instance. /// Encapsulates monitoring and logging for SMAPI. /// Simplifies access to private game code. - /// Manages SMAPI events for mods. /// Handles mod hooks provided by the game. + /// The game log output handler. /// The core multiplayer logic. /// 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. /// Raised after the game finishes loading its initial content. + /// Raised invoke when the load stage changes through a method like . /// Raised when XNA is updating its state (roughly 60 times per second). /// Raised when the game instance for a local split-screen player is updating (once per per player). + /// Raised after an instance finishes a draw loop. /// Raised before the game exits. - public SGameRunner(Monitor monitor, Reflector reflection, EventManager eventManager, SModHooks modHooks, SMultiplayer multiplayer, Action exitGameImmediately, Action onGameContentLoaded, Action onGameUpdating, Action onPlayerInstanceUpdating, Action onGameExiting) + public SGameRunner(Monitor monitor, Reflector reflection, SModHooks modHooks, IGameLogger gameLogger, SMultiplayer multiplayer, Action exitGameImmediately, Action onGameContentLoaded, Action onLoadStageChanged, Action onGameUpdating, Action onPlayerInstanceUpdating, Action onGameExiting, Action onPlayerInstanceRendered) { // init XNA Game1.graphics.GraphicsProfile = GraphicsProfile.HiDef; // hook into game this.ModHooks = modHooks; + this.GameLogger = gameLogger; // init SMAPI this.Monitor = monitor; - this.Events = eventManager; this.Reflection = reflection; this.Multiplayer = multiplayer; this.ExitGameImmediately = exitGameImmediately; this.OnGameContentLoaded = onGameContentLoaded; + this.OnLoadStageChanged = onLoadStageChanged; this.OnGameUpdating = onGameUpdating; this.OnPlayerInstanceUpdating = onPlayerInstanceUpdating; + this.OnPlayerInstanceRendered = onPlayerInstanceRendered; this.OnGameExiting = onGameExiting; } @@ -94,7 +105,21 @@ public SGameRunner(Monitor monitor, Reflector reflection, EventManager eventMana public override Game1 CreateGameInstance(PlayerIndex playerIndex = PlayerIndex.One, int instanceIndex = 0) { SInputState inputState = new(); - return new SGame(playerIndex, instanceIndex, this.Monitor, this.Reflection, this.Events, inputState, this.ModHooks, this.Multiplayer, this.ExitGameImmediately, this.OnPlayerInstanceUpdating, this.OnGameContentLoaded); + return new SGame( + playerIndex: playerIndex, + instanceIndex: instanceIndex, + monitor: this.Monitor, + reflection: this.Reflection, + input: inputState, + modHooks: this.ModHooks, + gameLogger: this.GameLogger, + multiplayer: this.Multiplayer, + exitGameImmediately: this.ExitGameImmediately, + onUpdating: this.OnPlayerInstanceUpdating, + onContentLoaded: this.OnGameContentLoaded, + onLoadStageChanged: this.OnLoadStageChanged, + onRendered: this.OnPlayerInstanceRendered + ); } /// diff --git a/src/SMAPI/Framework/SModHooks.cs b/src/SMAPI/Framework/SModHooks.cs index ac4f242cb..fe1b1c630 100644 --- a/src/SMAPI/Framework/SModHooks.cs +++ b/src/SMAPI/Framework/SModHooks.cs @@ -1,11 +1,19 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Enums; +using StardewModdingAPI.Internal; using StardewModdingAPI.Utilities; using StardewValley; +using StardewValley.Menus; +using StardewValley.Mods; namespace StardewModdingAPI.Framework { /// Invokes callbacks for mod hooks provided by the game. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Inherited from the game code.")] internal class SModHooks : DelegatingModHooks { /********* @@ -17,6 +25,15 @@ internal class SModHooks : DelegatingModHooks /// Writes messages to the console. private readonly IMonitor Monitor; + /// A callback to invoke when the load stage changes. + private readonly Action OnStageChanged; + + /// A callback to invoke when the game starts a render step in the draw loop. + private readonly Action OnRenderingStep; + + /// A callback to invoke when the game finishes a render step in the draw loop. + private readonly Action OnRenderedStep; + /********* ** Public methods @@ -24,12 +41,18 @@ internal class SModHooks : DelegatingModHooks /// Construct an instance. /// The underlying hooks to call by default. /// A callback to invoke before runs. + /// A callback to invoke when the load stage changes. + /// A callback to invoke when the game starts a render step in the draw loop. + /// A callback to invoke when the game finishes a render step in the draw loop. /// Writes messages to the console. - public SModHooks(ModHooks parent, Action beforeNewDayAfterFade, IMonitor monitor) + public SModHooks(ModHooks parent, Action beforeNewDayAfterFade, Action onStageChanged, Action onRenderingStep, Action onRenderedStep, IMonitor monitor) : base(parent) { - this.BeforeNewDayAfterFade = beforeNewDayAfterFade; this.Monitor = monitor; + this.BeforeNewDayAfterFade = beforeNewDayAfterFade; + this.OnStageChanged = onStageChanged; + this.OnRenderingStep = onRenderingStep; + this.OnRenderedStep = onRenderedStep; } /// @@ -56,5 +79,47 @@ public override Task StartTask(Task task, string id) this.Monitor.Log(" task complete."); return task; } + + /// + public override void CreatedInitialLocations() + { + this.OnStageChanged(LoadStage.CreatedInitialLocations); + } + + /// + public override void SaveAddedLocations() + { + this.OnStageChanged(LoadStage.SaveAddedLocations); + } + + /// + public override bool OnRendering(RenderSteps step, SpriteBatch sb, GameTime time, RenderTarget2D target_screen) + { + this.OnRenderingStep(step, sb, target_screen); + + return true; + } + + /// + public override void OnRendered(RenderSteps step, SpriteBatch sb, GameTime time, RenderTarget2D target_screen) + { + this.OnRenderedStep(step, sb, target_screen); + } + + /// + public override bool TryDrawMenu(IClickableMenu menu, Action draw_menu_action) + { + try + { + draw_menu_action(); + return true; + } + catch (Exception ex) + { + this.Monitor.Log($"The {menu.GetMenuChainLabel()} menu crashed while drawing itself. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); + Game1.activeClickableMenu.exitThisMenu(); + return false; + } + } } } diff --git a/src/SMAPI/Framework/SMultiplayer.cs b/src/SMAPI/Framework/SMultiplayer.cs index 441a50ef8..46477142d 100644 --- a/src/SMAPI/Framework/SMultiplayer.cs +++ b/src/SMAPI/Framework/SMultiplayer.cs @@ -2,19 +2,16 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using Galaxy.Api; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using StardewModdingAPI.Events; using StardewModdingAPI.Framework.Events; using StardewModdingAPI.Framework.Networking; -using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Internal; using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Utilities; using StardewValley; using StardewValley.Network; -using StardewValley.SDKs; namespace StardewModdingAPI.Framework { @@ -44,9 +41,6 @@ internal class SMultiplayer : Multiplayer /// Encapsulates SMAPI's JSON file parsing. private readonly JsonHelper JsonHelper; - /// Simplifies access to private code. - private readonly Reflector Reflection; - /// Manages SMAPI events. private readonly EventManager EventManager; @@ -85,16 +79,14 @@ public MultiplayerPeer? HostPeer /// Manages SMAPI events. /// Encapsulates SMAPI's JSON file parsing. /// Tracks the installed mods. - /// Simplifies access to private code. /// A callback to invoke when a mod message is received. /// Whether to log network traffic. - public SMultiplayer(IMonitor monitor, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, Reflector reflection, Action onModMessageReceived, bool logNetworkTraffic) + public SMultiplayer(IMonitor monitor, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, Action onModMessageReceived, bool logNetworkTraffic) { this.Monitor = monitor; this.EventManager = eventManager; this.JsonHelper = jsonHelper; this.ModRegistry = modRegistry; - this.Reflection = reflection; this.OnModMessageReceived = onModMessageReceived; this.LogNetworkTraffic = logNetworkTraffic; } @@ -110,48 +102,31 @@ public void CleanupOnMultiplayerExit() /// The client to initialize. public override Client InitClient(Client client) { - switch (client) - { - case LidgrenClient: - { - string address = this.Reflection.GetField(client, "address").GetValue() ?? throw new InvalidOperationException("Can't initialize base networking client: no valid address found."); - return new SLidgrenClient(address, this.OnClientProcessingMessage, this.OnClientSendingMessage); - } + client = base.InitClient(client); - case GalaxyNetClient: - { - GalaxyID address = this.Reflection.GetField(client, "lobbyId").GetValue() ?? throw new InvalidOperationException("Can't initialize GOG networking client: no valid address found."); - return new SGalaxyNetClient(address, this.OnClientProcessingMessage, this.OnClientSendingMessage); - } - - default: - this.Monitor.Log($"Unknown multiplayer client type: {client.GetType().AssemblyQualifiedName}"); - return client; + if (client is IHookableClient hookClient) + { + hookClient.OnProcessingMessage = this.OnClientProcessingMessage; + hookClient.OnSendingMessage = this.OnClientSendingMessage; } + else + this.Monitor.Log($"Multiplayer client type '{client.GetType().AssemblyQualifiedName}' doesn't implement {nameof(IHookableClient)}, so SMAPI is unable to hook into it. This may cause mod issues in multiplayer."); + + return client; } /// Initialize a server before the game connects to an incoming player. /// The server to initialize. public override Server InitServer(Server server) { - switch (server) - { - case LidgrenServer: - { - IGameServer gameServer = this.Reflection.GetField(server, "gameServer").GetValue() ?? throw new InvalidOperationException("Can't initialize base networking client: the required 'gameServer' field wasn't found."); - return new SLidgrenServer(gameServer, this, this.OnServerProcessingMessage); - } + server = base.InitServer(server); - case GalaxyNetServer: - { - IGameServer gameServer = this.Reflection.GetField(server, "gameServer").GetValue() ?? throw new InvalidOperationException("Can't initialize GOG networking client: the required 'gameServer' field wasn't found."); - return new SGalaxyNetServer(gameServer, this, this.OnServerProcessingMessage); - } + if (server is IHookableServer hookServer) + hookServer.OnProcessingMessage = this.OnServerProcessingMessage; + else + this.Monitor.Log($"Multiplayer server type '{server.GetType().AssemblyQualifiedName}' doesn't implement {nameof(IHookableServer)}, so SMAPI is unable to hook into it. This may cause mod issues in multiplayer."); - default: - this.Monitor.Log($"Unknown multiplayer server type: {server.GetType().AssemblyQualifiedName}"); - return server; - } + return server; } /// A callback raised when sending a message as a farmhand. diff --git a/src/SMAPI/Framework/StateTracking/ChestTracker.cs b/src/SMAPI/Framework/StateTracking/ChestTracker.cs index 2796ad546..0f6f74e4b 100644 --- a/src/SMAPI/Framework/StateTracking/ChestTracker.cs +++ b/src/SMAPI/Framework/StateTracking/ChestTracker.cs @@ -44,9 +44,9 @@ internal class ChestTracker : IDisposable public ChestTracker(string name, Chest chest) { this.Chest = chest; - this.InventoryWatcher = WatcherFactory.ForNetList($"{name}.{nameof(chest.items)}", chest.items); + this.InventoryWatcher = WatcherFactory.ForInventory($"{name}.{nameof(chest.Items)}", chest.Items); - this.StackSizes = this.Chest.items + this.StackSizes = this.Chest.Items .Where(n => n != null) .Distinct() .ToDictionary(n => n, n => n.Stack); @@ -74,7 +74,9 @@ public void Update() public void Reset() { // update stack sizes - foreach (Item item in this.StackSizes.Keys.ToArray().Concat(this.Added)) + foreach (Item item in this.StackSizes.Keys) + this.StackSizes[item] = item.Stack; + foreach (Item item in this.Added) this.StackSizes[item] = item.Stack; // update watcher diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/NetListWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/InventoryWatcher.cs similarity index 61% rename from src/SMAPI/Framework/StateTracking/FieldWatchers/NetListWatcher.cs rename to src/SMAPI/Framework/StateTracking/FieldWatchers/InventoryWatcher.cs index 5b6a3e1f4..ecb0d2c34 100644 --- a/src/SMAPI/Framework/StateTracking/FieldWatchers/NetListWatcher.cs +++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/InventoryWatcher.cs @@ -1,25 +1,24 @@ using System.Collections.Generic; -using Netcode; using StardewModdingAPI.Framework.StateTracking.Comparers; +using StardewValley; +using StardewValley.Inventories; namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers { - /// A watcher which detects changes to a net list field. - /// The list value type. - internal class NetListWatcher : BaseDisposableWatcher, ICollectionWatcher - where TValue : class, INetObject + /// A watcher which detects changes to an item inventory. + internal class InventoryWatcher : BaseDisposableWatcher, ICollectionWatcher { /********* ** Fields *********/ - /// The field being watched. - private readonly NetList> Field; + /// The inventory being watched. + private readonly Inventory Inventory; /// The pairs added since the last reset. - private readonly ISet AddedImpl = new HashSet(new ObjectReferenceComparer()); + private readonly ISet AddedImpl = new HashSet(new ObjectReferenceComparer()); /// The pairs removed since the last reset. - private readonly ISet RemovedImpl = new HashSet(new ObjectReferenceComparer()); + private readonly ISet RemovedImpl = new HashSet(new ObjectReferenceComparer()); /********* @@ -32,10 +31,10 @@ internal class NetListWatcher : BaseDisposableWatcher, ICollectionWatche public bool IsChanged => this.AddedImpl.Count > 0 || this.RemovedImpl.Count > 0; /// - public IEnumerable Added => this.AddedImpl; + public IEnumerable Added => this.AddedImpl; /// - public IEnumerable Removed => this.RemovedImpl; + public IEnumerable Removed => this.RemovedImpl; /********* @@ -43,13 +42,14 @@ internal class NetListWatcher : BaseDisposableWatcher, ICollectionWatche *********/ /// Construct an instance. /// A name which identifies what the watcher is watching, used for troubleshooting. - /// The field to watch. - public NetListWatcher(string name, NetList> field) + /// The inventory to watch. + public InventoryWatcher(string name, Inventory inventory) { this.Name = name; - this.Field = field; - field.OnElementChanged += this.OnElementChanged; - field.OnArrayReplaced += this.OnArrayReplaced; + this.Inventory = inventory; + + inventory.OnSlotChanged += this.OnSlotChanged; + inventory.OnInventoryReplaced += this.OnInventoryReplaced; } /// @@ -70,8 +70,8 @@ public override void Dispose() { if (!this.IsDisposed) { - this.Field.OnElementChanged -= this.OnElementChanged; - this.Field.OnArrayReplaced -= this.OnArrayReplaced; + this.Inventory.OnSlotChanged -= this.OnSlotChanged; + this.Inventory.OnInventoryReplaced -= this.OnInventoryReplaced; } base.Dispose(); @@ -82,20 +82,20 @@ public override void Dispose() ** Private methods *********/ /// A callback invoked when the value list is replaced. - /// The net field whose values changed. + /// The net field whose values changed. /// The previous list of values. /// The new list of values. - private void OnArrayReplaced(NetList> list, IList oldValues, IList newValues) + private void OnInventoryReplaced(Inventory inventory, IList oldValues, IList newValues) { - ISet oldSet = new HashSet(oldValues, new ObjectReferenceComparer()); - ISet changed = new HashSet(newValues, new ObjectReferenceComparer()); + ISet oldSet = new HashSet(oldValues, new ObjectReferenceComparer()); + ISet changed = new HashSet(newValues, new ObjectReferenceComparer()); - foreach (TValue value in oldSet) + foreach (Item value in oldSet) { if (!changed.Contains(value)) this.Remove(value); } - foreach (TValue value in changed) + foreach (Item value in changed) { if (!oldSet.Contains(value)) this.Add(value); @@ -103,11 +103,11 @@ private void OnArrayReplaced(NetList> list, IList } /// A callback invoked when an entry is replaced. - /// The net field whose values changed. + /// The inventory whose values changed. /// The list index which changed. /// The previous value. /// The new value. - private void OnElementChanged(NetList> list, int index, TValue? oldValue, TValue? newValue) + private void OnSlotChanged(Inventory inventory, int index, Item? oldValue, Item? newValue) { this.Remove(oldValue); this.Add(newValue); @@ -115,7 +115,7 @@ private void OnElementChanged(NetList> list, int index, T /// Track an added item. /// The value that was added. - private void Add(TValue? value) + private void Add(Item? value) { if (value == null) return; @@ -131,7 +131,7 @@ private void Add(TValue? value) /// Track a removed item. /// The value that was removed. - private void Remove(TValue? value) + private void Remove(Item? value) { if (value == null) return; diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs index c31be1fcc..1f4728470 100644 --- a/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs +++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs @@ -3,6 +3,8 @@ using System.Collections.ObjectModel; using Netcode; using StardewModdingAPI.Framework.StateTracking.Comparers; +using StardewValley; +using StardewValley.Inventories; namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers { @@ -82,24 +84,22 @@ public static ICollectionWatcher ForImmutableCollection() return ImmutableCollectionWatcher.Instance; } - /// Get a watcher for a net collection. - /// The value type. + /// Get a watcher for an item inventory. /// A name which identifies what the watcher is watching, used for troubleshooting. - /// The net collection. - public static ICollectionWatcher ForNetCollection(string name, NetCollection collection) - where T : class, INetObject + /// The item inventory. + public static ICollectionWatcher ForInventory(string name, Inventory inventory) { - return new NetCollectionWatcher(name, collection); + return new InventoryWatcher(name, inventory); } - /// Get a watcher for a net list. + /// Get a watcher for a net collection. /// The value type. /// A name which identifies what the watcher is watching, used for troubleshooting. - /// The net list. - public static ICollectionWatcher ForNetList(string name, NetList> collection) + /// The net collection. + public static ICollectionWatcher ForNetCollection(string name, NetCollection collection) where T : class, INetObject { - return new NetListWatcher(name, collection); + return new NetCollectionWatcher(name, collection); } /// Get a watcher for a net dictionary. diff --git a/src/SMAPI/Framework/StateTracking/LocationTracker.cs b/src/SMAPI/Framework/StateTracking/LocationTracker.cs index 790c71ddb..47fe90a2e 100644 --- a/src/SMAPI/Framework/StateTracking/LocationTracker.cs +++ b/src/SMAPI/Framework/StateTracking/LocationTracker.cs @@ -70,7 +70,7 @@ public LocationTracker(GameLocation location) this.Location = location; // init watchers - this.BuildingsWatcher = location is BuildableGameLocation buildableLocation ? WatcherFactory.ForNetCollection($"{this.Name}.{nameof(buildableLocation.buildings)}", buildableLocation.buildings) : WatcherFactory.ForImmutableCollection(); + this.BuildingsWatcher = WatcherFactory.ForNetCollection($"{this.Name}.{nameof(location.buildings)}", location.buildings); this.DebrisWatcher = WatcherFactory.ForNetCollection($"{this.Name}.{nameof(location.debris)}", location.debris); this.LargeTerrainFeaturesWatcher = WatcherFactory.ForNetCollection($"{this.Name}.{nameof(location.largeTerrainFeatures)}", location.largeTerrainFeatures); this.NpcsWatcher = WatcherFactory.ForNetCollection($"{this.Name}.{nameof(location.characters)}", location.characters); diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/WorldLocationsSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/WorldLocationsSnapshot.cs index 59f949428..7f0f149cb 100644 --- a/src/SMAPI/Framework/StateTracking/Snapshots/WorldLocationsSnapshot.cs +++ b/src/SMAPI/Framework/StateTracking/Snapshots/WorldLocationsSnapshot.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Linq; using StardewModdingAPI.Framework.StateTracking.Comparers; using StardewValley; @@ -14,6 +13,9 @@ internal class WorldLocationsSnapshot /// A map of tracked locations. private readonly Dictionary LocationsDict = new(new ObjectReferenceComparer()); + /// The pooled list instance for . + private static readonly List PooledMissingLocations = new(); + /********* ** Accessors @@ -36,7 +38,7 @@ public void Update(WorldLocationsTracker watcher) this.LocationList.Update(watcher.IsLocationListChanged, watcher.Added, watcher.Removed); // remove missing locations - foreach (var key in this.LocationsDict.Keys.Where(key => !watcher.HasLocationTracker(key)).ToArray()) + foreach (var key in this.GetMissingLocations(watcher)) this.LocationsDict.Remove(key); // update locations @@ -48,5 +50,26 @@ public void Update(WorldLocationsTracker watcher) snapshot.Update(locationWatcher); } } + + + /********* + ** Private methods + *********/ + /// Get the watched locations which no longer exist in the world, if any. + /// The location list tracker. + private List GetMissingLocations(WorldLocationsTracker watcher) + { + List list = WorldLocationsSnapshot.PooledMissingLocations; + if (list.Count > 0) + list.Clear(); + + foreach (GameLocation location in this.LocationsDict.Keys) + { + if (!watcher.HasLocationTracker(location)) + list.Add(location); + } + + return list; + } } } diff --git a/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs b/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs index ca6988adc..dedbfc36d 100644 --- a/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs +++ b/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs @@ -30,6 +30,9 @@ internal class WorldLocationsTracker : IWatcher /// A lookup of registered buildings and their indoor location. private readonly Dictionary BuildingIndoors = new(new ObjectReferenceComparer()); + /// The pooled list instance for . + private static readonly List PooledLocationsWithBuildingsChanged = new(); + /********* ** Accessors @@ -95,21 +98,23 @@ public void Update() } // detect building changed - foreach (LocationTracker watcher in this.Locations.Where(p => p.BuildingsWatcher.IsChanged).ToArray()) + foreach (LocationTracker watcher in this.GetLocationsWhoseBuildingsChanged()) { this.Remove(watcher.BuildingsWatcher.Removed); this.Add(watcher.BuildingsWatcher.Added); } // detect building interiors changed (e.g. construction completed) - foreach ((Building building, GameLocation? oldIndoors) in this.BuildingIndoors.Where(p => !object.Equals(p.Key.indoors.Value, p.Value))) + foreach ((Building building, GameLocation? oldIndoors) in this.BuildingIndoors) { GameLocation? newIndoors = building.indoors.Value; + if (object.ReferenceEquals(oldIndoors, newIndoors)) + continue; + + this.Remove(oldIndoors); + this.Add(newIndoors); - if (oldIndoors != null) - this.Added.Add(oldIndoors); - if (newIndoors != null) - this.Removed.Add(newIndoors); + this.BuildingIndoors[building] = newIndoors; } } @@ -214,8 +219,7 @@ public void Add(GameLocation? location) this.LocationDict[location] = new LocationTracker(location); // add buildings - if (location is BuildableGameLocation buildableLocation) - this.Add(buildableLocation.buildings); + this.Add(location.buildings); } /// Remove the given building. @@ -244,8 +248,7 @@ public void Remove(GameLocation? location) // remove this.LocationDict.Remove(location); watcher.Dispose(); - if (location is BuildableGameLocation buildableLocation) - this.Remove(buildableLocation.buildings); + this.Remove(location.buildings); } } @@ -261,5 +264,21 @@ private IEnumerable GetWatchers() foreach (LocationTracker watcher in this.Locations) yield return watcher; } + + /// Get the locations whose building list changed, if any. + private List GetLocationsWhoseBuildingsChanged() + { + List list = WorldLocationsTracker.PooledLocationsWithBuildingsChanged; + if (list.Count > 0) + list.Clear(); + + foreach (LocationTracker watcher in this.LocationDict.Values) + { + if (watcher.IsChanged) + list.Add(watcher); + } + + return list; + } } } diff --git a/src/SMAPI/GameFramework.cs b/src/SMAPI/GameFramework.cs index 009865fe1..98662f780 100644 --- a/src/SMAPI/GameFramework.cs +++ b/src/SMAPI/GameFramework.cs @@ -1,18 +1,8 @@ -#if SMAPI_DEPRECATED -using System; -#endif - namespace StardewModdingAPI { /// The game framework running the game. public enum GameFramework { -#if SMAPI_DEPRECATED - /// The XNA Framework, previously used on Windows. - [Obsolete("Stardew Valley no longer uses XNA Framework on any supported platform. This value will be removed in SMAPI 4.0.0.")] - Xna, -#endif - /// The MonoGame framework. MonoGame } diff --git a/src/SMAPI/IAssetEditor.cs b/src/SMAPI/IAssetEditor.cs deleted file mode 100644 index f3238ba95..000000000 --- a/src/SMAPI/IAssetEditor.cs +++ /dev/null @@ -1,23 +0,0 @@ -#if SMAPI_DEPRECATED -using System; -using StardewModdingAPI.Events; - -namespace StardewModdingAPI -{ - /// Edits matching content assets. - [Obsolete($"Use {nameof(IMod.Helper)}.{nameof(IModHelper.Events)}.{nameof(IModEvents.Content)} instead. This interface will be removed in SMAPI 4.0.0.")] - public interface IAssetEditor - { - /********* - ** Public methods - *********/ - /// Get whether this instance can edit the given asset. - /// Basic metadata about the asset being loaded. - bool CanEdit(IAssetInfo asset); - - /// Edit a matched asset. - /// A helper which encapsulates metadata about an asset and enables changes to it. - void Edit(IAssetData asset); - } -} -#endif diff --git a/src/SMAPI/IAssetInfo.cs b/src/SMAPI/IAssetInfo.cs index 20064946f..f0cadffc8 100644 --- a/src/SMAPI/IAssetInfo.cs +++ b/src/SMAPI/IAssetInfo.cs @@ -8,20 +8,10 @@ public interface IAssetInfo /********* ** Accessors *********/ -#if SMAPI_DEPRECATED /// The content's locale code, if the content is localized. - /// LEGACY NOTE: when reading this field from an or implementation, for non-localized assets it will return the current game locale (or an empty string for English) instead of null. -#else - /// The content's locale code, if the content is localized. -#endif string? Locale { get; } -#if SMAPI_DEPRECATED - /// The asset name being read. - /// LEGACY NOTE: when reading this field from an or implementation, it's always equivalent to for backwards compatibility. -#else /// The asset name being read. -#endif public IAssetName Name { get; } /// The with any locale codes stripped. @@ -30,20 +20,5 @@ public interface IAssetInfo /// The content data type. Type DataType { get; } - -#if SMAPI_DEPRECATED - /// The normalized asset name being read. The format may change between platforms; see to compare with a known path. - [Obsolete($"Use {nameof(Name)} or {nameof(NameWithoutLocale)} instead. This property will be removed in SMAPI 4.0.0.")] - string AssetName { get; } - - - /********* - ** Public methods - *********/ - /// Get whether the asset name being loaded matches a given name after normalization. - /// The expected asset path, relative to the game's content folder and without the .xnb extension or locale suffix (like 'Data\ObjectInformation'). - [Obsolete($"Use {nameof(Name)}.{nameof(IAssetName.IsEquivalentTo)} or {nameof(NameWithoutLocale)}.{nameof(IAssetName.IsEquivalentTo)} instead. This method will be removed in SMAPI 4.0.0.")] - bool AssetNameEquals(string path); -#endif } } diff --git a/src/SMAPI/IAssetLoader.cs b/src/SMAPI/IAssetLoader.cs deleted file mode 100644 index 205980a74..000000000 --- a/src/SMAPI/IAssetLoader.cs +++ /dev/null @@ -1,23 +0,0 @@ -#if SMAPI_DEPRECATED -using System; -using StardewModdingAPI.Events; - -namespace StardewModdingAPI -{ - /// Provides the initial version for matching assets loaded by the game. SMAPI will raise an error if two mods try to load the same asset; in most cases you should use instead. - [Obsolete($"Use {nameof(IMod.Helper)}.{nameof(IModHelper.Events)}.{nameof(IModEvents.Content)} instead. This interface will be removed in SMAPI 4.0.0.")] - public interface IAssetLoader - { - /********* - ** Public methods - *********/ - /// Get whether this instance can load the initial version of the given asset. - /// Basic metadata about the asset being loaded. - bool CanLoad(IAssetInfo asset); - - /// Load a matched asset. - /// Basic metadata about the asset being loaded. - T Load(IAssetInfo asset); - } -} -#endif diff --git a/src/SMAPI/ICommandHelper.cs b/src/SMAPI/ICommandHelper.cs index c92a09c2b..afbcf2b02 100644 --- a/src/SMAPI/ICommandHelper.cs +++ b/src/SMAPI/ICommandHelper.cs @@ -16,14 +16,5 @@ public interface ICommandHelper : IModLinked /// The is not a valid format. /// There's already a command with that name. ICommandHelper Add(string name, string documentation, Action callback); - -#if SMAPI_DEPRECATED - /// Trigger a command. - /// The command name. - /// The command arguments. - /// Returns whether a matching command was triggered. - [Obsolete("Use mod-provided APIs to integrate with mods instead. This method will be removed in SMAPI 4.0.0.")] - bool Trigger(string name, string[] arguments); -#endif } } diff --git a/src/SMAPI/IContentHelper.cs b/src/SMAPI/IContentHelper.cs deleted file mode 100644 index b0e30a829..000000000 --- a/src/SMAPI/IContentHelper.cs +++ /dev/null @@ -1,84 +0,0 @@ -#if SMAPI_DEPRECATED -using System; -using System.Collections.Generic; -using System.Diagnostics.Contracts; -using Microsoft.Xna.Framework.Content; -using Microsoft.Xna.Framework.Graphics; -using StardewModdingAPI.Events; -using StardewValley; -using xTile; - -namespace StardewModdingAPI -{ - /// Provides an API for loading content assets. - [Obsolete($"Use {nameof(IMod.Helper)}.{nameof(IModHelper.GameContent)} or {nameof(IMod.Helper)}.{nameof(IModHelper.ModContent)} instead. This interface will be removed in SMAPI 4.0.0.")] - public interface IContentHelper : IModLinked - { - /********* - ** Accessors - *********/ - /// Interceptors which provide the initial versions of matching content assets. - [Obsolete($"Use {nameof(IMod.Helper)}.{nameof(IModHelper.Events)}.{nameof(IModEvents.Content)} instead. This property will be removed in SMAPI 4.0.0.")] - IList AssetLoaders { get; } - - /// Interceptors which edit matching content assets after they're loaded. - [Obsolete($"Use {nameof(IMod.Helper)}.{nameof(IModHelper.Events)}.{nameof(IModEvents.Content)} instead. This property will be removed in SMAPI 4.0.0.")] - IList AssetEditors { get; } - - /// The game's current locale code (like pt-BR). - string CurrentLocale { get; } - - /// The game's current locale as an enum value. - LocalizedContentManager.LanguageCode CurrentLocaleConstant { get; } - - - /********* - ** Public methods - *********/ - /// Load content from the game folder or mod folder (if not already cached), and return it. When loading a .png file, this must be called outside the game's draw loop. - /// The expected data type. The main supported types are , , (for mod content only), and data structures; other types may be supported by the game's content pipeline. - /// The asset key to fetch (if the is ), or the local path to a content file relative to the mod folder. - /// Where to search for a matching content asset. - /// The is empty or contains invalid characters. - /// The content asset couldn't be loaded (e.g. because it doesn't exist). - T Load(string key, ContentSource source = ContentSource.ModFolder) - where T : notnull; - - /// Normalize an asset name so it's consistent with those generated by the game. This is mainly useful for string comparisons like on generated asset names, and isn't necessary when passing asset names into other content helper methods. - /// The asset key. - /// The asset key is empty or contains invalid characters. - [Pure] - string NormalizeAssetName(string? assetName); - - /// 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. - /// The asset key to fetch (if the is ), or the local path to a content file relative to the mod folder. - /// Where to search for a matching content asset. - /// The is empty or contains invalid characters. - string GetActualAssetKey(string key, ContentSource source = ContentSource.ModFolder); - - /// 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. - /// The asset key to invalidate in the content folder. - /// The is empty or contains invalid characters. - /// Returns whether the given asset key was cached. - bool InvalidateCache(string key); - - /// Remove all assets of the given type from the cache so they're reloaded on the next request. This can be a very expensive operation and should only be used in very specific cases. This will reload core game assets if needed, but references to the former assets will still show the previous content. - /// The asset type to remove from the cache. - /// Returns whether any assets were invalidated. - bool InvalidateCache() - where T : notnull; - - /// 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. - /// A predicate matching the assets to invalidate. - /// Returns whether any cache entries were invalidated. - bool InvalidateCache(Func predicate); - - /// Get a patch helper for arbitrary data. - /// The data type. - /// The asset data. - /// The asset name. This is only used for tracking purposes and has no effect on the patch helper. - IAssetData GetPatchHelper(T data, string? assetName = null) - where T : notnull; - } -} -#endif diff --git a/src/SMAPI/IContentPack.cs b/src/SMAPI/IContentPack.cs index 5047b172b..5dac451df 100644 --- a/src/SMAPI/IContentPack.cs +++ b/src/SMAPI/IContentPack.cs @@ -1,9 +1,4 @@ using System; -#if SMAPI_DEPRECATED -using Microsoft.Xna.Framework.Content; -using Microsoft.Xna.Framework.Graphics; -using xTile; -#endif namespace StardewModdingAPI { @@ -48,22 +43,5 @@ public interface IContentPack /// The is not relative or contains directory climbing (../). void WriteJsonFile(string path, TModel data) where TModel : class; - -#if SMAPI_DEPRECATED - /// Load content from the content pack folder (if not already cached), and return it. When loading a .png file, this must be called outside the game's draw loop. - /// The expected data type. The main supported types are , , , and data structures; other types may be supported by the game's content pipeline. - /// The relative file path within the content pack (case-insensitive). - /// The is empty or contains invalid characters. - /// The content asset couldn't be loaded (e.g. because it doesn't exist). - [Obsolete($"Use {nameof(IContentPack.ModContent)}.{nameof(IModContentHelper.Load)} instead. This method will be removed in SMAPI 4.0.0.")] - T LoadAsset(string key) - where T : notnull; - - /// 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. - /// The relative file path within the content pack (case-insensitive). - /// The is empty or contains invalid characters. - [Obsolete($"Use {nameof(IContentPack.ModContent)}.{nameof(IModContentHelper.GetInternalAssetName)} instead. This method will be removed in SMAPI 4.0.0.")] - string GetActualAssetKey(string key); -#endif } } diff --git a/src/SMAPI/IModHelper.cs b/src/SMAPI/IModHelper.cs index a44d92c18..5e56bf823 100644 --- a/src/SMAPI/IModHelper.cs +++ b/src/SMAPI/IModHelper.cs @@ -1,6 +1,3 @@ -#if SMAPI_DEPRECATED -using System; -#endif using StardewModdingAPI.Events; namespace StardewModdingAPI @@ -27,12 +24,6 @@ public interface IModHelper /// This API is intended for reading content assets from the mod files (like game data, images, etc); see also which is intended for persisting internal mod data. IModContentHelper ModContent { get; } -#if SMAPI_DEPRECATED - /// An API for loading content assets. - [Obsolete($"Use {nameof(IGameContentHelper)} or {nameof(IModContentHelper)} instead.")] - IContentHelper Content { get; } -#endif - /// An API for managing content packs. IContentPackHelper ContentPacks { get; } diff --git a/src/SMAPI/IMonitor.cs b/src/SMAPI/IMonitor.cs index c400a211b..e4a71436c 100644 --- a/src/SMAPI/IMonitor.cs +++ b/src/SMAPI/IMonitor.cs @@ -1,3 +1,6 @@ +using System.Runtime.CompilerServices; +using StardewModdingAPI.Framework.Logging; + namespace StardewModdingAPI { /// Encapsulates monitoring and logging for a given module. @@ -26,5 +29,9 @@ public interface IMonitor /// Log a message that only appears when is enabled. /// The message to log. void VerboseLog(string message); + + /// Log a message that only appears when is enabled. + /// The message to log. + void VerboseLog([InterpolatedStringHandlerArgument("")] ref VerboseLogStringHandler message); } } diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs index b48b6f96b..a979760c2 100644 --- a/src/SMAPI/Metadata/CoreAssetPropagator.cs +++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs @@ -9,19 +9,14 @@ using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Utilities; using StardewModdingAPI.Internal; -using StardewModdingAPI.Toolkit.Utilities; using StardewValley; -using StardewValley.BellsAndWhistles; using StardewValley.Buildings; -using StardewValley.Characters; -using StardewValley.GameData.Movies; using StardewValley.Locations; -using StardewValley.Menus; -using StardewValley.Objects; -using StardewValley.Projectiles; +using StardewValley.Pathfinding; using StardewValley.TerrainFeatures; +using StardewValley.Triggers; +using StardewValley.WorldMaps; using xTile; -using xTile.Tiles; namespace StardewModdingAPI.Metadata { @@ -49,19 +44,6 @@ internal class CoreAssetPropagator /// Parse a raw asset name. private readonly Func ParseAssetName; - /// Optimized bucket categories for batch reloading assets. - private enum AssetBucket - { - /// NPC overworld sprites. - Sprite, - - /// Villager dialogue portraits. - Portrait, - - /// Any other asset. - Other - }; - /// A cache of world data fetched for the current tick. private readonly TickCacheDictionary WorldCache = new(); @@ -87,11 +69,12 @@ public CoreAssetPropagator(LocalizedContentManager mainContent, GameContentManag } /// Reload one of the game's core assets (if applicable). + /// The content managers whose assets to update. /// The asset keys and types to reload. /// Whether the in-game world is fully unloaded (e.g. on the title screen), so there's no need to propagate changes into the world. /// A lookup of asset names to whether they've been propagated. /// Whether the NPC pathfinding warp route cache was reloaded. - public void Propagate(IDictionary assets, bool ignoreWorld, out IDictionary propagatedAssets, out bool changedWarpRoutes) + public void Propagate(IList contentManagers, IDictionary assets, bool ignoreWorld, out IDictionary propagatedAssets, out bool changedWarpRoutes) { // get base name lookup propagatedAssets = assets @@ -99,94 +82,172 @@ public void Propagate(IDictionary assets, bool ignoreWorld, ou .Distinct() .ToDictionary(name => name, _ => false); - // group into optimized lists - var buckets = assets.GroupBy(p => + // edit textures in-place { - if (p.Key.IsDirectlyUnderPath("Characters") || p.Key.IsDirectlyUnderPath("Characters/Monsters")) - return AssetBucket.Sprite; + IAssetName[] textureAssets = assets + .Where(p => typeof(Texture2D).IsAssignableFrom(p.Value)) + .Select(p => p.Key) + .ToArray(); + + if (textureAssets.Any()) + { + var defaultLanguage = this.MainContentManager.GetCurrentLanguage(); + + foreach (IAssetName assetName in textureAssets) + { + var language = assetName.LanguageCode ?? defaultLanguage; + if (language == LocalizedContentManager.LanguageCode.mod && LocalizedContentManager.CurrentModLanguage is null) + language = defaultLanguage; - if (p.Key.IsDirectlyUnderPath("Portraits")) - return AssetBucket.Portrait; + bool changed = this.PropagateTexture(assetName, language, contentManagers, ignoreWorld); + if (changed) + propagatedAssets[assetName] = true; + } - return AssetBucket.Other; - }); + foreach (IAssetName assetName in textureAssets) + assets.Remove(assetName); + } + } - // reload assets + // reload other assets changedWarpRoutes = false; - foreach (var bucket in buckets) + foreach (var entry in assets) { - switch (bucket.Key) + bool changed = false; + bool curChangedMapRoutes = false; + try { - case AssetBucket.Sprite: - if (!ignoreWorld) - this.UpdateNpcSprites(propagatedAssets); - break; + changed = this.PropagateOther(entry.Key, entry.Value, ignoreWorld, out curChangedMapRoutes); + } + catch (Exception ex) + { + this.Monitor.Log($"An error occurred while propagating asset changes. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + } - case AssetBucket.Portrait: - if (!ignoreWorld) - this.UpdateNpcPortraits(propagatedAssets); - break; + propagatedAssets[entry.Key] = changed; + changedWarpRoutes = changedWarpRoutes || curChangedMapRoutes; + } - default: - foreach (var entry in bucket) + // reload NPC pathfinding cache if any map routes changed + if (changedWarpRoutes) + WarpPathfindingCache.PopulateCache(); + } + + + /********* + ** Private methods + *********/ + /// Propagate changes to a cached texture asset. + /// The asset name to reload. + /// The language for which to get assets. + /// The content managers whose assets to update. + /// Whether the in-game world is fully unloaded (e.g. on the title screen), so there's no need to propagate changes into the world. + /// Returns whether an asset was loaded. + [SuppressMessage("ReSharper", "StringLiteralTypo", Justification = "These deliberately match the asset names.")] + private bool PropagateTexture(IAssetName assetName, LocalizedContentManager.LanguageCode language, IList contentManagers, bool ignoreWorld) + { + bool changed = false; + + // get asset names to replace + // We propagate non-textures by comparing base asset names, to update any localized version like + // `asset.fr-FR` too. We need to check every content manager for in-place texture edits though, so we + // should avoid iterating their assets if possible. So here we just check for the current localized name + // and base name, which should cover normal cases. + IAssetName[] assetNames = assetName.LocaleCode != null + ? new[] { assetName, assetName.GetBaseAssetName() } + : new[] { assetName }; + + // update textures in-place + { + // get new textures to copy + Lazy[] newTextures = new Lazy[assetNames.Length]; + newTextures[0] = new Lazy(() => this.DisposableContentManager.LoadLocalized(assetName, language, useCache: false)); + if (assetNames.Length > 1) + newTextures[1] = new Lazy(() => this.DisposableContentManager.LoadLocalized(assetNames[1], language, useCache: false)); + + // apply to content managers + foreach (IContentManager contentManager in contentManagers) + { + for (int i = 0; i < assetNames.Length; i++) + { + IAssetName name = assetNames[i]; + + if (contentManager.IsLoaded(name)) { - bool changed = false; - bool curChangedMapRoutes = false; - try + if (this.DisposableContentManager.DoesAssetExist(name)) { - changed = this.PropagateOther(entry.Key, entry.Value, ignoreWorld, out curChangedMapRoutes); + changed = true; + + Texture2D texture = contentManager.LoadLocalized(name, language, useCache: true); + texture.CopyFromTexture(newTextures[i].Value); } - catch (Exception ex) + else { - this.Monitor.Log($"An error occurred while propagating asset changes. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + this.Monitor.Log($"Skipped reload for '{name.Name}' because the underlying asset no longer exists.", LogLevel.Warn); + break; } + } + } + } + + // drop temporary textures + foreach (Lazy newTexture in newTextures) + { + if (newTexture.IsValueCreated) + newTexture.Value.Dispose(); + } + } + + // update game state if needed + if (changed) + { + switch (assetName.Name.ToLower().Replace("\\", "/")) // normalized key so we can compare statically + { + /**** + ** Content\Characters\Farmer + ****/ + case "characters/farmer/farmer_base": // Farmer + case "characters/farmer/farmer_base_bald": + case "characters/farmer/farmer_girl_base": + case "characters/farmer/farmer_girl_base_bald": + if (ignoreWorld) + this.UpdatePlayerSprites(assetName); + break; + + /**** + ** Content\TileSheets + ****/ + case "tilesheets/tools": // Game1.ResetToolSpriteSheet + Game1.ResetToolSpriteSheet(); + break; - propagatedAssets[entry.Key] = changed; - changedWarpRoutes = changedWarpRoutes || curChangedMapRoutes; + default: + if (!ignoreWorld) + { + if (assetName.IsDirectlyUnderPath("Buildings") && assetName.BaseName.EndsWith("_PaintMask")) + return this.UpdateBuildingPaintMask(assetName); } + break; } } - // reload NPC pathfinding cache if any map routes changed - if (changedWarpRoutes) - NPC.populateRoutesFromLocationToLocationList(); + return changed; } - - /********* - ** Private methods - *********/ /// Reload one of the game's core assets (if applicable). /// The asset name to reload. /// The asset type to reload. /// Whether the in-game world is fully unloaded (e.g. on the title screen), so there's no need to propagate changes into the world. /// Whether the locations reachable by warps from this location changed as part of this propagation. - /// Returns whether an asset was loaded. The return value may be true or false, or a non-null value for true. + /// Returns whether an asset was loaded. [SuppressMessage("ReSharper", "StringLiteralTypo", Justification = "These deliberately match the asset names.")] private bool PropagateOther(IAssetName assetName, Type type, bool ignoreWorld, out bool changedWarpRoutes) { + bool changed = false; var content = this.MainContentManager; string key = assetName.BaseName; changedWarpRoutes = false; - bool changed = false; - - /**** - ** Special case: current map tilesheet - ** We only need to do this for the current location, since tilesheets are reloaded when you enter a location. - ** Just in case, we should still propagate by key even if a tilesheet is matched. - ****/ - if (!ignoreWorld && Game1.currentLocation?.map?.TileSheets != null) - { - foreach (TileSheet tilesheet in Game1.currentLocation.map.TileSheets) - { - if (this.IsSameBaseName(assetName, tilesheet.ImageSource)) - { - Game1.mapDisplayDevice.LoadTileSheet(tilesheet); - changed = true; - } - } - } /**** ** Propagate map changes @@ -236,70 +297,41 @@ static ISet GetWarpSet(GameLocation location) switch (assetName.BaseName.ToLower().Replace("\\", "/")) // normalized key so we can compare statically { /**** - ** Animals - ****/ - case "animals/horse": - return changed | (!ignoreWorld && this.UpdatePetOrHorseSprites(assetName)); - - /**** - ** Buildings - ****/ - case "buildings/houses": // Farm - Farm.houseTextures = this.LoadTexture(key); - return true; - - case "buildings/houses_paintmask": // Farm - { - bool removedFromCache = this.RemoveFromPaintMaskCache(assetName); - - Farm farm = Game1.getFarm(); - farm?.ApplyHousePaint(); - - return changed | (removedFromCache || farm != null); - } - - /**** - ** Content\Characters\Farmer + ** Content\Data ****/ - case "characters/farmer/accessories": // Game1.LoadContent - FarmerRenderer.accessoriesTexture = this.LoadTexture(key); - return true; - - case "characters/farmer/farmer_base": // Farmer - case "characters/farmer/farmer_base_bald": - case "characters/farmer/farmer_girl_base": - case "characters/farmer/farmer_girl_base_bald": - return changed | (!ignoreWorld && this.UpdatePlayerSprites(assetName)); - - case "characters/farmer/hairstyles": // Game1.LoadContent - FarmerRenderer.hairStylesTexture = this.LoadTexture(key); - return true; - - case "characters/farmer/hats": // Game1.LoadContent - FarmerRenderer.hatsTexture = this.LoadTexture(key); + case "data/achievements": // Game1.LoadContent + Game1.achievements = DataLoader.Achievements(content); return true; - case "characters/farmer/pants": // Game1.LoadContent - FarmerRenderer.pantsTexture = this.LoadTexture(key); + case "data/audiochanges": + Game1.CueModification.OnStartup(); // reload file and reapply changes return true; - case "characters/farmer/shirts": // Game1.LoadContent - FarmerRenderer.shirtsTexture = this.LoadTexture(key); + case "data/bigcraftables": // Game1.LoadContent + Game1.bigCraftableData = DataLoader.BigCraftables(content); + ItemRegistry.ResetCache(); return true; - /**** - ** Content\Data - ****/ - case "data/achievements": // Game1.LoadContent - Game1.achievements = content.Load>(key); + case "data/boots": // BootsDataDefinition + ItemRegistry.ResetCache(); return true; - case "data/bigcraftablesinformation": // Game1.LoadContent - Game1.bigCraftablesInformation = content.Load>(key); + case "data/buildings": // Game1.LoadContent + Game1.buildingData = DataLoader.Buildings(content); + if (!ignoreWorld) + { + Utility.ForEachBuilding(building => + { + building.ReloadBuildingData(); + return true; + }); + } return true; - case "data/clothinginformation": // Game1.LoadContent - Game1.clothingInformation = content.Load>(key); + case "data/characters": // Game1.LoadContent + Game1.characterData = DataLoader.Characters(content); + if (!ignoreWorld) + this.UpdateCharacterData(); return true; case "data/concessions": // MovieTheater.GetConcessions @@ -307,200 +339,120 @@ static ISet GetWarpSet(GameLocation location) return true; case "data/concessiontastes": // MovieTheater.GetConcessionTasteForCharacter - this.Reflection - .GetField>(typeof(MovieTheater), "_concessionTastes") - .SetValue(content.Load>(key)); + MovieTheater.ClearCachedConcessionTastes(); return true; case "data/cookingrecipes": // CraftingRecipe.InitShared - CraftingRecipe.cookingRecipes = content.Load>(key); + CraftingRecipe.cookingRecipes = DataLoader.CookingRecipes(content); return true; case "data/craftingrecipes": // CraftingRecipe.InitShared - CraftingRecipe.craftingRecipes = content.Load>(key); + CraftingRecipe.craftingRecipes = DataLoader.CraftingRecipes(content); return true; - case "data/farmanimals": // FarmAnimal constructor - return changed | (!ignoreWorld && this.UpdateFarmAnimalData()); - - case "data/hairdata": // Farmer.GetHairStyleMetadataFile - return changed | this.UpdateHairData(); - - case "data/movies": // MovieTheater.GetMovieData - case "data/moviesreactions": // MovieTheater.GetMovieReactions - MovieTheater.ClearCachedLocalizedData(); - return true; - - case "data/npcdispositions": // NPC constructor - return changed | (!ignoreWorld && this.UpdateNpcDispositions(content, assetName)); - - case "data/npcgifttastes": // Game1.LoadContent - Game1.NPCGiftTastes = content.Load>(key); - return true; - - case "data/objectcontexttags": // Game1.LoadContent - Game1.objectContextTags = content.Load>(key); + case "data/crops": // Game1.LoadContent + Game1.cropData = DataLoader.Crops(content); return true; - case "data/objectinformation": // Game1.LoadContent - Game1.objectInformation = content.Load>(key); - return true; - - /**** - ** Content\Fonts - ****/ - case "fonts/spritefont1": // Game1.LoadContent - Game1.dialogueFont = content.Load(key); - return true; - - case "fonts/smallfont": // Game1.LoadContent - Game1.smallFont = content.Load(key); + case "data/farmanimals": // FarmAnimal constructor + Game1.farmAnimalData = DataLoader.FarmAnimals(content); + if (!ignoreWorld) + this.UpdateFarmAnimalData(); return true; - case "fonts/tinyfont": // Game1.LoadContent - Game1.tinyFont = content.Load(key); + case "data/floorsandpaths": // Game1.LoadContent + Game1.floorPathData = DataLoader.FloorsAndPaths(content); return true; - case "fonts/tinyfontborder": // Game1.LoadContent - Game1.tinyFontBorder = content.Load(key); + case "data/furniture": // FurnitureDataDefinition + ItemRegistry.ResetCache(); return true; - /**** - ** Content\LooseSprites\Lighting - ****/ - case "loosesprites/lighting/greenlight": // Game1.LoadContent - Game1.cauldronLight = content.Load(key); + case "data/fruittrees": // Game1.LoadContent + Game1.fruitTreeData = DataLoader.FruitTrees(content); return true; - case "loosesprites/lighting/indoorwindowlight": // Game1.LoadContent - Game1.indoorWindowLight = content.Load(key); - return true; + case "data/hairdata": // Farmer.GetHairStyleMetadataFile + return changed | this.UpdateHairData(); - case "loosesprites/lighting/lantern": // Game1.LoadContent - Game1.lantern = content.Load(key); + case "data/hats": // HatDataDefinition + ItemRegistry.ResetCache(); return true; - case "loosesprites/lighting/sconcelight": // Game1.LoadContent - Game1.sconceLight = content.Load(key); + case "data/jukeboxtracks": // Game1.LoadContent + Game1.jukeboxTrackData = DataLoader.JukeboxTracks(content); return true; - case "loosesprites/lighting/windowlight": // Game1.LoadContent - Game1.windowLight = content.Load(key); + case "data/locationcontexts": // Game1.LoadContent + Game1.locationContextData = DataLoader.LocationContexts(content); return true; - /**** - ** Content\LooseSprites - ****/ - case "loosesprites/birds": // Game1.LoadContent - Game1.birdsSpriteSheet = content.Load(key); + case "data/movies": // MovieTheater.GetMovieData + case "data/moviesreactions": // MovieTheater.GetMovieReactions + MovieTheater.ClearCachedLocalizedData(); return true; - case "loosesprites/chatbox": // ChatBox constructor - if (Game1.chatBox?.chatBox != null) - { - Texture2D texture = content.Load(key); - - this.Reflection.GetField(Game1.chatBox.chatBox, "_textBoxTexture").SetValue(texture); - this.Reflection.GetField(Game1.chatBox.emojiMenu, "chatBoxTexture").SetValue(texture); - - return true; - } - return false; - - case "loosesprites/concessions": // Game1.LoadContent - Game1.concessionsSpriteSheet = content.Load(key); + case "data/npcgifttastes": // Game1.LoadContent + Game1.NPCGiftTastes = DataLoader.NpcGiftTastes(content); return true; - case "loosesprites/controllermaps": // Game1.LoadContent - Game1.controllerMaps = content.Load(key); + case "data/objects": // Game1.LoadContent + Game1.objectData = DataLoader.Objects(content); + ItemRegistry.ResetCache(); return true; - case "loosesprites/cursors": // Game1.LoadContent - Game1.mouseCursors = content.Load(key); - foreach (DayTimeMoneyBox menu in Game1.onScreenMenus.OfType()) - { - foreach (ClickableTextureComponent button in new[] { menu.questButton, menu.zoomInButton, menu.zoomOutButton }) - button.texture = Game1.mouseCursors; - } - - if (!ignoreWorld) - this.UpdateDoorSprites(content, assetName); + case "data/pants": // Game1.LoadContent + Game1.pantsData = DataLoader.Pants(content); + ItemRegistry.ResetCache(); return true; - case "loosesprites/cursors2": // Game1.LoadContent - Game1.mouseCursors2 = content.Load(key); + case "data/pets": // Game1.LoadContent + Game1.petData = DataLoader.Pets(content); + ItemRegistry.ResetCache(); return true; - case "loosesprites/daybg": // Game1.LoadContent - Game1.daybg = content.Load(key); + case "data/shirts": // Game1.LoadContent + Game1.shirtData = DataLoader.Shirts(content); + ItemRegistry.ResetCache(); return true; - case "loosesprites/emojis": // ChatBox constructor - if (Game1.chatBox != null) - { - Texture2D texture = content.Load(key); - - this.Reflection.GetField(Game1.chatBox.emojiMenu, "emojiTexture").SetValue(texture); - Game1.chatBox.emojiMenuIcon.texture = texture; - - return true; - } - return false; - - case "loosesprites/font_bold": // Game1.LoadContent - SpriteText.spriteTexture = content.Load(key); + case "data/tools": // Game1.LoadContent + Game1.toolData = DataLoader.Tools(content); + ItemRegistry.ResetCache(); return true; - case "loosesprites/font_colored": // Game1.LoadContent - SpriteText.coloredTexture = content.Load(key); + case "data/triggeractions": + TriggerActionManager.ResetDataCache(); return true; - case "loosesprites/giftbox": // Game1.LoadContent - Game1.giftboxTexture = content.Load(key); + case "data/weapons": // Game1.LoadContent + Game1.weaponData = DataLoader.Weapons(content); + ItemRegistry.ResetCache(); return true; - case "loosesprites/nightbg": // Game1.LoadContent - Game1.nightbg = content.Load(key); + case "data/wildtrees": // Tree + Tree.ClearCache(); return true; - case "loosesprites/shadow": // Game1.LoadContent - Game1.shadowTexture = content.Load(key); + case "data/worldmap": // WorldMapManager + WorldMapManager.ReloadData(); return true; - case "loosesprites/suspensionbridge": // SuspensionBridge constructor - return changed | (!ignoreWorld && this.UpdateSuspensionBridges(content, assetName)); - /**** - ** Content\Maps + ** Content\Fonts ****/ - case "maps/menutiles": // Game1.LoadContent - Game1.menuTexture = content.Load(key); + case "fonts/spritefont1": // Game1.LoadContent + Game1.dialogueFont = content.Load(key); return true; - case "maps/menutilesuncolored": // Game1.LoadContent - Game1.uncoloredMenuTexture = content.Load(key); + case "fonts/smallfont": // Game1.LoadContent + Game1.smallFont = content.Load(key); return true; - case "maps/springobjects": // Game1.LoadContent - Game1.objectSpriteSheet = content.Load(key); + case "fonts/tinyfont": // Game1.LoadContent + Game1.tinyFont = content.Load(key); return true; - /**** - ** Content\Minigames - ****/ - case "minigames/clouds": // TitleMenu - { - if (Game1.activeClickableMenu is TitleMenu titleMenu) - { - titleMenu.cloudsTexture = content.Load(key); - return true; - } - } - return changed; - - case "minigames/titlebuttons": // TitleMenu - return changed | this.UpdateTitleButtons(content, assetName); - /**** ** Content\Strings ****/ @@ -508,152 +460,20 @@ static ISet GetWarpSet(GameLocation location) return changed | this.UpdateStringsFromCsFiles(content); /**** - ** Content\TileSheets - ****/ - case "tilesheets/animations": // Game1.LoadContent - Game1.animations = content.Load(key); - return true; - - case "tilesheets/buffsicons": // Game1.LoadContent - Game1.buffsIcons = content.Load(key); - return true; - - case "tilesheets/bushes": // new Bush() - Bush.texture = new Lazy(() => content.Load(key)); - return true; - - case "tilesheets/chairtiles": // Game1.LoadContent - return this.UpdateChairTiles(content, assetName, ignoreWorld); - - case "tilesheets/craftables": // Game1.LoadContent - Game1.bigCraftableSpriteSheet = content.Load(key); - return true; - - case "tilesheets/critters": // Critter constructor - return changed | (!ignoreWorld && this.UpdateCritterTextures(assetName)); - - case "tilesheets/crops": // Game1.LoadContent - Game1.cropSpriteSheet = content.Load(key); - return true; - - case "tilesheets/debris": // Game1.LoadContent - Game1.debrisSpriteSheet = content.Load(key); - return true; - - case "tilesheets/emotes": // Game1.LoadContent - Game1.emoteSpriteSheet = content.Load(key); - return true; - - case "tilesheets/fruittrees": // FruitTree - FruitTree.texture = content.Load(key); - return true; - - case "tilesheets/furniture": // Game1.LoadContent - Furniture.furnitureTexture = content.Load(key); - return true; - - case "tilesheets/furniturefront": // Game1.LoadContent - Furniture.furnitureFrontTexture = content.Load(key); - return true; - - case "tilesheets/projectiles": // Game1.LoadContent - Projectile.projectileSheet = content.Load(key); - return true; - - case "tilesheets/rain": // Game1.LoadContent - Game1.rainTexture = content.Load(key); - return true; - - case "tilesheets/tools": // Game1.ResetToolSpriteSheet - Game1.ResetToolSpriteSheet(); - return true; - - case "tilesheets/weapons": // Game1.LoadContent - Tool.weaponsTexture = content.Load(key); - return true; - - /**** - ** Content\TerrainFeatures + ** Dynamic keys ****/ - case "terrainfeatures/flooring": // from Flooring - Flooring.floorsTexture = content.Load(key); - return true; - - case "terrainfeatures/flooring_winter": // from Flooring - Flooring.floorsTextureWinter = content.Load(key); - return true; - - case "terrainfeatures/grass": // from Grass - return !ignoreWorld && this.UpdateGrassTextures(content, assetName); - - case "terrainfeatures/hoedirt": // from HoeDirt - HoeDirt.lightTexture = content.Load(key); - return true; - - case "terrainfeatures/hoedirtdark": // from HoeDirt - HoeDirt.darkTexture = content.Load(key); - return true; - - case "terrainfeatures/hoedirtsnow": // from HoeDirt - HoeDirt.snowTexture = content.Load(key); - return true; - - case "terrainfeatures/mushroom_tree": // from Tree - return changed | (!ignoreWorld && this.UpdateTreeTextures(Tree.mushroomTree)); - - case "terrainfeatures/tree_palm": // from Tree - return changed | (!ignoreWorld && this.UpdateTreeTextures(Tree.palmTree)); - - case "terrainfeatures/tree1_fall": // from Tree - case "terrainfeatures/tree1_spring": // from Tree - case "terrainfeatures/tree1_summer": // from Tree - case "terrainfeatures/tree1_winter": // from Tree - return changed | (!ignoreWorld && this.UpdateTreeTextures(Tree.bushyTree)); - - case "terrainfeatures/tree2_fall": // from Tree - case "terrainfeatures/tree2_spring": // from Tree - case "terrainfeatures/tree2_summer": // from Tree - case "terrainfeatures/tree2_winter": // from Tree - return changed | (!ignoreWorld && this.UpdateTreeTextures(Tree.leafyTree)); - - case "terrainfeatures/tree3_fall": // from Tree - case "terrainfeatures/tree3_spring": // from Tree - case "terrainfeatures/tree3_winter": // from Tree - return changed | (!ignoreWorld && this.UpdateTreeTextures(Tree.pineTree)); - } - - /**** - ** Dynamic assets - ****/ - if (!ignoreWorld) - { - // dynamic textures - if (assetName.IsDirectlyUnderPath("Animals")) - { - if (assetName.StartsWith("animals/cat")) - return changed | this.UpdatePetOrHorseSprites(assetName); - - if (assetName.StartsWith("animals/dog")) - return changed | this.UpdatePetOrHorseSprites(assetName); - - return changed | this.UpdateFarmAnimalSprites(assetName); - } - - if (assetName.IsDirectlyUnderPath("Buildings")) - return changed | this.UpdateBuildings(assetName); - - if (assetName.StartsWith("LooseSprites/Fence")) - return changed | this.UpdateFenceTextures(assetName); + default: + if (!ignoreWorld) + { + if (assetName.IsDirectlyUnderPath("Characters/Dialogue")) + return changed | this.UpdateNpcDialogue(assetName); - // dynamic data - if (assetName.IsDirectlyUnderPath("Characters/Dialogue")) - return changed | this.UpdateNpcDialogue(assetName); + if (assetName.IsDirectlyUnderPath("Characters/schedules")) + return changed | this.UpdateNpcSchedules(assetName); + } - if (assetName.IsDirectlyUnderPath("Characters/schedules")) - return changed | this.UpdateNpcSchedules(assetName); + return false; } - - return false; } @@ -663,315 +483,34 @@ static ISet GetWarpSet(GameLocation location) /**** ** Update texture methods ****/ - /// Update buttons on the title screen. - /// The content manager through which to update the asset. - /// The asset name to update. - /// Returns whether any references were updated. - /// Derived from the constructor and . - private bool UpdateTitleButtons(LocalizedContentManager content, IAssetName assetName) - { - if (Game1.activeClickableMenu is TitleMenu titleMenu) - { - Texture2D texture = content.Load(assetName.BaseName); - - titleMenu.titleButtonsTexture = texture; - titleMenu.backButton.texture = texture; - titleMenu.aboutButton.texture = texture; - titleMenu.languageButton.texture = texture; - foreach (ClickableTextureComponent button in titleMenu.buttons) - button.texture = texture; - foreach (TemporaryAnimatedSprite bird in titleMenu.birds) - bird.texture = texture; - - return true; - } - - return false; - } - - /// Update the sprites for matching pets or horses. - /// The animal type. - /// The asset name to update. - /// Returns whether any references were updated. - private bool UpdatePetOrHorseSprites(IAssetName assetName) - where TAnimal : NPC - { - // find matches - TAnimal[] animals = this.GetCharacters() - .OfType() - .Where(p => this.IsSameBaseName(assetName, p.Sprite?.spriteTexture?.Name)) - .ToArray(); - - // update sprites - bool changed = false; - foreach (TAnimal animal in animals) - changed |= this.MarkSpriteDirty(animal.Sprite); - return changed; - } - - /// Update the sprites for matching farm animals. + /// Update building paint mask textures. /// The asset name to update. - /// Returns whether any references were updated. - /// Derived from . - private bool UpdateFarmAnimalSprites(IAssetName assetName) + /// Returns whether any textures were updated. + private bool UpdateBuildingPaintMask(IAssetName assetName) { - // find matches - FarmAnimal[] animals = this.GetFarmAnimals().ToArray(); - if (!animals.Any()) - return false; - - // update sprites - bool changed = true; - foreach (FarmAnimal animal in animals) - { - // get expected key - string expectedKey = animal.age.Value < animal.ageWhenMature.Value - ? $"Baby{(animal.type.Value == "Duck" ? "White Chicken" : animal.type.Value)}" - : animal.type.Value; - if (animal.showDifferentTextureWhenReadyForHarvest.Value && animal.currentProduce.Value <= 0) - expectedKey = $"Sheared{expectedKey}"; - expectedKey = $"Animals/{expectedKey}"; - - // reload asset - if (this.IsSameBaseName(assetName, expectedKey)) - changed |= this.MarkSpriteDirty(animal.Sprite); - } - return changed; - } - - /// Update building textures. - /// The asset name to update. - /// Returns whether any references were updated. - private bool UpdateBuildings(IAssetName assetName) - { - // get paint mask info - const string paintMaskSuffix = "_PaintMask"; - bool isPaintMask = assetName.BaseName.EndsWith(paintMaskSuffix, StringComparison.OrdinalIgnoreCase); - - // get building type - string type = Path.GetFileName(assetName.BaseName); - if (isPaintMask) - type = type[..^paintMaskSuffix.Length]; - - // get buildings - Building[] buildings = this.GetLocations(buildingInteriors: false) - .OfType() - .SelectMany(p => p.buildings) - .Where(p => p.buildingType.Value == type) - .ToArray(); - // remove from paint mask cache - bool removedFromCache = this.RemoveFromPaintMaskCache(assetName); - - // reload textures - if (buildings.Any()) - { - foreach (Building building in buildings) - building.resetTexture(); - - return true; - } - - return removedFromCache; - } - - /// Update map seat textures. - /// The content manager through which to reload the asset. - /// The asset name to update. - /// Whether the in-game world is fully unloaded (e.g. on the title screen), so there's no need to propagate changes into the world. - /// Returns whether any references were updated. - private bool UpdateChairTiles(LocalizedContentManager content, IAssetName assetName, bool ignoreWorld) - { - MapSeat.mapChairTexture = content.Load(assetName.BaseName); - - if (!ignoreWorld) - { - foreach (GameLocation location in this.GetLocations()) - { - foreach (MapSeat seat in location.mapSeats.Where(p => p != null)) - { - if (this.IsSameBaseName(assetName, seat._loadedTextureFile)) - seat._loadedTextureFile = null; - } - } - } - - return true; - } - - /// Update critter textures. - /// The asset name to update. - /// Returns whether any references were updated. - private bool UpdateCritterTextures(IAssetName assetName) - { - // get critters - Critter[] critters = - ( - from location in this.GetLocations() - where location.critters != null - from Critter critter in location.critters - where this.IsSameBaseName(assetName, critter.sprite?.spriteTexture?.Name) - select critter - ) - .ToArray(); - - // update sprites - bool changed = false; - foreach (Critter entry in critters) - changed |= this.MarkSpriteDirty(entry.sprite); - return changed; - } - - /// Update the sprites for interior doors. - /// The content manager through which to reload the asset. - /// The asset name to update. - /// Returns whether any references were updated. - private void UpdateDoorSprites(LocalizedContentManager content, IAssetName assetName) - { - Lazy texture = new Lazy(() => content.Load(assetName.BaseName)); + bool removedFromCache = BuildingPainter.paintMaskLookup.Remove(assetName.BaseName); - foreach (GameLocation location in this.GetLocations()) - { - IEnumerable? doors = location.interiorDoors?.Doors; - if (doors == null) - continue; - - foreach (InteriorDoor? door in doors) - { - if (door?.Sprite == null) - continue; - - string? curKey = this.Reflection.GetField(door.Sprite, "textureName").GetValue(); - if (this.IsSameBaseName(assetName, curKey)) - door.Sprite.texture = texture.Value; - } - } - } - - /// Update the sprites for a fence type. - /// The asset name to update. - /// Returns whether any references were updated. - private bool UpdateFenceTextures(IAssetName assetName) - { - // get fence type (e.g. LooseSprites/Fence3 => 3) - if (!int.TryParse(this.GetSegments(assetName.BaseName)[1]["Fence".Length..], out int fenceType)) - return false; - - // get fences - Fence[] fences = - ( - from location in this.GetLocations() - from fence in location.Objects.Values.OfType() - where - fence.whichType.Value == fenceType - || (fence.isGate.Value && fenceType == 1) // gates are hardcoded to draw fence type 1 - select fence - ) - .ToArray(); - - // update fence textures - bool changed = false; - foreach (Fence fence in fences) - { - if (fence.fenceTexture.IsValueCreated) - { - fence.fenceTexture = new Lazy(fence.loadFenceTexture); - changed = true; - } - } - return changed; - } - - /// Update tree textures. - /// The content manager through which to reload the asset. - /// The asset name to update. - /// Returns whether any references were updated. - private bool UpdateGrassTextures(LocalizedContentManager content, IAssetName assetName) - { - Grass[] grasses = - ( - from grass in this.GetTerrainFeatures().OfType() - where this.IsSameBaseName(assetName, grass.textureName()) - select grass - ) - .ToArray(); - - bool changed = false; - foreach (Grass grass in grasses) - { - if (grass.texture.IsValueCreated) - { - grass.texture = new Lazy(() => content.Load(assetName.BaseName)); - changed = true; - } - } - return changed; - } - - /// Update the sprites for matching NPCs. - /// The asset names being propagated. - private void UpdateNpcSprites(IDictionary propagated) - { - // get NPCs - var characters = - ( - from npc in this.GetCharacters() - let key = this.ParseAssetNameOrNull(npc.Sprite?.spriteTexture?.Name)?.GetBaseAssetName() - where key != null && propagated.ContainsKey(key) - select new { Npc = npc, AssetName = key } - ) - .ToArray(); - - // update sprite - foreach (var target in characters) - { - if (this.MarkSpriteDirty(target.Npc.Sprite)) - propagated[target.AssetName] = true; - } - } - - /// Update the portraits for matching NPCs. - /// The asset names being propagated. - private void UpdateNpcPortraits(IDictionary propagated) - { - // get NPCs - var characters = - ( - from npc in this.GetCharacters() - where npc.isVillager() - - let key = this.ParseAssetNameOrNull(npc.Portrait?.Name)?.GetBaseAssetName() - where key != null && propagated.ContainsKey(key) - select new { Npc = npc, AssetName = key } - ) - .ToList(); - - // special case: Gil is a private NPC field on the AdventureGuild class (only used for the portrait) + // reload building textures + bool anyReloaded = false; + foreach (GameLocation location in this.GetLocations(buildingInteriors: false)) { - IAssetName gilKey = this.ParseAssetName("Portraits/Gil"); - if (propagated.ContainsKey(gilKey)) + foreach (Building building in location.buildings) { - GameLocation adventureGuild = Game1.getLocationFromName("AdventureGuild"); - if (adventureGuild != null) + if (building.paintedTexture != null && assetName.IsEquivalentTo(building.textureName() + "_PaintMask")) { - NPC? gil = this.Reflection.GetField(adventureGuild, "Gil").GetValue(); - if (gil != null) - characters.Add(new { Npc = gil, AssetName = gilKey }); + anyReloaded = true; + building.resetTexture(); } } } - // update portrait - foreach (var target in characters) - { - target.Npc.resetPortrait(); - propagated[target.AssetName] = true; - } + return removedFromCache || anyReloaded; } /// Update the sprites for matching players. /// The asset name to update. - private bool UpdatePlayerSprites(IAssetName assetName) + private void UpdatePlayerSprites(IAssetName assetName) { Farmer[] players = ( @@ -983,77 +522,10 @@ select player foreach (Farmer player in players) { - var recolorOffsets = this.Reflection.GetField>>?>(typeof(FarmerRenderer), "_recolorOffsets").GetValue(); - recolorOffsets?.Clear(); + FarmerRenderer.recolorOffsets?.Clear(); player.FarmerRenderer.MarkSpriteDirty(); } - - return players.Any(); - } - - /// Update suspension bridge textures. - /// The content manager through which to reload the asset. - /// The asset name to update. - /// Returns whether any references were updated. - private bool UpdateSuspensionBridges(LocalizedContentManager content, IAssetName assetName) - { - Lazy texture = new Lazy(() => content.Load(assetName.BaseName)); - - foreach (GameLocation location in this.GetLocations(buildingInteriors: false)) - { - // get suspension bridges field - var field = this.Reflection.GetField?>(location, nameof(IslandNorth.suspensionBridges), required: false); - // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract -- field is nullable when required: false - if (field == null || !typeof(IEnumerable).IsAssignableFrom(field.FieldInfo.FieldType)) - continue; - - // update textures - IEnumerable? bridges = field.GetValue(); - if (bridges != null) - { - foreach (SuspensionBridge bridge in bridges) - this.Reflection.GetField(bridge, "_texture").SetValue(texture.Value); - } - } - - return texture.IsValueCreated; - } - - /// Update tree textures. - /// The type to update. - /// Returns whether any references were updated. - private bool UpdateTreeTextures(int type) - { - Tree[] trees = this - .GetTerrainFeatures() - .OfType() - .Where(tree => tree.treeType.Value == type) - .ToArray(); - - bool changed = false; - foreach (Tree tree in trees) - { - if (tree.texture.IsValueCreated) - { - this.Reflection.GetMethod(tree, "resetTexture").Invoke(); - changed = true; - } - } - return changed; - } - - /// Mark an animated sprite's texture dirty, so it's reloaded next time it's rendered. - /// The animated sprite to change. - /// Returns whether the sprite was changed. - private bool MarkSpriteDirty(AnimatedSprite sprite) - { - if (sprite.loadedTexture is null && sprite.spriteTexture is null) - return false; - - sprite.loadedTexture = null; - sprite.spriteTexture = null; - return true; } /**** @@ -1062,16 +534,14 @@ private bool MarkSpriteDirty(AnimatedSprite sprite) /// Update the data for matching farm animals. /// Returns whether any farm animals were updated. /// Derived from the constructor. - private bool UpdateFarmAnimalData() + private void UpdateFarmAnimalData() { - bool changed = false; foreach (FarmAnimal animal in this.GetFarmAnimals()) { - animal.reloadData(); - changed = true; + var data = animal.GetAnimalData(); + if (data != null) + animal.buildingTypeILiveIn.Value = data.House; } - - return changed; } /// Update hair style metadata. @@ -1119,24 +589,14 @@ private bool UpdateNpcDialogue(IAssetName assetName) return true; } - /// Update the disposition data for matching NPCs. - /// The content manager through which to reload the asset. - /// The asset name to update. - /// Returns whether any NPCs were updated. - private bool UpdateNpcDispositions(LocalizedContentManager content, IAssetName assetName) + /// Update the character data for matching NPCs. + private void UpdateCharacterData() { - IDictionary data = content.Load>(assetName.BaseName); - bool changed = false; foreach (NPC npc in this.GetCharacters()) { - if (npc.isVillager() && data.ContainsKey(npc.Name)) - { + if (npc.isVillager()) npc.reloadData(); - changed = true; - } } - - return changed; } /// Update the schedules for matching NPCs. @@ -1156,7 +616,7 @@ private bool UpdateNpcSchedules(IAssetName assetName) // reload schedule this.Reflection.GetField(villager, "_hasLoadedMasterScheduleData").SetValue(false); this.Reflection.GetField?>(villager, "_masterScheduleData").SetValue(null); - villager.Schedule = villager.getSchedule(Game1.dayOfMonth); + villager.TryLoadSchedule(); // switch to new schedule if needed if (villager.Schedule != null) @@ -1270,14 +730,9 @@ private IEnumerable GetFarmAnimals() foreach (GameLocation location in this.GetLocations()) { - if (location is Farm farm) - { - foreach (FarmAnimal animal in farm.animals.Values) - animals.Add(animal); - } - else if (location is AnimalHouse animalHouse) + if (location.animals.Length > 0) { - foreach (FarmAnimal animal in animalHouse.animals.Values) + foreach (FarmAnimal animal in location.animals.Values) animals.Add(animal); } } @@ -1319,7 +774,7 @@ private IEnumerable GetLocationsWithInfo(bool buildingInteriors = // get child locations if (buildingInteriors) { - foreach (BuildableGameLocation location in locations.Select(p => p.Location).OfType().ToArray()) + foreach (GameLocation location in locations.Select(p => p.Location).ToArray()) { foreach (Building building in location.buildings) { @@ -1334,15 +789,6 @@ private IEnumerable GetLocationsWithInfo(bool buildingInteriors = }); } - /// Get all terrain features in the game. - private IEnumerable GetTerrainFeatures() - { - return this.WorldCache.GetOrSet( - $"{nameof(this.GetTerrainFeatures)}", - () => this.GetLocations().SelectMany(p => p.terrainFeatures.Values).ToArray() - ); - } - /// Get whether two asset names are equivalent if you ignore the locale code. /// The first value to compare. /// The second value to compare. @@ -1376,35 +822,6 @@ private bool IsSameBaseName(IAssetName? left, IAssetName? right) return this.ParseAssetName(path); } - /// Get the segments in a path (e.g. 'a/b' is 'a' and 'b'). - /// The path to check. - private string[] GetSegments(string? path) - { - return path != null - ? PathUtilities.GetSegments(path) - : Array.Empty(); - } - - /// Load a texture from the main content manager. - /// The asset key to load. - private Texture2D LoadTexture(string key) - { - return this.MainContentManager.Load(key); - } - - /// Remove a case-insensitive key from the paint mask cache. - /// The paint mask asset name. - private bool RemoveFromPaintMaskCache(IAssetName assetName) - { - // make cache case-insensitive - // This is needed for cache invalidation since mods may specify keys with a different capitalization - if (!object.ReferenceEquals(BuildingPainter.paintMaskLookup.Comparer, StringComparer.OrdinalIgnoreCase)) - BuildingPainter.paintMaskLookup = new Dictionary>>(BuildingPainter.paintMaskLookup, StringComparer.OrdinalIgnoreCase); - - // remove key from cache - return BuildingPainter.paintMaskLookup.Remove(assetName.BaseName); - } - /// Metadata about a location used in asset propagation. /// The location instance. /// The building which contains the location, if any. diff --git a/src/SMAPI/Metadata/InstructionMetadata.cs b/src/SMAPI/Metadata/InstructionMetadata.cs index 1024b19c1..5f5fe8e4a 100644 --- a/src/SMAPI/Metadata/InstructionMetadata.cs +++ b/src/SMAPI/Metadata/InstructionMetadata.cs @@ -1,11 +1,41 @@ +using System; using System.Collections.Generic; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Audio; using Microsoft.Xna.Framework.Graphics; +using Netcode; using StardewModdingAPI.Events; using StardewModdingAPI.Framework.ModLoading; using StardewModdingAPI.Framework.ModLoading.Finders; using StardewModdingAPI.Framework.ModLoading.Rewriters; using StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_5; +using StardewModdingAPI.Framework.ModLoading.Rewriters.StardewValley_1_6; using StardewValley; +using StardewValley.Audio; +using StardewValley.BellsAndWhistles; +using StardewValley.Buildings; +using StardewValley.Enchantments; +using StardewValley.GameData; +using StardewValley.GameData.FloorsAndPaths; +using StardewValley.GameData.Movies; +using StardewValley.GameData.SpecialOrders; +using StardewValley.Locations; +using StardewValley.Menus; +using StardewValley.Minigames; +using StardewValley.Mods; +using StardewValley.Network; +using StardewValley.Objects; +using StardewValley.Pathfinding; +using StardewValley.Projectiles; +using StardewValley.Quests; +using StardewValley.SpecialOrders; +using StardewValley.SpecialOrders.Objectives; +using StardewValley.SpecialOrders.Rewards; +using StardewValley.TerrainFeatures; +using StardewValley.Tools; +using xTile.Layers; +using static StardewValley.Projectiles.BasicProjectile; +using SObject = StardewValley.Object; namespace StardewModdingAPI.Metadata { @@ -26,7 +56,8 @@ internal class InstructionMetadata /// Get rewriters which detect or fix incompatible CIL instructions in mod assemblies. /// Whether to detect paranoid mode issues. /// Whether to get handlers which rewrite mods for compatibility. - public IEnumerable GetHandlers(bool paranoidMode, bool rewriteMods) + /// Whether to include more technical details about broken mods in the TRACE logs. This is mainly useful for creating compatibility rewriters. + public IEnumerable GetHandlers(bool paranoidMode, bool rewriteMods, bool logTechnicalDetailsForBrokenMods) { /**** ** rewrite CIL to fix incompatible code @@ -34,51 +65,256 @@ public IEnumerable GetHandlers(bool paranoidMode, bool rewr // rewrite for crossplatform compatibility if (rewriteMods) { - // heuristic rewrites - yield return new HeuristicFieldRewriter(this.ValidateReferencesToAssemblies); - yield return new HeuristicMethodRewriter(this.ValidateReferencesToAssemblies); - // specific versions yield return new ReplaceReferencesRewriter() - // Stardew Valley 1.5 (fields moved) + /**** + ** Stardew Valley 1.5 + ****/ + // fields moved .MapField("Netcode.NetCollection`1 StardewValley.Locations.DecoratableLocation::furniture", typeof(GameLocation), nameof(GameLocation.furniture)) .MapField("Netcode.NetCollection`1 StardewValley.Farm::resourceClumps", typeof(GameLocation), nameof(GameLocation.resourceClumps)) .MapField("Netcode.NetCollection`1 StardewValley.Locations.MineShaft::resourceClumps", typeof(GameLocation), nameof(GameLocation.resourceClumps)) - // Stardew Valley 1.5.5 (XNA => MonoGame method changes) - .MapFacade(); + /**** + ** Stardew Valley 1.5.5 + ****/ + // XNA => MonoGame method changes + .MapFacade() - // 32-bit to 64-bit in Stardew Valley 1.5.5 - yield return new ArchitectureAssemblyRewriter(); + /**** + ** Stardew Valley 1.6 + ****/ + // moved types (audio) + .MapType("StardewValley.AudioCategoryWrapper", typeof(AudioCategoryWrapper)) + .MapType("StardewValley.AudioEngineWrapper", typeof(AudioEngineWrapper)) + .MapType("StardewValley.DummyAudioCategory", typeof(DummyAudioCategory)) + .MapType("StardewValley.DummyAudioEngine", typeof(DummyAudioEngine)) + .MapType("StardewValley.IAudioCategory", typeof(IAudioCategory)) + .MapType("StardewValley.IAudioEngine", typeof(IAudioEngine)) + .MapType("StardewValley.Network.NetAudio/SoundContext", typeof(SoundContext)) + + // moved types (enchantments) + .MapType("StardewValley.AmethystEnchantment", typeof(AmethystEnchantment)) + .MapType("StardewValley.AquamarineEnchantment", typeof(AquamarineEnchantment)) + .MapType("StardewValley.ArchaeologistEnchantment", typeof(ArchaeologistEnchantment)) + .MapType("StardewValley.ArtfulEnchantment", typeof(ArtfulEnchantment)) + .MapType("StardewValley.AutoHookEnchantment", typeof(AutoHookEnchantment)) + .MapType("StardewValley.AxeEnchantment", typeof(AxeEnchantment)) + .MapType("StardewValley.BaseEnchantment", typeof(BaseEnchantment)) + .MapType("StardewValley.BaseWeaponEnchantment", typeof(BaseWeaponEnchantment)) + .MapType("StardewValley.BottomlessEnchantment", typeof(BottomlessEnchantment)) + .MapType("StardewValley.BugKillerEnchantment", typeof(BugKillerEnchantment)) + .MapType("StardewValley.CrusaderEnchantment", typeof(CrusaderEnchantment)) + .MapType("StardewValley.DiamondEnchantment", typeof(DiamondEnchantment)) + .MapType("StardewValley.EfficientToolEnchantment", typeof(EfficientToolEnchantment)) + .MapType("StardewValley.EmeraldEnchantment", typeof(EmeraldEnchantment)) + .MapType("StardewValley.FishingRodEnchantment", typeof(FishingRodEnchantment)) + .MapType("StardewValley.GalaxySoulEnchantment", typeof(GalaxySoulEnchantment)) + .MapType("StardewValley.GenerousEnchantment", typeof(GenerousEnchantment)) + .MapType("StardewValley.HaymakerEnchantment", typeof(HaymakerEnchantment)) + .MapType("StardewValley.HoeEnchantment", typeof(HoeEnchantment)) + .MapType("StardewValley.JadeEnchantment", typeof(JadeEnchantment)) + .MapType("StardewValley.MagicEnchantment", typeof(MagicEnchantment)) + .MapType("StardewValley.MasterEnchantment", typeof(MasterEnchantment)) + .MapType("StardewValley.MilkPailEnchantment", typeof(MilkPailEnchantment)) + .MapType("StardewValley.PanEnchantment", typeof(PanEnchantment)) + .MapType("StardewValley.PickaxeEnchantment", typeof(PickaxeEnchantment)) + .MapType("StardewValley.PowerfulEnchantment", typeof(PowerfulEnchantment)) + .MapType("StardewValley.PreservingEnchantment", typeof(PreservingEnchantment)) + .MapType("StardewValley.ReachingToolEnchantment", typeof(ReachingToolEnchantment)) + .MapType("StardewValley.RubyEnchantment", typeof(RubyEnchantment)) + .MapType("StardewValley.ShavingEnchantment", typeof(ShavingEnchantment)) + .MapType("StardewValley.ShearsEnchantment", typeof(ShearsEnchantment)) + .MapType("StardewValley.SwiftToolEnchantment", typeof(SwiftToolEnchantment)) + .MapType("StardewValley.TopazEnchantment", typeof(TopazEnchantment)) + .MapType("StardewValley.VampiricEnchantment", typeof(VampiricEnchantment)) + .MapType("StardewValley.WateringCanEnchantment", typeof(WateringCanEnchantment)) + + // moved types (special orders) + .MapType("StardewValley.SpecialOrder", typeof(SpecialOrder)) + .MapType("StardewValley.SpecialOrder/QuestDuration", typeof(QuestDuration)) + .MapType("StardewValley.SpecialOrder/QuestState", typeof(SpecialOrderStatus)) + + .MapType("StardewValley.CollectObjective", typeof(CollectObjective)) + .MapType("StardewValley.DeliverObjective", typeof(DeliverObjective)) + .MapType("StardewValley.DonateObjective", typeof(DonateObjective)) + .MapType("StardewValley.FishObjective", typeof(FishObjective)) + .MapType("StardewValley.GiftObjective", typeof(GiftObjective)) + .MapType("StardewValley.JKScoreObjective", typeof(JKScoreObjective)) + .MapType("StardewValley.OrderObjective", typeof(OrderObjective)) + .MapType("StardewValley.ReachMineFloorObjective", typeof(ReachMineFloorObjective)) + .MapType("StardewValley.ShipObjective", typeof(ShipObjective)) + .MapType("StardewValley.SlayObjective", typeof(SlayObjective)) + + .MapType("StardewValley.FriendshipReward", typeof(FriendshipReward)) + .MapType("StardewValley.GemsReward", typeof(GemsReward)) + .MapType("StardewValley.MailReward", typeof(MailReward)) + .MapType("StardewValley.MoneyReward", typeof(MoneyReward)) + .MapType("StardewValley.OrderReward", typeof(OrderReward)) + .MapType("StardewValley.ResetEventReward", typeof(ResetEventReward)) + + // moved types (other) + .MapType("LocationWeather", typeof(LocationWeather)) + .MapType("WaterTiles", typeof(WaterTiles)) + .MapType("StardewValley.Game1/MusicContext", typeof(MusicContext)) + .MapType("StardewValley.ModDataDictionary", typeof(ModDataDictionary)) + .MapType("StardewValley.ModHooks", typeof(ModHooks)) + .MapType("StardewValley.Network.IWorldState", typeof(NetWorldState)) + .MapType("StardewValley.PathFindController", typeof(PathFindController)) + .MapType("StardewValley.SchedulePathDescription", typeof(SchedulePathDescription)) + + // deleted delegates + .MapType("StardewValley.DelayedAction/delayedBehavior", typeof(Action)) + + // field renames + .MapFieldName(typeof(FloorPathData), "ID", nameof(FloorPathData.Id)) + .MapFieldName(typeof(ModFarmType), "ID", nameof(ModFarmType.Id)) + .MapFieldName(typeof(ModLanguage), "ID", nameof(ModLanguage.Id)) + .MapFieldName(typeof(ModWallpaperOrFlooring), "ID", nameof(ModWallpaperOrFlooring.Id)) + .MapFieldName(typeof(MovieData), "ID", nameof(MovieData.Id)) + .MapFieldName(typeof(MovieReaction), "ID", nameof(MovieReaction.Id)) + .MapFieldName(typeof(MovieScene), "ID", nameof(MovieScene.Id)) - // detect Harmony & rewrite for SMAPI 3.12 (Harmony 1.x => 2.0 update) - yield return new HarmonyRewriter(); + // general API changes + // note: types are mapped before members, regardless of the order listed here + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapType("StardewValley.Buildings.Mill", typeof(Building)) + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade("StardewValley.Tools.ToolFactory", typeof(ToolFactoryFacade)) + .MapFacade() + .MapFacade() + .MapFacade() + .MapFacade("Microsoft.Xna.Framework.Graphics.ViewportExtensions", typeof(ViewportExtensionsFacade)) + .MapFacade() + .MapFacade() + .MapFacade() -#if SMAPI_DEPRECATED - // detect issues for SMAPI 4.0.0 - yield return new LegacyAssemblyFinder(); -#endif + // Mono.Cecil seems to have trouble resolving rewritten signatures which include a nested type like `StardewValley.BellsAndWhistles.SpriteText/ScrollTextAlignment` + .MapMethod("System.Void StardewValley.BellsAndWhistles.SpriteText::drawString(Microsoft.Xna.Framework.Graphics.SpriteBatch,System.String,System.Int32,System.Int32,System.Int32,System.Int32,System.Int32,System.Single,System.Single,System.Boolean,System.Int32,System.String,System.Int32,StardewValley.BellsAndWhistles.SpriteText/ScrollTextAlignment)", typeof(SpriteTextFacade), nameof(SpriteTextFacade.drawString)) + .MapMethod("System.Void StardewValley.BellsAndWhistles.SpriteText::drawStringWithScrollBackground(Microsoft.Xna.Framework.Graphics.SpriteBatch,System.String,System.Int32,System.Int32,System.String,System.Single,System.Int32,StardewValley.BellsAndWhistles.SpriteText/ScrollTextAlignment)", typeof(SpriteTextFacade), nameof(SpriteTextFacade.drawStringWithScrollBackground)) + .MapMethod("System.Void StardewValley.Projectiles.BasicProjectile::.ctor(System.Int32,System.Int32,System.Int32,System.Int32,System.Single,System.Single,System.Single,Microsoft.Xna.Framework.Vector2,System.String,System.String,System.Boolean,System.Boolean,StardewValley.GameLocation,StardewValley.Character,System.Boolean,StardewValley.Projectiles.BasicProjectile/onCollisionBehavior)", typeof(BasicProjectileFacade), nameof(BasicProjectileFacade.Constructor), new[] { typeof(int), typeof(int), typeof(int), typeof(int), typeof(float), typeof(float), typeof(float), typeof(Vector2), typeof(string), typeof(string), typeof(bool), typeof(bool), typeof(GameLocation), typeof(Character), typeof(bool), typeof(onCollisionBehavior) }) + .MapMethod("System.String StardewValley.LocalizedContentManager::LanguageCodeString(StardewValley.LocalizedContentManager/LanguageCode)", typeof(LocalizedContentManagerFacade), nameof(LocalizedContentManager.LanguageCodeString)) + + // BuildableGameLocation merged into GameLocation + .MapFacade("StardewValley.Locations.BuildableGameLocation", typeof(BuildableGameLocationFacade)) + .MapField("Netcode.NetCollection`1 StardewValley.Locations.BuildableGameLocation::buildings", typeof(GameLocation), nameof(GameLocation.buildings)) + + // OverlaidDictionary enumerators changed + // note: types are mapped before members, regardless of the order listed here + .MapType("StardewValley.Network.OverlaidDictionary/KeysCollection", typeof(OverlaidDictionaryFacade.KeysCollection)) + .MapType("StardewValley.Network.OverlaidDictionary/KeysCollection/Enumerator", typeof(OverlaidDictionaryFacade.KeysCollection.Enumerator)) + .MapType("StardewValley.Network.OverlaidDictionary/PairsCollection", typeof(OverlaidDictionaryFacade.PairsCollection)) + .MapType("StardewValley.Network.OverlaidDictionary/PairsCollection/Enumerator", typeof(OverlaidDictionaryFacade.PairsCollection.Enumerator)) + .MapType("StardewValley.Network.OverlaidDictionary/ValuesCollection", typeof(OverlaidDictionaryFacade.ValuesCollection)) + .MapType("StardewValley.Network.OverlaidDictionary/ValuesCollection/Enumerator", typeof(OverlaidDictionaryFacade.ValuesCollection.Enumerator)) + .MapMethod($"{typeof(OverlaidDictionaryFacade).FullName}/{nameof(OverlaidDictionaryFacade.KeysCollection)} StardewValley.Network.OverlaidDictionary::get_Keys()", typeof(OverlaidDictionaryFacade), $"get_{nameof(OverlaidDictionaryFacade.Keys)}") + .MapMethod($"{typeof(OverlaidDictionaryFacade).FullName}/{nameof(OverlaidDictionaryFacade.PairsCollection)} StardewValley.Network.OverlaidDictionary::get_Pairs()", typeof(OverlaidDictionaryFacade), $"get_{nameof(OverlaidDictionaryFacade.Pairs)}") + .MapMethod($"{typeof(OverlaidDictionaryFacade).FullName}/{nameof(OverlaidDictionaryFacade.ValuesCollection)} StardewValley.Network.OverlaidDictionary::get_Values()", typeof(OverlaidDictionaryFacade), $"get_{nameof(OverlaidDictionaryFacade.Values)}") + + // implicit NetField conversions removed + .MapMethod("Netcode.NetFieldBase`2::op_Implicit", typeof(NetFieldBaseFacade<,>), "op_Implicit") + .MapMethod("System.Int64 Netcode.NetLong::op_Implicit(Netcode.NetLong)", typeof(NetLongFacade), nameof(NetLongFacade.op_Implicit)) + .MapMethod("System.Int32 StardewValley.Network.NetDirection::op_Implicit(StardewValley.Network.NetDirection)", typeof(ImplicitConversionOperators), nameof(ImplicitConversionOperators.NetDirection_ToInt)) + .MapMethod("!0 StardewValley.Network.NetPausableField`3::op_Implicit(StardewValley.Network.NetPausableField`3)", typeof(NetPausableFieldFacade), nameof(NetPausableFieldFacade.op_Implicit)); + + // heuristic rewrites + yield return new HeuristicFieldRewriter(this.ValidateReferencesToAssemblies); + yield return new HeuristicMethodRewriter(this.ValidateReferencesToAssemblies); + + // 32-bit to 64-bit in Stardew Valley 1.5.5 + yield return new ArchitectureAssemblyRewriter(); } - else - yield return new HarmonyRewriter(shouldRewrite: false); /**** ** detect mod issues ****/ + // Harmony usage + yield return new HarmonyDetector(); + // broken code - yield return new ReferenceToMissingMemberFinder(this.ValidateReferencesToAssemblies); - yield return new ReferenceToMemberWithUnexpectedTypeFinder(this.ValidateReferencesToAssemblies); + yield return new ReferenceToInvalidMemberFinder(this.ValidateReferencesToAssemblies, logTechnicalDetailsForBrokenMods); // code which may impact game stability yield return new FieldFinder(typeof(SaveGame).FullName!, new[] { nameof(SaveGame.serializer), nameof(SaveGame.farmerSerializer), nameof(SaveGame.locationSerializer) }, InstructionHandleResult.DetectedSaveSerializer); yield return new EventFinder(typeof(ISpecializedEvents).FullName!, new[] { nameof(ISpecializedEvents.UnvalidatedUpdateTicked), nameof(ISpecializedEvents.UnvalidatedUpdateTicking) }, InstructionHandleResult.DetectedUnvalidatedUpdateTick); + // direct console access + yield return new TypeFinder(typeof(System.Console).FullName!, InstructionHandleResult.DetectedConsoleAccess); + // paranoid issues if (paranoidMode) { - // direct console access - yield return new TypeFinder(typeof(System.Console).FullName!, InstructionHandleResult.DetectedConsoleAccess); - // filesystem access yield return new TypeFinder( new[] diff --git a/src/SMAPI/PatchMode.cs b/src/SMAPI/PatchMode.cs index 34d3007da..91fe2cec6 100644 --- a/src/SMAPI/PatchMode.cs +++ b/src/SMAPI/PatchMode.cs @@ -6,7 +6,7 @@ public enum PatchMode /// Erase the original content within the area before drawing the new content. Replace, - /// Draw the new content over the original content, so the original content shows through any transparent pixels. + /// Draw the new content over the original content, so the original content shows through any transparent or semi-transparent pixels. Overlay } } diff --git a/src/SMAPI/Patches/Game1Patcher.cs b/src/SMAPI/Patches/Game1Patcher.cs deleted file mode 100644 index 8f8067900..000000000 --- a/src/SMAPI/Patches/Game1Patcher.cs +++ /dev/null @@ -1,125 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using HarmonyLib; -using StardewModdingAPI.Enums; -using StardewModdingAPI.Framework.Reflection; -using StardewModdingAPI.Internal.Patching; -using StardewValley; -using StardewValley.Menus; -using StardewValley.Minigames; - -namespace StardewModdingAPI.Patches -{ - /// Harmony patches for which notify SMAPI for save load stages. - /// Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments. - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - internal class Game1Patcher : BasePatcher - { - /********* - ** Fields - *********/ - /// Simplifies access to private code. - private static Reflector Reflection = null!; // initialized in constructor - - /// A callback to invoke when the load stage changes. - private static Action OnStageChanged = null!; // initialized in constructor - - /// Whether the game is running running the code in . - private static bool IsInLoadForNewGame; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// Simplifies access to private code. - /// A callback to invoke when the load stage changes. - public Game1Patcher(Reflector reflection, Action onStageChanged) - { - Game1Patcher.Reflection = reflection; - Game1Patcher.OnStageChanged = onStageChanged; - } - - /// - public override void Apply(Harmony harmony, IMonitor monitor) - { - // detect CreatedInitialLocations and SaveAddedLocations - harmony.Patch( - original: this.RequireMethod(nameof(Game1.AddModNPCs)), - prefix: this.GetHarmonyMethod(nameof(Game1Patcher.Before_AddModNpcs)) - ); - - // detect CreatedLocations, and track IsInLoadForNewGame - harmony.Patch( - original: this.RequireMethod(nameof(Game1.loadForNewGame)), - prefix: this.GetHarmonyMethod(nameof(Game1Patcher.Before_LoadForNewGame)), - postfix: this.GetHarmonyMethod(nameof(Game1Patcher.After_LoadForNewGame)) - ); - - // detect ReturningToTitle - harmony.Patch( - original: this.RequireMethod(nameof(Game1.CleanupReturningToTitle)), - prefix: this.GetHarmonyMethod(nameof(Game1Patcher.Before_CleanupReturningToTitle)) - ); - } - - - /********* - ** Private methods - *********/ - /// The method to call before . - /// Returns whether to execute the original method. - /// This method must be static for Harmony to work correctly. See the Harmony documentation before renaming arguments. - private static bool Before_AddModNpcs() - { - // When this method is called from Game1.loadForNewGame, it happens right after adding the vanilla - // locations but before initializing them. - if (Game1Patcher.IsInLoadForNewGame) - { - Game1Patcher.OnStageChanged(Game1Patcher.IsCreating() - ? LoadStage.CreatedInitialLocations - : LoadStage.SaveAddedLocations - ); - } - - return true; - } - - /// The method to call before . - /// Returns whether to execute the original method. - /// This method must be static for Harmony to work correctly. See the Harmony documentation before renaming arguments. - private static bool Before_CleanupReturningToTitle() - { - Game1Patcher.OnStageChanged(LoadStage.ReturningToTitle); - return true; - } - - /// The method to call before . - /// Returns whether to execute the original method. - /// This method must be static for Harmony to work correctly. See the Harmony documentation before renaming arguments. - private static bool Before_LoadForNewGame() - { - Game1Patcher.IsInLoadForNewGame = true; - return true; - } - - /// The method to call after . - /// This method must be static for Harmony to work correctly. See the Harmony documentation before renaming arguments. - private static void After_LoadForNewGame() - { - Game1Patcher.IsInLoadForNewGame = false; - - if (Game1Patcher.IsCreating()) - Game1Patcher.OnStageChanged(LoadStage.CreatedLocations); - } - - /// Get whether the save file is currently being created. - private static bool IsCreating() - { - return - (Game1.currentMinigame is Intro) // creating save with intro - || (Game1.activeClickableMenu is TitleMenu menu && Game1Patcher.Reflection.GetField(menu, "transitioningCharacterCreationMenu").GetValue()); // creating save, skipped intro - } - } -} diff --git a/src/SMAPI/Patches/TitleMenuPatcher.cs b/src/SMAPI/Patches/TitleMenuPatcher.cs deleted file mode 100644 index 18f1a8306..000000000 --- a/src/SMAPI/Patches/TitleMenuPatcher.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using HarmonyLib; -using StardewModdingAPI.Enums; -using StardewModdingAPI.Internal.Patching; -using StardewValley.Menus; - -namespace StardewModdingAPI.Patches -{ - /// Harmony patches for which notify SMAPI when a new character was created. - /// Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments. - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - internal class TitleMenuPatcher : BasePatcher - { - /********* - ** Fields - *********/ - /// A callback to invoke when the load stage changes. - private static Action OnStageChanged = null!; // initialized in constructor - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// A callback to invoke when the load stage changes. - public TitleMenuPatcher(Action onStageChanged) - { - TitleMenuPatcher.OnStageChanged = onStageChanged; - } - - /// - public override void Apply(Harmony harmony, IMonitor monitor) - { - harmony.Patch( - original: this.RequireMethod(nameof(TitleMenu.createdNewCharacter)), - prefix: this.GetHarmonyMethod(nameof(TitleMenuPatcher.Before_CreatedNewCharacter)) - ); - } - - - /********* - ** Private methods - *********/ - /// The method to call before . - /// Returns whether to execute the original method. - /// This method must be static for Harmony to work correctly. See the Harmony documentation before renaming arguments. - private static bool Before_CreatedNewCharacter() - { - TitleMenuPatcher.OnStageChanged(LoadStage.CreatedBasicInfo); - return true; - } - } -} diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs index a6861bca0..766fd2d9e 100644 --- a/src/SMAPI/Program.cs +++ b/src/SMAPI/Program.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Reflection; @@ -30,6 +31,7 @@ internal class Program /// The command-line arguments. public static void Main(string[] args) { + Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture; // per StardewValley.Program.Main Console.Title = $"SMAPI {EarlyConstants.RawApiVersion}"; try diff --git a/src/SMAPI/Properties/AssemblyInfo.cs b/src/SMAPI/Properties/AssemblyInfo.cs index afff16b8a..cfe612dc5 100644 --- a/src/SMAPI/Properties/AssemblyInfo.cs +++ b/src/SMAPI/Properties/AssemblyInfo.cs @@ -1,6 +1,6 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("SMAPI.Tests")] -[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // Moq for unit testing +[assembly: InternalsVisibleTo("ConsoleCommands")] [assembly: InternalsVisibleTo("ContentPatcher")] -[assembly: InternalsVisibleTo("ErrorHandler")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // Moq for unit testing diff --git a/src/SMAPI/SMAPI.config.json b/src/SMAPI/SMAPI.config.json index 0d00db4d7..55f9869b6 100644 --- a/src/SMAPI/SMAPI.config.json +++ b/src/SMAPI/SMAPI.config.json @@ -2,14 +2,14 @@ -This file contains advanced configuration for SMAPI. You generally shouldn't change this file. -The default values are mirrored in StardewModdingAPI.Framework.Models.SConfig to log custom changes. +This file has advanced configuration for SMAPI. +Don't edit this file directly! It will be reset each time you update or reinstall SMAPI. -This file is overwritten each time you update or reinstall SMAPI. To avoid losing custom settings, -create a 'config.user.json' file in the same folder with *only* the settings you want to change. -That file won't be overwritten, and any settings in it will override the default options. Don't -copy all the settings, or you may cause bugs due to overridden changes in future SMAPI versions. +Instead create a `smapi-internal/config.user.json` or `Mods/SMAPI-config.json` file with *only* the +settings you want to change. That file won't be overwritten, and any settings in it will override +the default options. Don't copy all the settings, or you may cause bugs due to overridden changes +in future SMAPI versions. @@ -53,6 +53,13 @@ copy all the settings, or you may cause bugs due to overridden changes in future */ "RewriteMods": true, + /** + * Whether to fix the library mods use to patch game code, so it works with Stardew Valley. + * + * If you disable this, mods which use Harmony are likely to cause game crashes. + */ + "FixHarmony": true, + /** * Whether to make SMAPI file APIs case-insensitive (even if the filesystem is case-sensitive). * @@ -92,6 +99,13 @@ copy all the settings, or you may cause bugs due to overridden changes in future */ "LogNetworkTraffic": false, + /** + * Whether to include more technical details about broken mods in the TRACE logs. This is + * mainly useful for creating compatibility rewriters, it's not useful to most players or mod + * authors. + */ + "LogTechnicalDetailsForBrokenMods": false, + /** * The colors to use for text written to the SMAPI console. * @@ -145,7 +159,6 @@ copy all the settings, or you may cause bugs due to overridden changes in future */ "SuppressUpdateChecks": [ "SMAPI.ConsoleCommands", - "SMAPI.ErrorHandler", "SMAPI.SaveBackup" ], diff --git a/src/SMAPI/SMAPI.csproj b/src/SMAPI/SMAPI.csproj index 2235623dd..b77d332b8 100644 --- a/src/SMAPI/SMAPI.csproj +++ b/src/SMAPI/SMAPI.csproj @@ -3,7 +3,7 @@ StardewModdingAPI StardewModdingAPI The modding API for Stardew Valley. - net5.0 + net6.0 x64 Exe true @@ -64,5 +64,4 @@ - diff --git a/src/SMAPI/SemanticVersion.cs b/src/SMAPI/SemanticVersion.cs index 81e526e72..ad44d83c4 100644 --- a/src/SMAPI/SemanticVersion.cs +++ b/src/SMAPI/SemanticVersion.cs @@ -85,7 +85,7 @@ internal SemanticVersion(ISemanticVersion version) } /// -#if NET5_0_OR_GREATER +#if NET6_0_OR_GREATER [MemberNotNullWhen(true, nameof(SemanticVersion.PrereleaseTag))] #endif public bool IsPrerelease() diff --git a/src/SMAPI/Utilities/DelegatingModHooks.cs b/src/SMAPI/Utilities/DelegatingModHooks.cs index 3ebcf997a..c13a3b61e 100644 --- a/src/SMAPI/Utilities/DelegatingModHooks.cs +++ b/src/SMAPI/Utilities/DelegatingModHooks.cs @@ -1,15 +1,21 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; using StardewModdingAPI.Events; using StardewModdingAPI.Framework; using StardewValley; using StardewValley.Events; +using StardewValley.Menus; +using StardewValley.Mods; namespace StardewModdingAPI.Utilities { /// An implementation of which automatically calls the parent instance for any method that's not overridden. /// The mod hooks are primarily meant for SMAPI to use. Using this directly in mods is a last resort, since it's very easy to break SMAPI this way. This class requires that SMAPI is present in the parent chain. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Inherited from the game code.")] public class DelegatingModHooks : ModHooks { /********* @@ -94,6 +100,56 @@ public override FarmEvent OnUtility_PickFarmEvent(Func action) return this.Parent.OnUtility_PickFarmEvent(action); } + /// Raised after the player crosses a mutex barrier in the new-day initialization before saving. + /// The barrier ID set in the new-day code. + public override void AfterNewDayBarrier(string barrier_id) + { + this.Parent.AfterNewDayBarrier(barrier_id); + } + + /// Raised when creating a new save slot, after the game has added the location instances but before it fully initializes them. + public override void CreatedInitialLocations() + { + this.Parent.CreatedInitialLocations(); + } + + /// Raised when loading a save slot, after the game has added the location instances but before it restores their save data. Not applicable when connecting to a multiplayer host. + public override void SaveAddedLocations() + { + this.Parent.SaveAddedLocations(); + } + + /// Raised before the game renders content to the screen in the draw loop. + /// The render step being started. + /// The sprite batch being drawn (which might not always be open yet). + /// A snapshot of the game timing state. + /// The render target, if any. + /// Returns whether to continue with the render step. + public override bool OnRendering(RenderSteps step, SpriteBatch sb, GameTime time, RenderTarget2D target_screen) + { + return this.Parent.OnRendering(step, sb, time, target_screen); + } + + /// Raised after the game renders content to the screen in the draw loop. + /// The render step being started. + /// The sprite batch being drawn (which might not always be open yet). + /// A snapshot of the game timing state. + /// The render target, if any. + /// Returns whether to continue with the render step. + public override void OnRendered(RenderSteps step, SpriteBatch sb, GameTime time, RenderTarget2D target_screen) + { + this.Parent.OnRendered(step, sb, time, target_screen); + } + + /// Draw a menu (or child menu) if possible. + /// The menu to draw. + /// The action which draws the menu. + /// Returns whether the menu was successfully drawn. + public override bool TryDrawMenu(IClickableMenu menu, Action draw_menu_action) + { + return this.Parent.TryDrawMenu(menu, draw_menu_action); + } + /// Start an asynchronous task for the game. /// The task to start. /// A unique key which identifies the task. diff --git a/src/SMAPI/Utilities/PerScreen.cs b/src/SMAPI/Utilities/PerScreen.cs index 674ec760e..ba6434e4f 100644 --- a/src/SMAPI/Utilities/PerScreen.cs +++ b/src/SMAPI/Utilities/PerScreen.cs @@ -1,10 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -#if SMAPI_DEPRECATED -using StardewModdingAPI.Framework; -using StardewModdingAPI.Framework.Deprecations; -#endif namespace StardewModdingAPI.Utilities { @@ -51,22 +47,7 @@ public PerScreen() /// Create the initial state for a screen. public PerScreen(Func createNewState) { - if (createNewState is null) - { -#if SMAPI_DEPRECATED - createNewState = (() => default!); - SCore.DeprecationManager.Warn( - null, - $"calling the {nameof(PerScreen)} constructor with null", - "3.14.0", - DeprecationLevel.PendingRemoval - ); -#else - throw new ArgumentNullException(nameof(createNewState)); -#endif - } - - this.CreateNewState = createNewState; + this.CreateNewState = createNewState ?? throw new ArgumentNullException(nameof(createNewState)); } /// Get all active values by screen ID. This doesn't initialize the value for a screen ID if it's not created yet. diff --git a/src/SMAPI/Utilities/SDate.cs b/src/SMAPI/Utilities/SDate.cs index 06ee8b911..33512cc80 100644 --- a/src/SMAPI/Utilities/SDate.cs +++ b/src/SMAPI/Utilities/SDate.cs @@ -1,6 +1,5 @@ using System; using System.Diagnostics.CodeAnalysis; -using System.Linq; using Newtonsoft.Json; using StardewModdingAPI.Framework; using StardewValley; @@ -32,8 +31,11 @@ public class SDate : IEquatable /// The day of month. public int Day { get; } + /// The season. + public Season Season { get; } + /// The season name. - public string Season { get; } + public string SeasonKey { get; } /// The index of the season (where 0 is spring, 1 is summer, 2 is fall, and 3 is winter). /// This is used in some game calculations (e.g. seasonal game sprites) and methods (e.g. ). @@ -62,6 +64,13 @@ public class SDate : IEquatable public SDate(int day, string season) : this(day, season, Game1.year) { } + /// Construct an instance. + /// The day of month. + /// The season name. + /// One of the arguments has an invalid value (like day 35). + public SDate(int day, Season season) + : this(day, season, Game1.year) { } + /// Construct an instance. /// The day of month. /// The season name. @@ -71,6 +80,14 @@ public SDate(int day, string season) public SDate(int day, string season, int year) : this(day, season, year, allowDayZero: false) { } + /// Construct an instance. + /// The day of month. + /// The season name. + /// The year. + /// One of the arguments has an invalid value (like day 35). + public SDate(int day, Season season, int year) + : this(day, season, year, allowDayZero: false) { } + /// Get the current in-game date. public static SDate Now() { @@ -140,7 +157,7 @@ public WorldDate ToWorldDate() /// Get an untranslated string representation of the date. This is mainly intended for debugging or console messages. public override string ToString() { - return $"{this.Day:00} {this.Season} Y{this.Year}"; + return $"{this.Day:00} {this.SeasonKey} Y{this.Year}"; } /// Get a translated string representation of the date in the current game locale. @@ -246,20 +263,16 @@ public override int GetHashCode() *********/ /// Construct an instance. /// The day of month. - /// The season name. + /// The season. /// The year. /// Whether to allow 0 spring Y1 as a valid date. /// One of the arguments has an invalid value (like day 35). [SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract", Justification = "The nullability is validated in this constructor.")] - private SDate(int day, string season, int year, bool allowDayZero) + private SDate(int day, Season season, int year, bool allowDayZero) { - season = season?.Trim().ToLowerInvariant()!; // null-checked below - // validate - if (season == null) - throw new ArgumentNullException(nameof(season)); - if (!this.Seasons.Contains(season)) - throw new ArgumentException($"Unknown season '{season}', must be one of [{string.Join(", ", this.Seasons)}]."); + if (!Enum.IsDefined(typeof(Season), season)) + throw new ArgumentException($"Unknown season '{season}', must be one of [{string.Join(", ", Enum.GetNames(typeof(Season)))}]."); if (day < 0 || day > this.DaysInSeason) throw new ArgumentException($"Invalid day '{day}', must be a value from 1 to {this.DaysInSeason}."); if (day == 0 && !(allowDayZero && this.IsDayZero(day, season, year))) @@ -270,19 +283,38 @@ private SDate(int day, string season, int year, bool allowDayZero) // initialize this.Day = day; this.Season = season; - this.SeasonIndex = this.GetSeasonIndex(season); + this.SeasonKey = Utility.getSeasonKey(season); + this.SeasonIndex = (int)season; this.Year = year; this.DayOfWeek = this.GetDayOfWeek(day); this.DaysSinceStart = this.GetDaysSinceStart(day, season, year); } + /// Construct an instance. + /// The day of month. + /// The season name. + /// The year. + /// Whether to allow 0 spring Y1 as a valid date. + /// One of the arguments has an invalid value (like day 35). + [SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract", Justification = "The nullability is validated in this constructor.")] + private SDate(int day, string season, int year, bool allowDayZero) + : this( + day, + Utility.TryParseEnum(season, out Season parsedSeason) + ? parsedSeason + : throw new ArgumentException($"Unknown season '{season}', must be one of [{string.Join(", ", Enum.GetNames(typeof(Season)))}]."), + year, + allowDayZero + ) + { } + /// Get whether a date represents 0 spring Y1, which is the date during the in-game intro. /// The day of month. /// The normalized season name. /// The year. - private bool IsDayZero(int day, string season, int year) + private bool IsDayZero(int day, Season season, int year) { - return day == 0 && season == "spring" && year == 1; + return day == 0 && season == Season.Spring && year == 1; } /// Get the day of week for a given date. @@ -306,25 +338,14 @@ private DayOfWeek GetDayOfWeek(int day) /// The day of month. /// The season name. /// The year. - private int GetDaysSinceStart(int day, string season, int year) + private int GetDaysSinceStart(int day, Season season, int year) { // return the number of days since 01 spring Y1 (inclusively) int yearIndex = year - 1; return yearIndex * this.DaysInSeason * this.SeasonsInYear - + this.GetSeasonIndex(season) * this.DaysInSeason + + (int)season * this.DaysInSeason + day; } - - /// Get a season index. - /// The season name. - /// The current season wasn't recognized. - private int GetSeasonIndex(string season) - { - int index = Array.IndexOf(this.Seasons, season); - if (index == -1) - throw new InvalidOperationException($"The season '{season}' wasn't recognized."); - return index; - } } } diff --git a/src/SMAPI/i18n/id.json b/src/SMAPI/i18n/id.json new file mode 100644 index 000000000..4616e7c05 --- /dev/null +++ b/src/SMAPI/i18n/id.json @@ -0,0 +1,6 @@ +{ + // short date format for SDate + // tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2) + "generic.date": "Tanggal {{day}} di {{season}}", + "generic.date-with-year": "Tanggal {{day}} di {{season}} tahun ke-{{year}}" +}