diff options
33 files changed, 494 insertions, 288 deletions
diff --git a/build/common.targets b/build/common.targets index 1021c2a1..8eac9757 100644 --- a/build/common.targets +++ b/build/common.targets @@ -1,7 +1,7 @@ <Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <PropertyGroup> <!--set general build properties --> - <Version>3.13.2</Version> + <Version>3.13.3</Version> <Product>SMAPI</Product> <LangVersion>latest</LangVersion> <AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths> diff --git a/build/find-game-folder.targets b/build/find-game-folder.targets index 3164b071..ba7cb26c 100644 --- a/build/find-game-folder.targets +++ b/build/find-game-folder.targets @@ -28,15 +28,31 @@ <_SteamLibraryPath>$([MSBuild]::GetRegistryValueFromView('HKEY_CURRENT_USER\SOFTWARE\Valve\Steam', 'SteamPath', null, RegistryView.Registry32))</_SteamLibraryPath> <GamePath Condition="!Exists('$(GamePath)') AND '$(_SteamLibraryPath)' != ''">$(_SteamLibraryPath)\steamapps\common\Stardew Valley</GamePath> - <!-- default paths --> + <!-- GOG paths --> <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files\GalaxyClient\Games\Stardew Valley</GamePath> <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files\GOG Galaxy\Games\Stardew Valley</GamePath> <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files\GOG Games\Stardew Valley</GamePath> - <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files\Steam\steamapps\common\Stardew Valley</GamePath> - <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\GalaxyClient\Games\Stardew Valley</GamePath> <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\GOG Galaxy\Games\Stardew Valley</GamePath> <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\GOG Games\Stardew Valley</GamePath> + + <!-- Xbox app paths --> + <!-- + The Xbox app saves the install path to the registry, but we can't use it here since it + saves the internal readonly path (like C:\Program Files\WindowsApps\Mutable\<package ID>) + instead of the mods-enabled path (like C:\Program Files\ModifiableWindowsApps\Stardew Valley). + Fortunately we can cheat a bit: players can customize the install drive, but they can't + change the install path on the drive. + --> + <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files\ModifiableWindowsApps\Stardew Valley</GamePath> + <GamePath Condition="!Exists('$(GamePath)')">D:\Program Files\ModifiableWindowsApps\Stardew Valley</GamePath> + <GamePath Condition="!Exists('$(GamePath)')">E:\Program Files\ModifiableWindowsApps\Stardew Valley</GamePath> + <GamePath Condition="!Exists('$(GamePath)')">F:\Program Files\ModifiableWindowsApps\Stardew Valley</GamePath> + <GamePath Condition="!Exists('$(GamePath)')">G:\Program Files\ModifiableWindowsApps\Stardew Valley</GamePath> + <GamePath Condition="!Exists('$(GamePath)')">H:\Program Files\ModifiableWindowsApps\Stardew Valley</GamePath> + + <!-- Steam paths --> + <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files\Steam\steamapps\common\Stardew Valley</GamePath> <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley</GamePath> </PropertyGroup> </When> diff --git a/docs/README.md b/docs/README.md index ecfa6f2b..d3aaae64 100644 --- a/docs/README.md +++ b/docs/README.md @@ -56,22 +56,24 @@ SMAPI rarely shows text in-game, so it only has a few translations. Contribution [Modding:Translations](https://stardewvalleywiki.com/Modding:Translations) on the wiki for help contributing translations. -locale | status ----------- | :---------------- -default | ✓ [fully translated](../src/SMAPI/i18n/default.json) -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) -Italian | ✓ [fully translated](../src/SMAPI/i18n/it.json) -Japanese | ✓ [fully translated](../src/SMAPI/i18n/ja.json) -Korean | ✓ [fully translated](../src/SMAPI/i18n/ko.json) -Polish¹ | ✓ [fully translated](../src/SMAPI/i18n/pl.json) -Portuguese | ✓ [fully translated](../src/SMAPI/i18n/pt.json) -Russian | ✓ [fully translated](../src/SMAPI/i18n/ru.json) -Spanish | ✓ [fully translated](../src/SMAPI/i18n/es.json) -Thai¹ | ✓ [fully translated](../src/SMAPI/i18n/th.json) -Turkish | ✓ [fully translated](../src/SMAPI/i18n/tr.json) +locale | status +----------- | :---------------- +default | ✓ [fully translated](../src/SMAPI/i18n/default.json) +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) +Italian | ✓ [fully translated](../src/SMAPI/i18n/it.json) +Japanese | ✓ [fully translated](../src/SMAPI/i18n/ja.json) +Korean | ✓ [fully translated](../src/SMAPI/i18n/ko.json) +[Polish] | ✓ [fully translated](../src/SMAPI/i18n/pl.json) +Portuguese | ✓ [fully translated](../src/SMAPI/i18n/pt.json) +Russian | ✓ [fully translated](../src/SMAPI/i18n/ru.json) +Spanish | ✓ [fully translated](../src/SMAPI/i18n/es.json) +[Thai] | ✓ [fully translated](../src/SMAPI/i18n/th.json) +Turkish | ✓ [fully translated](../src/SMAPI/i18n/tr.json) +[Ukrainian] | ✓ [fully translated](../src/SMAPI/i18n/uk.json) -¹ This is a custom language provided by a mod (see [Polish](https://www.nexusmods.com/stardewvalley/mods/3616) -and [Thai](https://www.nexusmods.com/stardewvalley/mods/7052)). +[Polish]: https://www.nexusmods.com/stardewvalley/mods/3616 +[Thai]: https://www.nexusmods.com/stardewvalley/mods/7052 +[Ukrainian]: https://www.nexusmods.com/stardewvalley/mods/8427 diff --git a/docs/release-notes.md b/docs/release-notes.md index 499fa322..957d5199 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,6 +1,32 @@ ← [README](README.md) # Release notes +## 3.13.3 +Released 16 January 2021 for Stardew Valley 1.5.6 or later. + +* For players: + * **SMAPI now needs Stardew Valley 1.5.6 or later.** + * Added automatic fix for custom maps which are missing a required tilesheet. + * Added automatic save recovery when a custom farm type isn't available anymore. + * Added the game's new build number to the SMAPI console + log. + * The installer now detects Xbox app game folders. + * Reduced mod loading time a bit. + * Fixed macOS launch issue when using some terminals (thanks to bruce2409!). + * Fixed Linux/macOS terminal ignoring backspaces in Stardew Valley 1.5.5+. + * Fixed extra newlines in the SMAPI console. + * Fixed outdated instructions in Steam error message. + * Fixed uninstaller not removing `StardewModdingAPI.deps.json` file. + * Simplified [running without a terminal on Linux/macOS](https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting#SMAPI_doesn.27t_recognize_controller_.28Steam_only.29) when needed. + * Updated compatibility list. + * Improved translations. Thanks to ChulkyBow (added Ukrainian)! + +* For the web UI: + * Added log instructions for Xbox app on Windows. + * Added log download option. + * Redesigned log instruction UI. + * Fixed log parser not correctly handling multiple mods having the exact same name. + * Fixed JSON validator not recognizing manifest [update subkeys](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Update_checks#Update_subkeys). + ## 3.13.2 Released 05 December 2021 for Stardew Valley 1.5.5 or later. diff --git a/docs/technical/mod-package.md b/docs/technical/mod-package.md index 41f808a5..5e408168 100644 --- a/docs/technical/mod-package.md +++ b/docs/technical/mod-package.md @@ -412,6 +412,9 @@ The NuGet package is generated automatically in `StardewModdingAPI.ModBuildConfi when you compile it. ## Release notes +## Upcoming release +* Added detection for Xbox app game folders. + ## 4.0.0 Released 30 November 2021. diff --git a/src/SMAPI.Installer/InteractiveInstaller.cs b/src/SMAPI.Installer/InteractiveInstaller.cs index 1257f12b..b3bba883 100644 --- a/src/SMAPI.Installer/InteractiveInstaller.cs +++ b/src/SMAPI.Installer/InteractiveInstaller.cs @@ -41,6 +41,7 @@ namespace StardewModdingApi.Installer // current files yield return GetInstallPath("StardewModdingAPI"); // Linux/macOS only + yield return GetInstallPath("StardewModdingAPI.deps.json"); yield return GetInstallPath("StardewModdingAPI.dll"); yield return GetInstallPath("StardewModdingAPI.exe"); yield return GetInstallPath("StardewModdingAPI.exe.config"); diff --git a/src/SMAPI.Installer/assets/unix-launcher.sh b/src/SMAPI.Installer/assets/unix-launcher.sh index e8b9ae62..47937f95 100644 --- a/src/SMAPI.Installer/assets/unix-launcher.sh +++ b/src/SMAPI.Installer/assets/unix-launcher.sh @@ -6,6 +6,10 @@ # move to script's directory cd "$(dirname "$0")" || exit $? +# change to true to skip opening a terminal +# This isn't recommended since you won't see errors, warnings, and update alerts. +SKIP_TERMINAL=false + ########## ## Open terminal if needed @@ -16,7 +20,6 @@ cd "$(dirname "$0")" || exit $? if [ "$(uname)" == "Darwin" ]; then if [ ! -t 1 ]; then # https://stackoverflow.com/q/911168/262123 # sanity check to make sure we don't have an infinite loop of opening windows - SKIP_TERMINAL=false for argument in "$@"; do if [ "$argument" == "--no-reopen-terminal" ]; then SKIP_TERMINAL=true @@ -28,7 +31,8 @@ if [ "$(uname)" == "Darwin" ]; then # https://stackoverflow.com/a/29511052/262123 if [ "$SKIP_TERMINAL" == "false" ]; then echo "Reopening in the Terminal app..." - echo "\"$0\" $@ --no-reopen-terminal" > /tmp/open-smapi-terminal.sh + echo '#!/bin/sh' > /tmp/open-smapi-terminal.sh + echo "\"$0\" $@ --no-reopen-terminal" >> /tmp/open-smapi-terminal.sh chmod +x /tmp/open-smapi-terminal.sh cat /tmp/open-smapi-terminal.sh open -W -a Terminal /tmp/open-smapi-terminal.sh @@ -63,64 +67,71 @@ else LAUNCH_FILE="./StardewModdingAPI" export LAUNCH_FILE - # 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; do - if command -v "$terminal" 2>/dev/null; then - export TERMINAL_NAME=$terminal - break; + # run in terminal + if [ "$SKIP_TERMINAL" == "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; do + if command -v "$terminal" 2>/dev/null; then + export TERMINAL_NAME=$terminal + break; + fi + done + + # find the true shell behind x-terminal-emulator + if [ "$TERMINAL_NAME" = "x-terminal-emulator" ]; then + export TERMINAL_NAME="$(basename "$(readlink -f $(command -v x-terminal-emulator))")" fi - done - # find the true shell behind x-terminal-emulator - if [ "$TERMINAL_NAME" = "x-terminal-emulator" ]; then - export TERMINAL_NAME="$(basename "$(readlink -f $(command -v x-terminal-emulator))")" - fi + # run in selected terminal and account for quirks + export TERMINAL_PATH="$(command -v $TERMINAL_NAME)" + if [ -x $TERMINAL_PATH ]; then + case $TERMINAL_NAME in + terminal|termite) + # consumes only one argument after -e + # options containing space characters are unsupported + exec $TERMINAL_NAME -e "env TERM=xterm $LAUNCH_FILE $@" + ;; + + xterm|konsole|alacritty) + # consumes all arguments after -e + exec $TERMINAL_NAME -e env TERM=xterm $LAUNCH_FILE "$@" + ;; + + terminator|xfce4-terminal|mate-terminal) + # consumes all arguments after -x + exec $TERMINAL_NAME -x env TERM=xterm $LAUNCH_FILE "$@" + ;; + + gnome-terminal) + # consumes all arguments after -- + exec $TERMINAL_NAME -- env TERM=xterm $LAUNCH_FILE "$@" + ;; + + kitty) + # consumes all trailing arguments + exec $TERMINAL_NAME env TERM=xterm $LAUNCH_FILE "$@" + ;; + + *) + # If we don't know the terminal, just try to run it in the current shell. + # If THAT fails, launch with no output. + env TERM=xterm $LAUNCH_FILE "$@" + if [ $? -eq 127 ]; then + exec $LAUNCH_FILE --no-terminal "$@" + fi + esac + + ## terminal isn't executable; fallback to current shell or no terminal + else + echo "The '$TERMINAL_NAME' terminal isn't executable. SMAPI might be running in a sandbox or the system might be misconfigured? Falling back to current shell." + env TERM=xterm $LAUNCH_FILE "$@" + if [ $? -eq 127 ]; then + exec $LAUNCH_FILE --no-terminal "$@" + fi + fi - # run in selected terminal and account for quirks - export TERMINAL_PATH="$(command -v $TERMINAL_NAME)" - if [ -x $TERMINAL_PATH ]; then - case $TERMINAL_NAME in - terminal|termite) - # consumes only one argument after -e - # options containing space characters are unsupported - exec $TERMINAL_NAME -e "env TERM=xterm $LAUNCH_FILE $@" - ;; - - xterm|konsole|alacritty) - # consumes all arguments after -e - exec $TERMINAL_NAME -e env TERM=xterm $LAUNCH_FILE "$@" - ;; - - terminator|xfce4-terminal|mate-terminal) - # consumes all arguments after -x - exec $TERMINAL_NAME -x env TERM=xterm $LAUNCH_FILE "$@" - ;; - - gnome-terminal) - # consumes all arguments after -- - exec $TERMINAL_NAME -- env TERM=xterm $LAUNCH_FILE "$@" - ;; - - kitty) - # consumes all trailing arguments - exec $TERMINAL_NAME env TERM=xterm $LAUNCH_FILE "$@" - ;; - - *) - # If we don't know the terminal, just try to run it in the current shell. - # If THAT fails, launch with no output. - env TERM=xterm $LAUNCH_FILE "$@" - if [ $? -eq 127 ]; then - exec $LAUNCH_FILE --no-terminal "$@" - fi - esac - - ## terminal isn't executable; fallback to current shell or no terminal + # explicitly run without terminal else - echo "The '$TERMINAL_NAME' terminal isn't executable. SMAPI might be running in a sandbox or the system might be misconfigured? Falling back to current shell." - env TERM=xterm $LAUNCH_FILE "$@" - if [ $? -eq 127 ]; then - exec $LAUNCH_FILE --no-terminal "$@" - fi + exec $LAUNCH_FILE --no-terminal "$@" fi fi diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json index 216a4c32..97e1c243 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.13.2", + "Version": "3.13.3", "Description": "Adds SMAPI console commands that let you manipulate the game.", "UniqueID": "SMAPI.ConsoleCommands", "EntryDll": "ConsoleCommands.dll", - "MinimumApiVersion": "3.13.2" + "MinimumApiVersion": "3.13.3" } diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/SaveGamePatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/SaveGamePatcher.cs index 2a43cb10..0a7ed212 100644 --- a/src/SMAPI.Mods.ErrorHandler/Patches/SaveGamePatcher.cs +++ b/src/SMAPI.Mods.ErrorHandler/Patches/SaveGamePatcher.cs @@ -4,11 +4,11 @@ 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; -using SObject = StardewValley.Object; namespace StardewModdingAPI.Mods.ErrorHandler.Patches { @@ -47,6 +47,11 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches original: this.RequireMethod<SaveGame>(nameof(SaveGame.loadDataToLocations)), prefix: this.GetHarmonyMethod(nameof(SaveGamePatcher.Before_LoadDataToLocations)) ); + + harmony.Patch( + original: this.RequireMethod<SaveGame>(nameof(SaveGame.LoadFarmType)), + finalizer: this.GetHarmonyMethod(nameof(SaveGamePatcher.Finalize_LoadFarmType)) + ); } @@ -58,14 +63,35 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches /// <returns>Returns whether to execute the original method.</returns> private static bool Before_LoadDataToLocations(List<GameLocation> gamelocations) { + // missing locations/NPCs IDictionary<string, string> npcs = Game1.content.Load<Dictionary<string, string>>("Data\\NPCDispositions"); - if (SaveGamePatcher.RemoveBrokenContent(gamelocations, npcs)) SaveGamePatcher.OnContentRemoved(); return true; } + /// <summary>The method to call after <see cref="SaveGame.LoadFarmType"/> throws an exception.</summary> + /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> + /// <returns>Returns the exception to throw, if any.</returns> + 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; + } + /// <summary>Remove content which no longer exists in the game data.</summary> /// <param name="locations">The current game locations.</param> /// <param name="npcs">The NPC data.</param> diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/uk.json b/src/SMAPI.Mods.ErrorHandler/i18n/uk.json new file mode 100644 index 00000000..a58102ab --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/i18n/uk.json @@ -0,0 +1,4 @@ +{ + // warning messages + "warn.invalid-content-removed": "Недійсний вміст видалено, щоб запобігти аварійному завершенню роботи (Додаткову інформацію див. на консолі SMAPI)." +} diff --git a/src/SMAPI.Mods.ErrorHandler/manifest.json b/src/SMAPI.Mods.ErrorHandler/manifest.json index beb52020..c4246721 100644 --- a/src/SMAPI.Mods.ErrorHandler/manifest.json +++ b/src/SMAPI.Mods.ErrorHandler/manifest.json @@ -1,9 +1,9 @@ { "Name": "Error Handler", "Author": "SMAPI", - "Version": "3.13.2", + "Version": "3.13.3", "Description": "Handles some common vanilla errors to log more useful info or avoid breaking the game.", "UniqueID": "SMAPI.ErrorHandler", "EntryDll": "ErrorHandler.dll", - "MinimumApiVersion": "3.13.2" + "MinimumApiVersion": "3.13.3" } diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json index 2bd20a63..b1b946c8 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.13.2", + "Version": "3.13.3", "Description": "Automatically backs up all your saves once per day into its folder.", "UniqueID": "SMAPI.SaveBackup", "EntryDll": "SaveBackup.dll", - "MinimumApiVersion": "3.13.2" + "MinimumApiVersion": "3.13.3" } diff --git a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs index 37e4f263..8d4198de 100644 --- a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs +++ b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs @@ -157,7 +157,7 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning yield return Path.Combine(steamPath.Replace('/', '\\'), @"steamapps\common\Stardew Valley"); #endif - // default paths + // default GOG/Steam paths foreach (string programFiles in new[] { @"C:\Program Files", @"C:\Program Files (x86)" }) { yield return $@"{programFiles}\GalaxyClient\Games\Stardew Valley"; @@ -165,6 +165,15 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning yield return $@"{programFiles}\GOG Games\Stardew Valley"; yield return $@"{programFiles}\Steam\steamapps\common\Stardew Valley"; } + + // default Xbox app paths + // The Xbox app saves the install path to the registry, but we can't use it + // here since it saves the internal readonly path (like C:\Program Files\WindowsApps\Mutable\<package ID>) + // instead of the mods-enabled path(like C:\Program Files\ModifiableWindowsApps\Stardew Valley). + // Fortunately we can cheat a bit: players can customize the install drive, but they can't + // change the install path on the drive. + for (char driveLetter = 'C'; driveLetter <= 'H'; driveLetter++) + yield return $@"{driveLetter}:\Program Files\ModifiableWindowsApps\Stardew Valley"; } break; diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs index 39de4b5d..db53d942 100644 --- a/src/SMAPI.Web/Controllers/LogParserController.cs +++ b/src/SMAPI.Web/Controllers/LogParserController.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using StardewModdingAPI.Toolkit.Utilities; @@ -39,24 +40,42 @@ namespace StardewModdingAPI.Web.Controllers ***/ /// <summary>Render the log parser UI.</summary> /// <param name="id">The stored file ID.</param> - /// <param name="raw">Whether to display the raw unparsed log.</param> + /// <param name="format">How to render the log view.</param> /// <param name="renew">Whether to reset the log expiry.</param> [HttpGet] [Route("log")] [Route("log/{id}")] - public async Task<ViewResult> Index(string id = null, bool raw = false, bool renew = false) + public async Task<ActionResult> Index(string id = null, LogViewFormat format = LogViewFormat.Default, bool renew = false) { // fresh page if (string.IsNullOrWhiteSpace(id)) return this.View("Index", this.GetModel(id)); - // log page + // fetch log StoredFileInfo file = await this.Storage.GetAsync(id, renew); - ParsedLog log = file.Success - ? new LogParser().Parse(file.Content) - : new ParsedLog { IsValid = false, Error = file.Error }; - return this.View("Index", this.GetModel(id, uploadWarning: file.Warning, expiry: file.Expiry).SetResult(log, raw)); + // render view + switch (format) + { + case LogViewFormat.Default: + case LogViewFormat.RawView: + { + ParsedLog log = file.Success + ? new LogParser().Parse(file.Content) + : new ParsedLog { IsValid = false, Error = file.Error }; + + return this.View("Index", this.GetModel(id, uploadWarning: file.Warning, expiry: file.Expiry).SetResult(log, showRaw: format == LogViewFormat.RawView)); + } + + case LogViewFormat.RawDownload: + { + string content = file.Error ?? file.Content; + return this.File(Encoding.UTF8.GetBytes(content), "plain/text", $"SMAPI log ({id}).txt"); + } + + default: + throw new InvalidOperationException($"Unknown log view format '{format}'."); + } } /*** diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs index 84013ccc..887d0105 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs @@ -79,7 +79,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing // parse log messages LogModInfo smapiMod = new LogModInfo { Name = "SMAPI", Author = "Pathoschild", Description = "", Loaded = true }; LogModInfo gameMod = new LogModInfo { Name = "game", Author = "", Description = "", Loaded = true }; - IDictionary<string, LogModInfo> mods = new Dictionary<string, LogModInfo>(); + IDictionary<string, List<LogModInfo>> mods = new Dictionary<string, List<LogModInfo>>(); bool inModList = false; bool inContentPackList = false; bool inModUpdateList = false; @@ -99,8 +99,11 @@ namespace StardewModdingAPI.Web.Framework.LogParsing break; default: - if (mods.ContainsKey(message.Mod)) - mods[message.Mod].Errors++; + if (mods.TryGetValue(message.Mod, out var entries)) + { + foreach (var entry in entries) + entry.Errors++; + } break; } } @@ -127,7 +130,10 @@ namespace StardewModdingAPI.Web.Framework.LogParsing string version = match.Groups["version"].Value; string author = match.Groups["author"].Value; string description = match.Groups["description"].Value; - mods[name] = new LogModInfo { Name = name, Author = author, Version = version, Description = description, Loaded = true }; + + if (!mods.TryGetValue(name, out List<LogModInfo> entries)) + mods[name] = entries = new List<LogModInfo>(); + entries.Add(new LogModInfo { Name = name, Author = author, Version = version, Description = description, Loaded = true }); message.Section = LogSection.ModsList; } @@ -147,7 +153,10 @@ namespace StardewModdingAPI.Web.Framework.LogParsing string author = match.Groups["author"].Value; string description = match.Groups["description"].Value; string forMod = match.Groups["for"].Value; - mods[name] = new LogModInfo { Name = name, Author = author, Version = version, Description = description, ContentPackFor = forMod, Loaded = true }; + + if (!mods.TryGetValue(name, out List<LogModInfo> entries)) + mods[name] = entries = new List<LogModInfo>(); + entries.Add(new LogModInfo { Name = name, Author = author, Version = version, Description = description, ContentPackFor = forMod, Loaded = true }); message.Section = LogSection.ContentPackList; } @@ -165,14 +174,14 @@ namespace StardewModdingAPI.Web.Framework.LogParsing string name = match.Groups["name"].Value; string version = match.Groups["version"].Value; string link = match.Groups["link"].Value; - if (mods.ContainsKey(name)) - { - mods[name].UpdateLink = link; - mods[name].UpdateVersion = version; - } - else + + if (mods.TryGetValue(name, out var entries)) { - mods[name] = new LogModInfo { Name = name, UpdateVersion = version, UpdateLink = link, Loaded = false }; + foreach (var entry in entries) + { + entry.UpdateLink = link; + entry.UpdateVersion = version; + } } message.Section = LogSection.ModUpdateList; @@ -219,7 +228,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing // finalize log gameMod.Version = log.GameVersion; - log.Mods = new[] { gameMod, smapiMod }.Concat(mods.Values.OrderBy(p => p.Name)).ToArray(); + log.Mods = new[] { gameMod, smapiMod }.Concat(mods.Values.SelectMany(p => p).OrderBy(p => p.Name)).ToArray(); return log; } catch (LogParseException ex) diff --git a/src/SMAPI.Web/ViewModels/LogViewFormat.cs b/src/SMAPI.Web/ViewModels/LogViewFormat.cs new file mode 100644 index 00000000..7ef79319 --- /dev/null +++ b/src/SMAPI.Web/ViewModels/LogViewFormat.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Web.ViewModels +{ + /// <summary>How a log file should be displayed.</summary> + public enum LogViewFormat + { + /// <summary>Render a parsed log and metadata.</summary> + Default, + + /// <summary>Render a raw log with parsed metadata.</summary> + RawView, + + /// <summary>Render directly as a text file.</summary> + RawDownload + } +} diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index 06d46c9e..91fc3535 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -2,6 +2,7 @@ @using StardewModdingAPI.Toolkit.Utilities @using StardewModdingAPI.Web.Framework @using StardewModdingAPI.Web.Framework.LogParsing.Models +@using StardewModdingAPI.Web.ViewModels @model StardewModdingAPI.Web.ViewModels.LogParserModel @{ @@ -22,13 +23,15 @@ { <meta name="robots" content="noindex" /> } - <link rel="stylesheet" href="~/Content/css/file-upload.css?r=202002" /> - <link rel="stylesheet" href="~/Content/css/log-parser.css?r=202002" /> + <link rel="stylesheet" href="~/Content/css/file-upload.css" /> + <link rel="stylesheet" href="~/Content/css/log-parser.css" /> + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tabbyjs@12.0.3/dist/css/tabby-ui-vertical.min.css" /> + <script src="https://cdn.jsdelivr.net/npm/tabbyjs@12.0.3" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/vue@2.6.11" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1" crossorigin="anonymous"></script> - <script src="~/Content/js/file-upload.js?r=202002"></script> - <script src="~/Content/js/log-parser.js?r=202002"></script> + <script src="~/Content/js/file-upload.js"></script> + <script src="~/Content/js/log-parser.js"></script> <script> $(function() { smapi.logParser({ @@ -40,6 +43,8 @@ enableFilters: @this.ForJson(!Model.ShowRaw), screenIds: @this.ForJson(screenIds) }, '@this.Url.PlainAction("Index", "LogParser", values: null)'); + + new Tabby('[data-tabs]'); }); </script> } @@ -90,51 +95,65 @@ else if (Model.ParsedLog?.IsValid == true) @if (Model.ParsedLog == null) { <h2>Where do I find my SMAPI log?</h2> - <div>What system do you use?</div> - <ul id="os-list"> - @foreach (Platform platform in new[] { Platform.Android, Platform.Linux, Platform.Mac, Platform.Windows }) - { - <li> - <input type="radio" name="os" value="@platform" id="os-@platform" checked="@(Model.DetectedPlatform == platform)" /> - <label for="os-@platform">@platform</label> - </li> - } - </ul> - <div data-os="@Platform.Android"> - On Android: - <ol> - <li>Open a file app (like My Files or MT Manager).</li> - <li>Find the <code>StardewValley</code> folder on your internal storage.</li> - <li>Open the <code>ErrorLogs</code> subfolder.</li> - <li>The log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li> - </ol> - </div> - <div data-os="@Platform.Linux"> - On Linux: - <ol> - <li>Open the Files app.</li> - <li>Click the options menu (might be labeled <em>Go</em> or <code>⋮</code>).</li> - <li>Choose <em>Enter Location</em>.</li> - <li>Enter this exact text: <pre>~/.config/StardewValley/ErrorLogs</pre></li> - <li>The log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li> - </ol> - </div> - <div data-os="@Platform.Mac"> - On macOS: - <ol> - <li>Open the Finder app.</li> - <li>Click <em>Go</em> at the top, then <em>Go to Folder</em>.</li> - <li>Enter this exact text: <pre>~/.config/StardewValley/ErrorLogs</pre></li> - <li>The log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li> - </ol> - </div> - <div data-os="@Platform.Windows"> - On Windows: - <ol> - <li>Press the <code>Windows</code> and <code>R</code> buttons at the same time.</li> - <li>In the 'run' box that appears, enter this exact text: <pre>%appdata%\StardewValley\ErrorLogs</pre></li> - <li>The log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li> - </ol> + <div id="os-instructions"> + <div> + <ul data-tabs> + @foreach (Platform platform in new[] {Platform.Android, Platform.Linux, Platform.Mac, Platform.Windows}) + { + @if (platform == Platform.Windows) + { + <li><a data-tabby-default href="#@(platform)-steamgog">@platform (Steam or GOG)</a></li> + <li><a href="#@(platform)-xbox">@platform (Xbox app)</a></li> + } + else + { + <li><a href="#@platform">@platform</a></li> + } + } + </ul> + </div> + <div> + <div id="@Platform.Android"> + <ol> + <li>Open a file app (like My Files or MT Manager).</li> + <li>Find the <code>StardewValley</code> folder on your internal storage.</li> + <li>Open the <code>ErrorLogs</code> subfolder.</li> + <li>The log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li> + </ol> + </div> + <div id="@Platform.Linux"> + <ol> + <li>Open the Files app.</li> + <li>Click the options menu (might be labeled <em>Go</em> or <code>⋮</code>).</li> + <li>Choose <em>Enter Location</em>.</li> + <li>Enter this exact text: <pre>~/.config/StardewValley/ErrorLogs</pre></li> + <li>The log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li> + </ol> + </div> + <div id="@Platform.Mac"> + <ol> + <li>Open the Finder app.</li> + <li>Click <em>Go</em> at the top, then <em>Go to Folder</em>.</li> + <li>Enter this exact text: <pre>~/.config/StardewValley/ErrorLogs</pre></li> + <li>The log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li> + </ol> + </div> + <div id="@(Platform.Windows)-steamgog"> + <ol> + <li>Press the <kbd>Windows</kbd> and <kbd>R</kbd> buttons at the same time.</li> + <li>In the 'run' box that appears, enter this exact text: <pre>%appdata%\StardewValley\ErrorLogs</pre></li> + <li>The log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li> + </ol> + </div> + <div id="@(Platform.Windows)-xbox"> + <ol> + <li>Press the <kbd>Windows</kbd> and <kbd>R</kbd> buttons at the same time.</li> + <li>In the 'run' box that appears, enter this exact text: <pre>%localappdata%\Packages\ConcernedApe.StardewValleyPC_0c8vynj4cqe4e\LocalCache\Roaming\StardewValley\ErrorLogs</pre></li> + <li>If you get an error with the title "Location is not available", try the "with Steam or GOG" instructions above.</li> + <li>Otherwise the log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li> + </ol> + </div> + </div> </div> <h2>How do I share my log?</h2> @@ -338,14 +357,24 @@ else if (Model.ParsedLog?.IsValid == true) } } </table> - - <small><a href="@this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID, raw = true })">view raw log</a></small> } else { <pre v-pre>@Model.ParsedLog.RawText</pre> - <small><a href="@this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID })">view parsed log</a></small> } + + <small> + @if (Model.ShowRaw) + { + <a href="@this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID })">view parsed log</a> + } + else + { + <a href="@this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID, format = LogViewFormat.RawView })">view raw log</a> + } + + | <a href="@this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID, format = LogViewFormat.RawDownload })" download>download</a> + </small> </div> } else if (Model.ParsedLog?.IsValid == false) diff --git a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css index bfbc8982..8c3acceb 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css +++ b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css @@ -291,13 +291,29 @@ table caption { /********* -** Upload form +** OS instructions *********/ -#os-list { - list-style: none; +#os-instructions { + display: grid; + grid-template-columns: minmax(16em, auto) 30em; } -div[data-os] { - display: none; +#os-instructions [role="tablist"] { + border: 0; +} + +#os-instructions [role="tab"] { + display: block; + border: 0; + position: relative; } +#os-instructions [role="tab"][aria-selected="true"] { + font-weight: bold; + border-radius: 0 10px 10px 0; +} + +[role="tab"][aria-selected="true"]::after { + content: "▶"; + padding-left: 0.5em; +} diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js index 6ae1707e..90715375 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -115,17 +115,6 @@ smapi.logParser = function (data, sectionUrl) { *********/ var input = $("#input"); if (input.length) { - // instructions per OS - var systemOptions = $("input[name='os']"); - var systemInstructions = $("div[data-os]"); - - var chooseSystem = function () { - systemInstructions.hide(); - systemInstructions.filter("[data-os='" + $("input[name='os']:checked").val() + "']").show(); - }; - systemOptions.on("click", chooseSystem); - chooseSystem(); - // file upload smapi.fileUpload({ chooseFileLink: $("#choose-file-link"), diff --git a/src/SMAPI.Web/wwwroot/SMAPI.metadata.json b/src/SMAPI.Web/wwwroot/SMAPI.metadata.json index eb76b29a..75a3f8c7 100644 --- a/src/SMAPI.Web/wwwroot/SMAPI.metadata.json +++ b/src/SMAPI.Web/wwwroot/SMAPI.metadata.json @@ -481,6 +481,13 @@ "~1.2.2 | StatusReasonDetails": "references the deleted Content/Mine asset" }, + "Critical Crow": { + "ID": "leonary.CRCROWS", + "Default | UpdateKey": "Nexus:2663", + "~1.2.2 | Status": "AssumeBroken", + "~1.2.2 | StatusReasonDetails": "removes newer content from the TileSheets/Craftables asset" + }, + "Green Pastures Farm": { "ID": "bugbuddy.GreenPasturesFarm", "Default | UpdateKey": "Nexus:2326", diff --git a/src/SMAPI.Web/wwwroot/schemas/manifest.json b/src/SMAPI.Web/wwwroot/schemas/manifest.json index 05698ba2..b6722347 100644 --- a/src/SMAPI.Web/wwwroot/schemas/manifest.json +++ b/src/SMAPI.Web/wwwroot/schemas/manifest.json @@ -103,7 +103,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^(?i)(Chucklefish:\\d+|Nexus:\\d+|GitHub:[A-Za-z0-9_\\-]+/[A-Za-z0-9_\\-]+|ModDrop:\\d+)$", + "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.DotSettings b/src/SMAPI.sln.DotSettings index 9a6cad37..71cd7b82 100644 --- a/src/SMAPI.sln.DotSettings +++ b/src/SMAPI.sln.DotSettings @@ -70,6 +70,7 @@ <s:Boolean x:Key="/Default/UserDictionary/Words/=thumbstick/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=tilesheet/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=tilesheets/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=tilesheet_0027s/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=unloadable/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=virally/@EntryIndexedValue">True</s:Boolean> </wpf:ResourceDictionary>
\ No newline at end of file diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index 5de28f84..455cfd7e 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -49,7 +49,7 @@ namespace StardewModdingAPI internal static int? LogScreenId { get; set; } /// <summary>SMAPI's current raw semantic version.</summary> - internal static string RawApiVersion = "3.13.2"; + internal static string RawApiVersion = "3.13.3"; } /// <summary>Contains SMAPI's constants and assumptions.</summary> @@ -65,7 +65,7 @@ namespace StardewModdingAPI public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion(EarlyConstants.RawApiVersion); /// <summary>The minimum supported version of Stardew Valley.</summary> - public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.5.5"); + public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.5.6"); /// <summary>The maximum supported version of Stardew Valley.</summary> public static ISemanticVersion MaximumGameVersion { get; } = null; @@ -340,5 +340,16 @@ namespace StardewModdingAPI // if save doesn't exist yet, return the default one we expect to be created return folder; } + + /// <summary>Get a display label for the game's build number.</summary> + internal static string GetBuildVersionLabel() + { + string version = typeof(Game1).Assembly.GetName().Version?.ToString() ?? "unknown"; + + if (version.StartsWith($"{Game1.version}.")) + version = version.Substring(Game1.version.Length + 1); + + return version; + } } } diff --git a/src/SMAPI/Framework/Content/TilesheetReference.cs b/src/SMAPI/Framework/Content/TilesheetReference.cs index 2ea38430..0919bb44 100644 --- a/src/SMAPI/Framework/Content/TilesheetReference.cs +++ b/src/SMAPI/Framework/Content/TilesheetReference.cs @@ -1,3 +1,6 @@ +using System.Numerics; +using xTile.Dimensions; + namespace StardewModdingAPI.Framework.Content { /// <summary>Basic metadata about a vanilla tilesheet.</summary> @@ -15,6 +18,12 @@ namespace StardewModdingAPI.Framework.Content /// <summary>The asset path for the tilesheet texture.</summary> public readonly string ImageSource; + /// <summary>The number of tiles in the tilesheet.</summary> + public readonly Size SheetSize; + + /// <summary>The size of each tile in pixels.</summary> + public readonly Size TileSize; + /********* ** Public methods @@ -23,11 +32,15 @@ namespace StardewModdingAPI.Framework.Content /// <param name="index">The tilesheet's index in the list.</param> /// <param name="id">The tilesheet's unique ID in the map.</param> /// <param name="imageSource">The asset path for the tilesheet texture.</param> - public TilesheetReference(int index, string id, string imageSource) + /// <param name="sheetSize">The number of tiles in the tilesheet.</param> + /// <param name="tileSize">The size of each tile in pixels.</param> + public TilesheetReference(int index, string id, string imageSource, Size sheetSize, Size tileSize) { this.Index = index; this.Id = id; this.ImageSource = imageSource; + this.SheetSize = sheetSize; + this.TileSize = tileSize; } } } diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs index b6f1669a..99091f3e 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -406,14 +406,14 @@ namespace StardewModdingAPI.Framework if (!this.VanillaTilesheets.TryGetValue(assetName, out TilesheetReference[] tilesheets)) { tilesheets = this.TryLoadVanillaAsset(assetName, out Map map) - ? map.TileSheets.Select((sheet, index) => new TilesheetReference(index, sheet.Id, sheet.ImageSource)).ToArray() + ? map.TileSheets.Select((sheet, index) => new TilesheetReference(index, sheet.Id, sheet.ImageSource, sheet.SheetSize, sheet.TileSize)).ToArray() : null; this.VanillaTilesheets[assetName] = tilesheets; this.VanillaContentManager.Unload(); } - return tilesheets ?? new TilesheetReference[0]; + return tilesheets ?? Array.Empty<TilesheetReference>(); } /// <summary>Get the language enum which corresponds to a locale code (e.g. <see cref="LocalizedContentManager.LanguageCode.fr"/> given <c>fr-FR</c>).</summary> diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs index 7a49dd36..ab198076 100644 --- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs @@ -13,6 +13,7 @@ using StardewModdingAPI.Framework.Utilities; using StardewModdingAPI.Internal; using StardewValley; using xTile; +using xTile.Tiles; namespace StardewModdingAPI.Framework.ContentManagers { @@ -308,7 +309,7 @@ namespace StardewModdingAPI.Framework.ContentManagers } // return matched asset - return this.TryValidateLoadedAsset(info, data, mod) + return this.TryFixAndValidateLoadedAsset(info, data, mod) ? new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName) : null; } @@ -381,12 +382,13 @@ namespace StardewModdingAPI.Framework.ContentManagers return asset; } - /// <summary>Validate that an asset loaded by a mod is valid and won't cause issues.</summary> + /// <summary>Validate that an asset loaded by a mod is valid and won't cause issues, and fix issues if possible.</summary> /// <typeparam name="T">The asset type.</typeparam> /// <param name="info">The basic asset metadata.</param> /// <param name="data">The loaded asset data.</param> /// <param name="mod">The mod which loaded the asset.</param> - private bool TryValidateLoadedAsset<T>(IAssetInfo info, T data, IModMetadata mod) + /// <returns>Returns whether the asset passed validation checks (after any fixes were applied).</returns> + private bool TryFixAndValidateLoadedAsset<T>(IAssetInfo info, T data, IModMetadata mod) { // can't load a null asset if (data == null) @@ -401,20 +403,23 @@ namespace StardewModdingAPI.Framework.ContentManagers TilesheetReference[] vanillaTilesheetRefs = this.Coordinator.GetVanillaTilesheetIds(info.AssetName); foreach (TilesheetReference vanillaSheet in vanillaTilesheetRefs) { - // skip if match - if (loadedMap.TileSheets.Count > vanillaSheet.Index && loadedMap.TileSheets[vanillaSheet.Index].Id == vanillaSheet.Id) - continue; + // add missing tilesheet + if (loadedMap.GetTileSheet(vanillaSheet.Id) == null) + { + mod.Monitor.LogOnce("SMAPI fixed maps loaded by this mod to prevent errors. See the log file for details.", LogLevel.Warn); + this.Monitor.Log($"Fixed broken map replacement: {mod.DisplayName} loaded '{info.AssetName}' without a required tilesheet (id: {vanillaSheet.Id}, source: {vanillaSheet.ImageSource})."); + + loadedMap.AddTileSheet(new TileSheet(vanillaSheet.Id, loadedMap, vanillaSheet.ImageSource, vanillaSheet.SheetSize, vanillaSheet.TileSize)); + } // handle mismatch + if (loadedMap.TileSheets.Count <= vanillaSheet.Index || loadedMap.TileSheets[vanillaSheet.Index].Id != vanillaSheet.Id) { // 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.AssetNameEquals("Maps/Farm") || info.AssetNameEquals("Maps/Farm_Combat") || info.AssetNameEquals("Maps/Farm_Fishing") || info.AssetNameEquals("Maps/Farm_Foraging") || info.AssetNameEquals("Maps/Farm_FourCorners") || info.AssetNameEquals("Maps/Farm_Island") || info.AssetNameEquals("Maps/Farm_Mining"); - int loadedIndex = this.TryFindTilesheet(loadedMap, vanillaSheet.Id); - string reason = loadedIndex != -1 - ? $"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." - : $"mod has no tilesheet with ID '{vanillaSheet.Id}'. Map replacements must keep the original tilesheets to avoid errors or crashes."; + string reason = $"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) @@ -429,19 +434,5 @@ namespace StardewModdingAPI.Framework.ContentManagers return true; } - - /// <summary>Find a map tilesheet by ID.</summary> - /// <param name="map">The map whose tilesheets to search.</param> - /// <param name="id">The tilesheet ID to match.</param> - private int TryFindTilesheet(Map map, string id) - { - for (int i = 0; i < map.TileSheets.Count; i++) - { - if (map.TileSheets[i].Id == id) - return i; - } - - return -1; - } } } diff --git a/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs b/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs index d99f1dd2..bad69a2a 100644 --- a/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs +++ b/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs @@ -8,15 +8,11 @@ namespace StardewModdingAPI.Framework.Logging internal class InterceptingTextWriter : TextWriter { /********* - ** Fields + ** Accessors *********/ /// <summary>Prefixing a message with this character indicates that the console interceptor should write the string without intercepting it. (The character itself is not written.)</summary> - private readonly char IgnoreChar; - + public const char IgnoreChar = '\u200B'; - /********* - ** Accessors - *********/ /// <summary>The underlying console output.</summary> public TextWriter Out { get; } @@ -26,30 +22,48 @@ namespace StardewModdingAPI.Framework.Logging /// <summary>The event raised when a message is written to the console directly.</summary> public event Action<string> OnMessageIntercepted; + /// <summary>Whether the text writer should ignore the next input if it's a newline.</summary> + /// <remarks>This is used when log output is suppressed from the console, since <c>Console.WriteLine</c> writes the trailing newline as a separate call.</remarks> + public bool IgnoreNextIfNewline { get; set; } + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> /// <param name="output">The underlying output writer.</param> - /// <param name="ignoreChar">Prefixing a message with this character indicates that the console interceptor should write the string without intercepting it. (The character itself is not written.)</param> - public InterceptingTextWriter(TextWriter output, char ignoreChar) + public InterceptingTextWriter(TextWriter output) { this.Out = output; - this.IgnoreChar = ignoreChar; } /// <inheritdoc /> public override void Write(char[] buffer, int index, int count) { - if (buffer.Length == 0) + // 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); - else if (buffer[0] == this.IgnoreChar) + return; + } + char firstChar = buffer[index]; + + // handle output + if (firstChar == InterceptingTextWriter.IgnoreChar) this.Out.Write(buffer, index + 1, count - 1); - else if (this.IsEmptyOrNewline(buffer)) + 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?.Invoke(new string(buffer, index, count).TrimEnd('\r', '\n')); + this.OnMessageIntercepted?.Invoke(new string(buffer, index, count)); } /// <inheritdoc /> diff --git a/src/SMAPI/Framework/Logging/LogManager.cs b/src/SMAPI/Framework/Logging/LogManager.cs index 5a291d0a..a8a8b6ee 100644 --- a/src/SMAPI/Framework/Logging/LogManager.cs +++ b/src/SMAPI/Framework/Logging/LogManager.cs @@ -25,8 +25,11 @@ namespace StardewModdingAPI.Framework.Logging /// <summary>The log file to which to write messages.</summary> private readonly LogFileManager LogFile; + /// <summary>The text writer which intercepts console output.</summary> + private readonly InterceptingTextWriter ConsoleInterceptor; + /// <summary>Prefixing a low-level message with this character indicates that the console interceptor should write the string without intercepting it. (The character itself is not written.)</summary> - private readonly char IgnoreChar = '\u200B'; + private const char IgnoreChar = InterceptingTextWriter.IgnoreChar; /// <summary>Get a named monitor instance.</summary> private readonly Func<string, Monitor> GetMonitorImpl; @@ -34,22 +37,22 @@ namespace StardewModdingAPI.Framework.Logging /// <summary>Regex patterns which match console non-error messages to suppress from the console and log.</summary> private readonly Regex[] SuppressConsolePatterns = { - new Regex(@"^TextBox\.Selected is now '(?:True|False)'\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^loadPreferences\(\); begin", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^savePreferences\(\); async=", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^DebugOutput:\s+(?:added cricket|dismount tile|Ping|playerPos)", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^Ignoring keys: ", RegexOptions.Compiled | RegexOptions.CultureInvariant) + 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) }; /// <summary>Regex patterns which match console messages to show a more friendly error for.</summary> private readonly ReplaceLogPattern[] ReplaceConsolePatterns = { // Steam not loaded - new ReplaceLogPattern( + 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 'Launch SMAPI through Steam or GOG Galaxy' in the install guide for more info: https://smapi.io/install.", + "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 @@ -57,7 +60,7 @@ namespace StardewModdingAPI.Framework.Logging ), // save file not found error - new ReplaceLogPattern( + 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 @@ -91,7 +94,7 @@ namespace StardewModdingAPI.Framework.Logging public LogManager(string logPath, ColorSchemeConfig colorConfig, bool writeToConsole, bool isVerbose, bool isDeveloperMode, Func<int?> getScreenIdForLog) { // init construction logic - this.GetMonitorImpl = name => new Monitor(name, this.IgnoreChar, this.LogFile, colorConfig, isVerbose, getScreenIdForLog) + this.GetMonitorImpl = name => new Monitor(name, LogManager.IgnoreChar, this.LogFile, colorConfig, isVerbose, getScreenIdForLog) { WriteToConsole = writeToConsole, ShowTraceInConsole = isDeveloperMode, @@ -104,10 +107,10 @@ namespace StardewModdingAPI.Framework.Logging this.MonitorForGame = this.GetMonitor("game"); // redirect direct console output - var output = new InterceptingTextWriter(Console.Out, this.IgnoreChar); + this.ConsoleInterceptor = new InterceptingTextWriter(Console.Out); if (writeToConsole) - output.OnMessageIntercepted += message => this.HandleConsoleMessage(this.MonitorForGame, message); - Console.SetOut(output); + this.ConsoleInterceptor.OnMessageIntercepted += message => this.HandleConsoleMessage(this.MonitorForGame, message); + Console.SetOut(this.ConsoleInterceptor); // enable Unicode handling on Windows // (the terminal defaults to UTF-8 on Linux/macOS) @@ -146,7 +149,7 @@ namespace StardewModdingAPI.Framework.Logging .Add(new ReloadI18nCommand(reloadTranslations), this.Monitor); // start handling command line input - Thread inputThread = new Thread(() => + Thread inputThread = new(() => { while (true) { @@ -262,7 +265,7 @@ namespace StardewModdingAPI.Framework.Logging public void LogIntro(string modsPath, IDictionary<string, object> customSettings) { // log platform - this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} on {EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform)}", LogLevel.Info); + this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} (build {Constants.GetBuildVersionLabel()}) on {EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform)}", LogLevel.Info); // log basic info this.Monitor.Log($"Mods go here: {modsPath}", LogLevel.Info); @@ -363,7 +366,10 @@ namespace StardewModdingAPI.Framework.Logging // 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) @@ -383,6 +389,7 @@ namespace StardewModdingAPI.Framework.Logging // forward to monitor gameMonitor.Log(message, level); + this.ConsoleInterceptor.IgnoreNextIfNewline = true; } /// <summary>Write a summary of mod warnings to the console and log.</summary> diff --git a/src/SMAPI/Framework/ModLoading/Framework/RewriteHelper.cs b/src/SMAPI/Framework/ModLoading/Framework/RewriteHelper.cs index 60bbd2c7..d7cb2471 100644 --- a/src/SMAPI/Framework/ModLoading/Framework/RewriteHelper.cs +++ b/src/SMAPI/Framework/ModLoading/Framework/RewriteHelper.cs @@ -13,7 +13,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework ** Fields *********/ /// <summary>The comparer which heuristically compares type definitions.</summary> - private static readonly TypeReferenceComparer TypeDefinitionComparer = new TypeReferenceComparer(); + private static readonly TypeReferenceComparer TypeDefinitionComparer = new(); /********* @@ -28,28 +28,6 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework : null; } - /// <summary>Get whether the field is a reference to the expected type and field.</summary> - /// <param name="instruction">The IL instruction.</param> - /// <param name="fullTypeName">The full type name containing the expected field.</param> - /// <param name="fieldName">The name of the expected field.</param> - public static bool IsFieldReferenceTo(Instruction instruction, string fullTypeName, string fieldName) - { - FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); - return RewriteHelper.IsFieldReferenceTo(fieldRef, fullTypeName, fieldName); - } - - /// <summary>Get whether the field is a reference to the expected type and field.</summary> - /// <param name="fieldRef">The field reference to check.</param> - /// <param name="fullTypeName">The full type name containing the expected field.</param> - /// <param name="fieldName">The name of the expected field.</param> - public static bool IsFieldReferenceTo(FieldReference fieldRef, string fullTypeName, string fieldName) - { - return - fieldRef != null - && fieldRef.DeclaringType.FullName == fullTypeName - && fieldRef.Name == fieldName; - } - /// <summary>Get the method reference from an instruction if it matches.</summary> /// <param name="instruction">The IL instruction.</param> public static MethodReference AsMethodReference(Instruction instruction) diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs index 0b679e9d..857a2230 100644 --- a/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs +++ b/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Reflection; using Mono.Cecil; using Mono.Cecil.Cil; @@ -12,54 +13,55 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters /********* ** Fields *********/ - /// <summary>The type containing the field to which references should be rewritten.</summary> - private readonly Type Type; - - /// <summary>The field name to which references should be rewritten.</summary> - private readonly string FromFieldName; - - /// <summary>The new field to reference.</summary> - private readonly FieldInfo ToField; + /// <summary>The new fields to reference indexed by the old field/type names.</summary> + private readonly Dictionary<string, Dictionary<string, FieldInfo>> FieldMaps = new(); /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> + public FieldReplaceRewriter() + : base(defaultPhrase: "field replacement") { } // will be overridden when a field is replaced + + /// <summary>Add a field to replace.</summary> /// <param name="fromType">The type whose field to rewrite.</param> /// <param name="fromFieldName">The field name to rewrite.</param> /// <param name="toType">The new type which will have the field.</param> /// <param name="toFieldName">The new field name to reference.</param> - public FieldReplaceRewriter(Type fromType, string fromFieldName, Type toType, string toFieldName) - : base(defaultPhrase: $"{fromType.FullName}.{fromFieldName} field") + public FieldReplaceRewriter AddField(Type fromType, string fromFieldName, Type toType, string toFieldName) { - this.Type = fromType; - this.FromFieldName = fromFieldName; - this.ToField = toType.GetField(toFieldName); - if (this.ToField == null) + // get full type name + string fromTypeName = fromType?.FullName; + if (fromTypeName == null) + throw new InvalidOperationException($"Can't replace field for invalid type reference {toType}."); + + // get target field + FieldInfo toField = toType.GetField(toFieldName); + if (toField == null) throw new InvalidOperationException($"The {toType.FullName} class doesn't have a {toFieldName} field."); - } - /// <summary>Construct an instance.</summary> - /// <param name="type">The type whose field to rewrite.</param> - /// <param name="fromFieldName">The field name to rewrite.</param> - /// <param name="toFieldName">The new field name to reference.</param> - public FieldReplaceRewriter(Type type, string fromFieldName, string toFieldName) - : this(type, fromFieldName, type, toFieldName) - { + // add mapping + if (!this.FieldMaps.TryGetValue(fromTypeName, out var fieldMap)) + this.FieldMaps[fromTypeName] = fieldMap = new(); + fieldMap[fromFieldName] = toField; + + return this; } /// <inheritdoc /> public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction) { - // get field reference FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); - if (!RewriteHelper.IsFieldReferenceTo(fieldRef, this.Type.FullName, this.FromFieldName)) + string declaringType = fieldRef?.DeclaringType?.FullName; + + // get mapped field + if (declaringType == null || !this.FieldMaps.TryGetValue(declaringType, out var fieldMap) || !fieldMap.TryGetValue(fieldRef.Name, out FieldInfo toField)) return false; // replace with new field - instruction.Operand = module.ImportReference(this.ToField); - + this.Phrases.Add($"{fieldRef.DeclaringType.Name}.{fieldRef.Name} field"); + instruction.Operand = module.ImportReference(toField); return this.MarkRewritten(); } } diff --git a/src/SMAPI/Framework/Monitor.cs b/src/SMAPI/Framework/Monitor.cs index 04e67d68..ab76e7c0 100644 --- a/src/SMAPI/Framework/Monitor.cs +++ b/src/SMAPI/Framework/Monitor.cs @@ -28,7 +28,7 @@ namespace StardewModdingAPI.Framework private static readonly int MaxLevelLength = (from level in Enum.GetValues(typeof(LogLevel)).Cast<LogLevel>() select level.ToString().Length).Max(); /// <summary>A cache of messages that should only be logged once.</summary> - private readonly HashSet<string> LogOnceCache = new HashSet<string>(); + private readonly HashSet<string> LogOnceCache = new(); /// <summary>Get the screen ID that should be logged to distinguish between players in split-screen mode, if any.</summary> private readonly Func<int?> GetScreenIdForLog; diff --git a/src/SMAPI/Metadata/InstructionMetadata.cs b/src/SMAPI/Metadata/InstructionMetadata.cs index 232e54ce..367372b2 100644 --- a/src/SMAPI/Metadata/InstructionMetadata.cs +++ b/src/SMAPI/Metadata/InstructionMetadata.cs @@ -37,9 +37,10 @@ namespace StardewModdingAPI.Metadata if (rewriteMods) { // rewrite for Stardew Valley 1.5 - yield return new FieldReplaceRewriter(typeof(DecoratableLocation), "furniture", typeof(GameLocation), nameof(GameLocation.furniture)); - yield return new FieldReplaceRewriter(typeof(Farm), "resourceClumps", typeof(GameLocation), nameof(GameLocation.resourceClumps)); - yield return new FieldReplaceRewriter(typeof(MineShaft), "resourceClumps", typeof(GameLocation), nameof(GameLocation.resourceClumps)); + yield return new FieldReplaceRewriter() + .AddField(typeof(DecoratableLocation), "furniture", typeof(GameLocation), nameof(GameLocation.furniture)) + .AddField(typeof(Farm), "resourceClumps", typeof(GameLocation), nameof(GameLocation.resourceClumps)) + .AddField(typeof(MineShaft), "resourceClumps", typeof(GameLocation), nameof(GameLocation.resourceClumps)); // heuristic rewrites yield return new HeuristicFieldRewriter(this.ValidateReferencesToAssemblies); @@ -87,7 +88,7 @@ namespace StardewModdingAPI.Metadata typeof(System.IO.DirectoryInfo).FullName, typeof(System.IO.DriveInfo).FullName, typeof(System.IO.FileSystemWatcher).FullName - }, + }, InstructionHandleResult.DetectedFilesystemAccess ); diff --git a/src/SMAPI/i18n/uk.json b/src/SMAPI/i18n/uk.json new file mode 100644 index 00000000..d84aabcf --- /dev/null +++ b/src/SMAPI/i18n/uk.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": "{{season}} День {{day}}", + "generic.date-with-year": "{{season}} День {{day}}, Рік {{year}}" +} |